Compare commits

..

179 Commits

Author SHA1 Message Date
jxxghp
bbe831a57c 优化 transfer.py 中任务处理逻辑,增强错误信息反馈 2026-01-21 23:55:20 +08:00
jxxghp
90c86c056c fix all_tasks 2026-01-21 23:30:39 +08:00
jxxghp
36f22a28df fix 完成状态计算 2026-01-21 23:23:37 +08:00
jxxghp
ac03c51e2c 更新 transfer.py 2026-01-21 23:06:29 +08:00
jxxghp
bd9e92f705 更新 transfer.py 2026-01-21 22:59:30 +08:00
jxxghp
281eff5eb2 更新 version.py 2026-01-21 22:54:31 +08:00
jxxghp
abbd2253ad fix deadlock 2026-01-21 22:46:04 +08:00
jxxghp
46466624ae fix:优化下载器整理控制逻辑 2026-01-21 22:21:17 +08:00
jxxghp
0ba8d51b2a fix:优化下载器整理 2026-01-21 21:31:55 +08:00
jxxghp
a1408ee18f feat:TRANSFER_THREADS 变更监听 2026-01-21 20:46:34 +08:00
jxxghp
58030bbcff fix #5392 2026-01-21 20:12:05 +08:00
jxxghp
e1b3e6ef01 fix:只有媒体文件整完成才触发事件,以保持与历史一致 2026-01-21 20:07:18 +08:00
jxxghp
298a6ba8ab 更新 update_subscribe.py 2026-01-21 19:36:12 +08:00
jxxghp
e5bf47629f 更新 config.py 2026-01-21 19:13:36 +08:00
jxxghp
ea29ee9f66 Merge pull request #5390 from xiaoQQya/develop 2026-01-21 18:39:06 +08:00
jxxghp
868c2254de v2.9.5 2026-01-21 17:59:52 +08:00
jxxghp
567522c87a fix:统一调整文件类型支持 2026-01-21 17:59:18 +08:00
jxxghp
25fd47f57b Merge pull request #5389 from hyuan280/v2 2026-01-21 17:22:27 +08:00
hyuan280
f89d6342d1 fix: 修复Cookie解码二进制数据导致请求发送时UnicodeEncodeError 2026-01-21 16:36:28 +08:00
jxxghp
b02affdea3 Merge pull request #5388 from cddjr/fix_tmdb_img_url 2026-01-21 13:24:39 +08:00
景大侠
6e5ade943b 修复 订阅无法查看文件列表的问题
TMDB图片路径参数增加空值检查
2026-01-21 12:47:39 +08:00
jxxghp
a6ed0c0d00 fix:优化transhandler线程安全 2026-01-21 08:42:57 +08:00
jxxghp
68402aadd7 fix:去除文件操作全局锁 2026-01-21 08:31:51 +08:00
jxxghp
85cacd447b feat: 为文件整理服务引入多线程处理并优化进度管理。 2026-01-21 08:16:02 +08:00
xiaoQQya
11262b321a fix(rousi pro): 修复 Rousi Pro 站点未读消息未推送通知的问题 2026-01-20 22:12:31 +08:00
jxxghp
bf290f063d Merge pull request #5386 from PKC278/v2 2026-01-20 22:09:02 +08:00
PKC278
7ac0fbaf76 fix(otp): 修正 OTP 关闭逻辑 2026-01-20 19:53:59 +08:00
PKC278
7489c76722 feat(passkey): 允许在未开启 OTP 时注册通行密钥 2026-01-20 19:35:36 +08:00
jxxghp
bcdf1b6efe 更新 transhandler.py 2026-01-20 15:29:28 +08:00
jxxghp
8a9dbe212c Merge pull request #5385 from cddjr/feature_optimize_transfer 2026-01-20 15:25:38 +08:00
景大侠
16bd71a6cb 优化整理代码效率、减少额外递归 2026-01-20 14:38:41 +08:00
jxxghp
71caad0655 feat:优化蓝光目录判断,减少目录遍历 2026-01-20 13:38:52 +08:00
jxxghp
2c62ffe34a feat:优化字幕和音频文件整理方式 2026-01-20 13:24:35 +08:00
jxxghp
3450a89880 Merge pull request #5383 from jxxghp/copilot/merge-agent-and-execution-messages 2026-01-20 00:03:23 +08:00
copilot-swe-agent[bot]
a081a69bbe Simplify message merging logic using list join
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-01-19 16:00:36 +00:00
copilot-swe-agent[bot]
271d1d23d5 Merge agent and tool execution messages into a single message
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-01-19 15:59:21 +00:00
copilot-swe-agent[bot]
605aba1a3c Initial plan 2026-01-19 15:55:13 +00:00
jxxghp
be3c2b4c7c Merge pull request #5382 from jxxghp/copilot/fix-tool-call-id-error 2026-01-19 21:36:09 +08:00
copilot-swe-agent[bot]
08eb32d7bd Fix isinstance syntax error for int/float type checking
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-01-19 13:33:01 +00:00
copilot-swe-agent[bot]
2b9cda15e4 Fix tool_call_id error by adding metadata to tool_result and using it in ToolMessage
Co-authored-by: jxxghp <51039935+jxxghp@users.noreply.github.com>
2026-01-19 13:31:43 +00:00
copilot-swe-agent[bot]
f6055b290a Initial plan 2026-01-19 13:28:07 +00:00
jxxghp
ec665e05e4 Merge pull request #5379 from Pollo3470/v2 2026-01-19 21:17:53 +08:00
jxxghp
2b6d7205ec Merge pull request #5378 from cddjr/fix_tv_dir_scrape 2026-01-19 17:47:11 +08:00
Pollo
41381a920c fix: 修复订阅自定义识别词在整理时不生效的问题
问题:订阅中添加的自定义识别词(特别是集数偏移)在下载时正常生效,
但在下载完成整理时没有生效。

根因:下载历史中未保存识别词,整理时 MetaInfoPath 未接收
custom_words 参数。

修复:
- 在 DownloadHistory 模型中添加 custom_words 字段
- 下载时从 meta.apply_words 获取并保存识别词到下载历史
- MetaInfoPath 函数添加 custom_words 参数支持
- 整理时从下载历史获取 custom_words 并传递给 MetaInfoPath
- 添加 Alembic 迁移脚本 (2.2.3)
- 添加相关单元测试
2026-01-19 15:46:00 +08:00
大虾
f1b3fc2254 更新注释 2026-01-19 10:11:54 +08:00
景大侠
a677ed307d 修复 剧集nfo文件刮削了错误的tmdb id
应使用剧集id而非剧id
2026-01-18 16:23:05 +08:00
景大侠
0ab23ee972 修复 刮削电视剧目录会误判剧集根目录为季目录
因辅助识别词指定了季号
2026-01-18 15:17:22 +08:00
景大侠
43f56d39be 修复 手动刮削电视剧目录可能会遗漏特别季 2026-01-18 01:51:35 +08:00
jxxghp
a39caee5f5 Merge pull request #5371 from cddjr/remove_unused_finished_files 2026-01-17 07:50:54 +08:00
景大侠
2edfdf47c8 移除整理进度数据中无用的文件列表 2026-01-17 00:20:09 +08:00
jxxghp
3819461db5 更新 version.py 2026-01-16 19:27:57 +08:00
jxxghp
85654dd7dd Merge pull request #5367 from PKC278/v2 2026-01-15 22:58:10 +08:00
PKC278
619a70416b fix: 修正智能推荐功能未检查智能助手总开关的问题 2026-01-15 22:57:49 +08:00
jxxghp
16d996fe70 Merge pull request #5366 from xiaoQQya/develop 2026-01-15 21:11:10 +08:00
xiaoQQya
1baeb6da19 feat(rousi pro): 支持解析 Rousi Pro 站点未读消息 2026-01-15 21:08:08 +08:00
jxxghp
1641d432dd feat: 为工具管理器添加参数类型规范化处理,并基于渠道能力动态生成提示词中的格式要求 2026-01-15 20:55:35 +08:00
jxxghp
1bf9862e47 feat: 更新代理提示词,增加详细的沟通、状态更新、总结、操作流程、工具使用和媒体管理规则。 2026-01-15 19:50:37 +08:00
jxxghp
602a394043 Merge pull request #5362 from cddjr/feat_extended_api_token_support 2026-01-15 13:33:56 +08:00
景大侠
22a2415ca5 缓存api鉴权结果 2026-01-15 12:38:47 +08:00
景大侠
feb034352d 让现有基于JWT令牌鉴权的接口也能支持API令牌鉴权 2026-01-15 12:30:03 +08:00
jxxghp
a7c8942c78 Merge pull request #5358 from PKC278/v2 2026-01-15 07:04:21 +08:00
PKC278
95f2ac3811 feat(search): 添加AI推荐功能并优化相关逻辑 2026-01-15 02:49:29 +08:00
jxxghp
91354295f2 Merge pull request #5356 from cddjr/fix_manual_transfer 2026-01-14 22:48:24 +08:00
景大侠
c9c4ab5911 修复 手动重新整理没有更新源文件大小的问题
- V1迁移过来的记录,重整理后文件大小显示为0
- 部分源文件大小有变动,重整理后大小显示没变化
2026-01-14 22:42:36 +08:00
jxxghp
a26c5e40dd Merge pull request #5354 from cddjr/fix_media_root_path 2026-01-14 19:04:53 +08:00
景大侠
80f5c7bc44 修复 整理文件或目录时没有正确应用多层标题的重命名格式
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-14 19:02:17 +08:00
jxxghp
4833b39c52 Merge pull request #5352 from cddjr/fix_concurrency_systemconfig 2026-01-13 16:58:21 +08:00
景大侠
f478958943 修复 SystemConfig潜在的资源竞争问题
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-13 14:33:53 +08:00
jxxghp
0469ad46d6 Merge pull request #5351 from winter0245/v2 2026-01-13 11:48:28 +08:00
winter0245
5fe5deb9df Merge branch 'jxxghp:v2' into v2 2026-01-13 09:30:16 +08:00
xjy
ce83bc24bd fix: 修复站点Cookie处理的两个关键问题
本次提交修复了PT站点搜索功能失败的两个根本原因:

1. **Cookie URL解码问题**
   - 问题:数据库中存储的Cookie值包含URL编码(如%3D、%2B、%2F),
     但cookie_parse()函数未进行解码
   - 影响:所有使用URL编码Cookie的站点可能无法正常登录
   - 修复:在app/utils/http.py的cookie_parse()中添加unquote()解码

2. **httpx Cookie jar覆盖问题**(关键)
 - 问题:httpx.AsyncClient的Cookie jar机制会自动保存服务器返回的
 Set-Cookie,并在后续请求中覆盖我们传入的Cookie
 - 表现:传入正确的c_secure_uid/c_secure_pass,实际发送的却是
 PHPSESSID等错误Cookie
 - 修复:在创建AsyncClient时传入Cookie,而不是在request()时传入

修改文件:
- app/utils/http.py: cookie_parse()添加URL解码 + AsyncClient传入cookies
- app/modules/indexer/spider/__init__.py: 清理调试代码

测试验证:
-  pterclub 搜索功能恢复正常
-  春天站点搜索功能正常(验证通用性)
2026-01-13 09:29:05 +08:00
jxxghp
dce729c8cb Merge pull request #5350 from cddjr/fix_tmdb_cache 2026-01-13 07:04:58 +08:00
jxxghp
a9d17cd96f Merge pull request #5349 from cddjr/fix_bluray 2026-01-13 07:03:16 +08:00
景大侠
294bb3d4a1 修复 目录监控无法触发蓝光原盘整理 2026-01-13 00:09:34 +08:00
景大侠
b31b9261f2 Update app/core/config.py
接受AI的建议
2026-01-12 23:31:44 +08:00
大虾
2211f8d9e4 Update tests/test_bluray.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-12 23:17:39 +08:00
景大侠
b9b7b00a7f 修复 下载器监控构造的原盘路径需以/结尾 2026-01-12 22:54:19 +08:00
景大侠
843faf6103 修复 整理记录无法显示原盘大小 2026-01-12 22:14:43 +08:00
景大侠
4af5dad9a8 修复 原盘自动刮削缺少nfo 2026-01-12 21:36:17 +08:00
景大侠
52437c9d18 按语言缓存tmdb元数据 2026-01-12 09:41:34 +08:00
景大侠
c6cb4c8479 统一构造tmdb图片网址 2026-01-12 09:41:25 +08:00
jxxghp
c3714ec251 Merge pull request #5346 from HankunYu/v2 2026-01-12 09:04:35 +08:00
HankunYu
dbe2f94af1 修改embed解析以支持emoji字符 2026-01-12 00:46:26 +00:00
jxxghp
07fd5f8a9e Merge pull request #5344 from PKC278/v2 2026-01-11 20:30:28 +08:00
PKC278
9e64b4cd7f refactor: 优化登录安全性并重构 PassKey 逻辑
- 统一登录失败返回信息,防止信息泄露
- 提取 PassKeyHelper 公共函数,简化 Base64 和凭证处理
- 重构 mfa.py 端点代码,提升可读性和维护性
- 移除冗余的 origin 验证逻辑
2026-01-11 19:20:53 +08:00
jxxghp
f08a7b9eb3 Merge pull request #5343 from Lyzd1/v2 2026-01-10 19:10:04 +08:00
The falling leaves know
a6fa764e2a Change media_type to required field in QueryMediaDetailInput 2026-01-10 18:49:02 +08:00
jxxghp
01676668f1 Merge pull request #5342 from Lyzd1/v2 2026-01-10 17:58:13 +08:00
The falling leaves know
8e5e4f460d Enhance media detail query with media type handling 2026-01-10 17:44:11 +08:00
jxxghp
f907b8a84d v2.9.3
- 优化通行密钥登录体验
- 优化智能体
- 支持Rousi Pro全新架构站点
2026-01-10 10:32:56 +08:00
jxxghp
a3a4285f90 Merge pull request #5339 from PKC278/v2 2026-01-10 07:42:38 +08:00
PKC278
0979163b79 fix(rousi): 修正分类参数为单一值以符合API要求 2026-01-10 02:12:33 +08:00
PKC278
248a25eaee fix(rousi): 移除单例模式 2026-01-10 01:39:40 +08:00
PKC278
f95b1fa68a fix(rousi): 修正分类映射 2026-01-10 01:31:12 +08:00
PKC278
d2b5d69051 feat(rousi): 重构响应处理逻辑以提高代码可读性和维护性 2026-01-10 00:54:43 +08:00
PKC278
3ca419b735 fix(rousi): 精简并修正分类映射 2026-01-10 00:27:45 +08:00
PKC278
50e275a2f9 feat(config): 增加最大搜索名称数量限制至3 确保包含 en_title 2026-01-09 23:53:09 +08:00
PKC278
aeccf78957 feat(rousi): 新增分类参数支持以优化搜索功能 2026-01-09 23:05:02 +08:00
PKC278
cb3cef70e5 feat: 新增 RousiPro 站点支持 2026-01-09 22:08:24 +08:00
jxxghp
b9bd303bf8 fix:优化Agent参数校验,避免中止推理 2026-01-09 20:26:49 +08:00
jxxghp
57d4786a7f Merge pull request #5332 from PKC278/v2 2026-01-08 07:47:01 +08:00
PKC278
df031455b2 feat(agent): 新增媒体详情查询工具 2026-01-07 23:31:08 +08:00
jxxghp
30059eff4f Merge pull request #5319 from cddjr/fix_5314 2026-01-04 18:59:26 +08:00
景大侠
bc289b48c8 修复 字幕支持通过代理下载 2026-01-04 16:07:45 +08:00
jxxghp
067d8b99b8 更新 version.py 2026-01-04 13:19:05 +08:00
jxxghp
00a6a9c42d Merge pull request #5317 from cddjr/fix_MetaInfoPath 2026-01-03 20:52:48 +08:00
大虾
070425d446 Update app/core/metainfo.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-03 19:26:03 +08:00
大虾
7405883444 Update app/core/metainfo.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-03 19:25:54 +08:00
景大侠
66959937ed 修复 电影文件可能会误识别成电视剧类型 2026-01-03 18:06:26 +08:00
jxxghp
e431efbcba Merge pull request #5315 from cddjr/fix_movie_scrape_image 2026-01-03 07:19:32 +08:00
景大侠
ba00baa5a0 修复 刮削电影会误报父目录的海报图已存在 2026-01-02 23:37:36 +08:00
jxxghp
0fb5d4a164 Merge pull request #5312 from PKC278/v2 2026-01-02 18:23:37 +08:00
PKC278
1ac717b67f fix(message): 修复缓存数据处理逻辑以避免空值错误 2026-01-02 17:12:33 +08:00
jxxghp
273cbd447e Merge pull request #5309 from Seed680/v2 2026-01-01 23:30:41 +08:00
noone
cee41567a2 feat(chain): 添加当前时间参数到消息渲染
- 在MessageTemplateHelper.render调用中添加current_time参数
2026-01-01 23:01:25 +08:00
jxxghp
1aae5eb1a6 Merge pull request #5307 from cddjr/fix_mteam_promotions 2026-01-01 13:54:58 +08:00
景大侠
28a4c81aff 识别馒头站点的全站促销规则 2026-01-01 13:10:41 +08:00
jxxghp
5e077cd64d 更新 version.py 2025-12-31 07:49:12 +08:00
jxxghp
e3f957a59b 更新 __init__.py 2025-12-31 07:20:52 +08:00
jxxghp
55c62a3ab5 Merge pull request #5303 from HankunYu/v2 2025-12-31 07:00:04 +08:00
jxxghp
22e7eef1bd Merge pull request #5302 from cddjr/fix_tmdb_healthcheck 2025-12-31 06:59:03 +08:00
HankunYu
d6524907f3 修复重载模块会产生新的DC实例;建立embed解析白名单,不解析插件等消息以免破坏原有格式 2025-12-30 16:51:30 +00:00
景大侠
357db334cd 修复 自建TMDB服无法通过健康检测
携带UA以避免被反爬虫脚本过滤
2025-12-30 22:13:43 +08:00
jxxghp
f8bed3909b Merge pull request #5299 from cddjr/fix_5297 2025-12-30 15:52:29 +08:00
景大侠
182bbdde91 fix #5297 2025-12-30 15:21:27 +08:00
jxxghp
2c70f990c2 Merge pull request #5294 from cddjr/mteam_subtitle 2025-12-30 06:57:15 +08:00
景大侠
0b01a6aa91 避免获取到字幕上传者的详情链接 2025-12-29 22:52:26 +08:00
景大侠
e557dffbc6 支持憨憨站点的字幕下载 2025-12-29 22:43:47 +08:00
景大侠
7f33b0b1b8 支持馒头站点的字幕下载 2025-12-29 22:43:07 +08:00
景大侠
41ddf77a5b 添加馒头字幕API 2025-12-29 20:01:54 +08:00
jxxghp
8c657ce41d 更新 version.py 2025-12-28 17:58:39 +08:00
jxxghp
3ff3b9ed4a Merge pull request #5290 from PKC278/v2 2025-12-28 17:58:05 +08:00
PKC278
ef43419ecd Update app/api/endpoints/system.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2025-12-28 16:38:30 +08:00
PKC278
2ca375c214 feat(system): 添加前端和后端版本信息 2025-12-28 16:08:14 +08:00
jxxghp
cbd45c1d0f Merge pull request #5289 from HankunYu/v2 2025-12-28 12:50:40 +08:00
HankunYu
2592ea3464 清理 prefix/suffix 与字段值的分隔符;字段名允许 &;当冒号落在 《》/【】 内时整行作为描述,避免书名号误拆 2025-12-27 17:00:07 +00:00
HankunYu
73ac97cd96 更新解析embed逻辑; 添加使用代理 2025-12-27 13:05:57 +00:00
jxxghp
e014663e97 更新 version.py 2025-12-27 14:22:35 +08:00
jxxghp
58592e961f Merge pull request #5283 from PKC278/v2 2025-12-26 23:25:24 +08:00
PKC278
9a99b9ce82 fix(system): 更新global返回字段,采用白名单模式 2025-12-26 23:02:40 +08:00
jxxghp
8c6dca1751 Merge pull request #5277 from Seed680/v2 2025-12-25 19:26:26 +08:00
noone
cf488d5f5f fix(qbittorrent): 修复种子文件读取和重复检查问题
- 将变量名从 torrent 改为 torrent_from_file 以避免混淆
- 修复添加种子任务失败时的错误检查逻辑
- 使用 getattr 函数安全获取种子文件的名称和大小属性
- 修复已存在种子任务检查时的属性访问问题

fix(transmission): 修复种子添加和重复检查逻辑

- 将变量名从 torrent 改为 torrent_from_file 以避免混淆
- 修复添加任务后的返回值变量名
- 使用 getattr 函数安全获取种子文件的名称和大小属性
- 修复已存在种子任务检查时的属性访问问题
- 修正种子哈希获取的变量引用
2025-12-25 19:09:45 +08:00
jxxghp
515584d34c fix warnings 2025-12-24 22:04:04 +08:00
jxxghp
fb2becc7f2 v2.8.9
- 支持Discord通知渠道
- 支持使用通行密钥登录
2025-12-24 19:41:58 +08:00
jxxghp
0f8ceb0fac fix warnings 2025-12-24 18:54:38 +08:00
jxxghp
a70bf18770 Merge pull request #5273 from PKC278/v2 2025-12-23 17:36:30 +08:00
PKC278
2de83c44ab refactor(mcp): 精简会话管理逻辑并更新API文档 2025-12-23 17:06:17 +08:00
PKC278
7b99f09810 fix(mfa): 修复双重验证漏洞 2025-12-23 14:58:00 +08:00
jxxghp
6b4ba8bfad Merge pull request #5272 from PKC278/v2 2025-12-23 14:39:03 +08:00
PKC278
0c6cfc5020 feat(passkey): 添加PassKey支持并优化双重验证登录逻辑 2025-12-23 13:53:54 +08:00
jxxghp
abd9733e7f Merge pull request #5269 from HankunYu/v2 2025-12-23 12:51:25 +08:00
HankunYu
98c3ae5e76 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot into v2 2025-12-22 21:00:47 +00:00
HankunYu
bb5a657469 更新Discord模块支持互动消息 2025-12-22 19:59:22 +00:00
jxxghp
7797532350 Merge pull request #5271 from PKC278/v2 2025-12-22 21:32:53 +08:00
PKC278
c3a5106adc feat(manager): 添加工具调用参数格式自动转换功能 2025-12-22 21:04:13 +08:00
HankunYu
c5fd935dd0 Merge branch 'v2' of https://github.com/jxxghp/MoviePilot into v2 2025-12-22 12:19:21 +00:00
jxxghp
ec375a19ae Merge pull request #5267 from stkevintan/cookiecloud-post 2025-12-22 19:06:05 +08:00
jxxghp
51e940617c Merge pull request #5270 from PKC278/v2 2025-12-22 18:50:12 +08:00
PKC278
58ec8bd437 feat(mcp): 实现标准MCP协议支持和会话管理功能 2025-12-22 18:49:00 +08:00
jxxghp
a096395086 Merge pull request #5250 from ixff/v2 2025-12-22 11:04:47 +08:00
HankunYu
4bd08bd915 通知渠道增加Discord 2025-12-22 02:15:28 +00:00
stkevintan
2c849cfa7a fix code style 2025-12-22 08:33:23 +08:00
stkevintan
501d530d1d cookiecloud: support download encrypted data using post 2025-12-21 23:07:35 +08:00
jxxghp
91fc4327f4 Merge pull request #5261 from ixff/fix 2025-12-19 12:38:43 +08:00
ixff
8d56c67079 fix typos 2025-12-19 12:19:42 +08:00
jxxghp
e52d43458e 更新 version.py 2025-12-15 15:19:57 +08:00
ixff
9b125bf9b0 feat: 支持选择Playwright浏览器环境 2025-12-14 23:15:28 +08:00
jxxghp
0716c65269 Refactor: Simplify memory key generation and update retention settings 2025-12-13 15:40:20 +08:00
jxxghp
ba3ce4f1b5 Merge pull request #5245 from jxxghp/cursor/agent-download-progress-tool-8daa 2025-12-13 15:09:54 +08:00
Cursor Agent
07f72b0cdc Refactor: Improve query download tasks logic and add status filtering
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-12-13 07:01:24 +00:00
Cursor Agent
bda19df87f Fix: Ensure list_torrents and downloading return empty lists
Co-authored-by: jxxghp <jxxghp@qq.com>
2025-12-13 06:53:06 +00:00
jxxghp
5d82fae2b0 fix agent memory 2025-12-13 14:40:47 +08:00
jxxghp
0813b87221 fix agent memory 2025-12-13 13:23:41 +08:00
jxxghp
961ecfc720 fix agent memory 2025-12-13 13:09:49 +08:00
jxxghp
81f30ef25a fix agent memory 2025-12-13 12:26:08 +08:00
jxxghp
140b0d3df2 Merge pull request #5234 from xgitc/patch-2 2025-12-10 16:20:36 +08:00
jxxghp
b3d69d7de4 Merge pull request #5233 from xgitc/patch-1 2025-12-10 16:20:07 +08:00
xgitc
8e65564fb8 适配不同版本的gazelle程序
适配隐藏了URL中“.php”的站点;适配下一页按钮title为“下一页”或“Next”的站点。
2025-12-10 16:12:42 +08:00
xgitc
06ce9bd4de 适配更多促销类型 2025-12-10 15:54:03 +08:00
86 changed files with 5759 additions and 1778 deletions

View File

@@ -1,5 +1,3 @@
"""MoviePilot AI智能体实现"""
import asyncio
from typing import Dict, List, Any
@@ -7,15 +5,16 @@ from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.callbacks import get_openai_callback
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.messages import HumanMessage, AIMessage, ToolCall
from langchain_core.messages import HumanMessage, AIMessage, ToolCall, ToolMessage, SystemMessage
from langchain_core.runnables.history import RunnableWithMessageHistory
from app.agent.callback import StreamingCallbackHandler
from app.agent.memory import ConversationMemoryManager
from app.agent.prompt import PromptManager
from app.agent.memory import conversation_manager
from app.agent.prompt import prompt_manager
from app.agent.tools.factory import MoviePilotToolFactory
from app.chain import ChainBase
from app.core.config import settings
from app.helper.llm import LLMHelper
from app.helper.message import MessageHelper
from app.log import logger
from app.schemas import Notification
@@ -26,7 +25,9 @@ class AgentChain(ChainBase):
class MoviePilotAgent:
"""MoviePilot AI智能体"""
"""
MoviePilot AI智能体
"""
def __init__(self, session_id: str, user_id: str = None,
channel: str = None, source: str = None, username: str = None):
@@ -39,12 +40,6 @@ class MoviePilotAgent:
# 消息助手
self.message_helper = MessageHelper()
# 记忆管理器
self.memory_manager = ConversationMemoryManager()
# 提示词管理器
self.prompt_manager = PromptManager()
# 回调处理器
self.callback_handler = StreamingCallbackHandler(
session_id=session_id
@@ -56,9 +51,6 @@ class MoviePilotAgent:
# 工具
self.tools = self._initialize_tools()
# 会话存储
self.session_store = self._initialize_session_store()
# 提示词模板
self.prompt = self._initialize_prompt()
@@ -66,61 +58,15 @@ class MoviePilotAgent:
self.agent_executor = self._create_agent_executor()
def _initialize_llm(self):
"""初始化LLM模型"""
provider = settings.LLM_PROVIDER.lower()
api_key = settings.LLM_API_KEY
if provider == "google":
if settings.PROXY_HOST:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=api_key,
max_retries=3,
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
temperature=settings.LLM_TEMPERATURE,
streaming=True,
callbacks=[self.callback_handler],
stream_usage=True,
openai_proxy=settings.PROXY_HOST
)
else:
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=settings.LLM_MODEL,
google_api_key=api_key,
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=True,
callbacks=[self.callback_handler]
)
elif provider == "deepseek":
from langchain_deepseek import ChatDeepSeek
return ChatDeepSeek(
model=settings.LLM_MODEL,
api_key=api_key,
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=True,
callbacks=[self.callback_handler],
stream_usage=True
)
else:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=api_key,
max_retries=3,
base_url=settings.LLM_BASE_URL,
temperature=settings.LLM_TEMPERATURE,
streaming=True,
callbacks=[self.callback_handler],
stream_usage=True,
openai_proxy=settings.PROXY_HOST
)
"""
初始化LLM模型
"""
return LLMHelper.get_llm(streaming=True, callbacks=[self.callback_handler])
def _initialize_tools(self) -> List:
"""初始化工具列表"""
"""
初始化工具列表
"""
return MoviePilotToolFactory.create_tools(
session_id=self.session_id,
user_id=self.user_id,
@@ -132,43 +78,55 @@ class MoviePilotAgent:
@staticmethod
def _initialize_session_store() -> Dict[str, InMemoryChatMessageHistory]:
"""初始化内存存储"""
"""
初始化内存存储
"""
return {}
def get_session_history(self, session_id: str) -> InMemoryChatMessageHistory:
"""获取会话历史"""
if session_id not in self.session_store:
chat_history = InMemoryChatMessageHistory()
messages: List[dict] = self.memory_manager.get_recent_messages_for_agent(
session_id=session_id,
user_id=self.user_id
)
if messages:
for msg in messages:
if msg.get("role") == "user":
chat_history.add_user_message(HumanMessage(content=msg.get("content", "")))
elif msg.get("role") == "agent":
chat_history.add_ai_message(AIMessage(content=msg.get("content", "")))
elif msg.get("role") == "tool_call":
metadata = msg.get("metadata", {})
chat_history.add_ai_message(AIMessage(
"""
获取会话历史
"""
chat_history = InMemoryChatMessageHistory()
messages: List[dict] = conversation_manager.get_recent_messages_for_agent(
session_id=session_id,
user_id=self.user_id
)
if messages:
for msg in messages:
if msg.get("role") == "user":
chat_history.add_message(HumanMessage(content=msg.get("content", "")))
elif msg.get("role") == "agent":
chat_history.add_message(AIMessage(content=msg.get("content", "")))
elif msg.get("role") == "tool_call":
metadata = msg.get("metadata", {})
chat_history.add_message(
AIMessage(
content=msg.get("content", ""),
tool_calls=[ToolCall(
id=metadata.get("call_id"),
name=metadata.get("tool_name"),
args=metadata.get("parameters"),
)]
))
elif msg.get("role") == "tool_result":
chat_history.add_ai_message(AIMessage(content=msg.get("content", "")))
elif msg.get("role") == "system":
chat_history.add_ai_message(AIMessage(content=msg.get("content", "")))
self.session_store[session_id] = chat_history
return self.session_store[session_id]
tool_calls=[
ToolCall(
id=metadata.get("call_id"),
name=metadata.get("tool_name"),
args=metadata.get("parameters"),
)
]
)
)
elif msg.get("role") == "tool_result":
metadata = msg.get("metadata", {})
chat_history.add_message(ToolMessage(
content=msg.get("content", ""),
tool_call_id=metadata.get("call_id", "unknown")
))
elif msg.get("role") == "system":
chat_history.add_message(SystemMessage(content=msg.get("content", "")))
return chat_history
@staticmethod
def _initialize_prompt() -> ChatPromptTemplate:
"""初始化提示词模板"""
"""
初始化提示词模板
"""
try:
prompt_template = ChatPromptTemplate.from_messages([
("system", "{system_prompt}"),
@@ -183,7 +141,9 @@ class MoviePilotAgent:
raise e
def _create_agent_executor(self) -> RunnableWithMessageHistory:
"""创建Agent执行器"""
"""
创建Agent执行器
"""
try:
agent = create_openai_tools_agent(
llm=self.llm,
@@ -210,10 +170,12 @@ class MoviePilotAgent:
raise e
async def process_message(self, message: str) -> str:
"""处理用户消息"""
"""
处理用户消息
"""
try:
# 添加用户消息到记忆
await self.memory_manager.add_memory(
await conversation_manager.add_conversation(
self.session_id,
user_id=self.user_id,
role="user",
@@ -222,7 +184,7 @@ class MoviePilotAgent:
# 构建输入上下文
input_context = {
"system_prompt": self.prompt_manager.get_agent_prompt(channel=self.channel),
"system_prompt": prompt_manager.get_agent_prompt(channel=self.channel),
"input": message
}
@@ -239,7 +201,7 @@ class MoviePilotAgent:
await self.send_agent_message(agent_message)
# 添加Agent回复到记忆
await self.memory_manager.add_memory(
await conversation_manager.add_conversation(
session_id=self.session_id,
user_id=self.user_id,
role="agent",
@@ -259,7 +221,9 @@ class MoviePilotAgent:
return error_message
async def _execute_agent(self, input_context: Dict[str, Any]) -> Dict[str, Any]:
"""执行LangChain Agent"""
"""
执行LangChain Agent
"""
try:
with get_openai_callback() as cb:
result = await self.agent_executor.ainvoke(
@@ -292,7 +256,9 @@ class MoviePilotAgent:
}
async def send_agent_message(self, message: str, title: str = "MoviePilot助手"):
"""通过原渠道发送消息给用户"""
"""
通过原渠道发送消息给用户
"""
await AgentChain().async_post_message(
Notification(
channel=self.channel,
@@ -305,26 +271,32 @@ class MoviePilotAgent:
)
async def cleanup(self):
"""清理智能体资源"""
if self.session_id in self.session_store:
del self.session_store[self.session_id]
"""
清理智能体资源
"""
logger.info(f"MoviePilot智能体已清理: session_id={self.session_id}")
class AgentManager:
"""AI智能体管理器"""
"""
AI智能体管理器
"""
def __init__(self):
self.active_agents: Dict[str, MoviePilotAgent] = {}
self.memory_manager = ConversationMemoryManager()
async def initialize(self):
"""初始化管理器"""
await self.memory_manager.initialize()
@staticmethod
async def initialize():
"""
初始化管理器
"""
await conversation_manager.initialize()
async def close(self):
"""关闭管理器"""
await self.memory_manager.close()
"""
关闭管理器
"""
await conversation_manager.close()
# 清理所有活跃的智能体
for agent in self.active_agents.values():
await agent.cleanup()
@@ -332,7 +304,9 @@ class AgentManager:
async def process_message(self, session_id: str, user_id: str, message: str,
channel: str = None, source: str = None, username: str = None) -> str:
"""处理用户消息"""
"""
处理用户消息
"""
# 获取或创建Agent实例
if session_id not in self.active_agents:
logger.info(f"创建新的AI智能体实例session_id: {session_id}, user_id: {user_id}")
@@ -343,7 +317,6 @@ class AgentManager:
source=source,
username=username
)
agent.memory_manager = self.memory_manager
self.active_agents[session_id] = agent
else:
agent = self.active_agents[session_id]
@@ -360,12 +333,14 @@ class AgentManager:
return await agent.process_message(message)
async def clear_session(self, session_id: str, user_id: str):
"""清空会话"""
"""
清空会话
"""
if session_id in self.active_agents:
agent = self.active_agents[session_id]
await agent.cleanup()
del self.active_agents[session_id]
await self.memory_manager.clear_memory(session_id, user_id)
await conversation_manager.clear_memory(session_id, user_id)
logger.info(f"会话 {session_id} 的记忆已清空")

View File

@@ -6,7 +6,9 @@ from app.log import logger
class StreamingCallbackHandler(AsyncCallbackHandler):
"""流式输出回调处理器"""
"""
流式输出回调处理器
"""
def __init__(self, session_id: str):
self._lock = threading.Lock()
@@ -14,7 +16,9 @@ class StreamingCallbackHandler(AsyncCallbackHandler):
self.current_message = ""
async def get_message(self):
"""获取当前消息内容,获取后清空"""
"""
获取当前消息内容,获取后清空
"""
with self._lock:
if not self.current_message:
return ""
@@ -24,7 +28,9 @@ class StreamingCallbackHandler(AsyncCallbackHandler):
return msg
async def on_llm_new_token(self, token: str, **kwargs):
"""处理新的token"""
"""
处理新的token
"""
if not token:
return
with self._lock:

View File

@@ -12,7 +12,9 @@ from app.schemas.agent import ConversationMemory
class ConversationMemoryManager:
"""对话记忆管理器"""
"""
对话记忆管理器
"""
def __init__(self):
# 内存中的会话记忆缓存
@@ -23,7 +25,9 @@ class ConversationMemoryManager:
self.cleanup_task: Optional[asyncio.Task] = None
async def initialize(self):
"""初始化记忆管理器"""
"""
初始化记忆管理器
"""
try:
# 启动内存缓存清理任务Redis通过TTL自动过期
self.cleanup_task = asyncio.create_task(self._cleanup_expired_memories())
@@ -33,7 +37,9 @@ class ConversationMemoryManager:
logger.warning(f"Redis连接失败将使用内存存储: {e}")
async def close(self):
"""关闭记忆管理器"""
"""
关闭记忆管理器
"""
if self.cleanup_task:
self.cleanup_task.cancel()
try:
@@ -45,47 +51,84 @@ class ConversationMemoryManager:
logger.info("对话记忆管理器已关闭")
async def get_memory(self, session_id: str, user_id: str) -> ConversationMemory:
"""获取会话记忆"""
# 首先检查缓存
cache_key = f"{user_id}:{session_id}" if user_id else session_id
if cache_key in self.memory_cache:
return self.memory_cache[cache_key]
@staticmethod
def _get_memory_key(session_id: str, user_id: str):
"""
计算内存Key
"""
return f"{user_id}:{session_id}" if user_id else session_id
# 尝试从Redis加载
@staticmethod
def _get_redis_key(session_id: str, user_id: str):
"""
计算Redis Key
"""
return f"agent_memory:{user_id}:{session_id}" if user_id else f"agent_memory:{session_id}"
def _get_memory(self, session_id: str, user_id: str):
"""
获取内存中的记忆
"""
cache_key = self._get_memory_key(session_id, user_id)
return self.memory_cache.get(cache_key)
async def _get_redis(self, session_id: str, user_id: str) -> Optional[ConversationMemory]:
"""
从Redis获取记忆
"""
if settings.CACHE_BACKEND_TYPE == "redis":
try:
redis_key = f"agent_memory:{user_id}:{session_id}" if user_id else f"agent_memory:{session_id}"
redis_key = self._get_redis_key(session_id, user_id)
memory_data = await self.redis_helper.get(redis_key, region="AI_AGENT")
if memory_data:
memory_dict = json.loads(memory_data) if isinstance(memory_data, str) else memory_data
memory = ConversationMemory(**memory_dict)
self.memory_cache[cache_key] = memory
return memory
except Exception as e:
logger.warning(f"从Redis加载记忆失败: {e}")
return None
async def get_conversation(self, session_id: str, user_id: str) -> ConversationMemory:
"""
获取会话记忆
"""
# 首先检查缓存
conversion = self._get_memory(session_id, user_id)
if conversion:
return conversion
# 尝试从Redis加载
memory = await self._get_redis(session_id, user_id)
if memory:
# 加载到内存缓存
self._save_memory(memory)
return memory
# 创建新的记忆
memory = ConversationMemory(session_id=session_id, user_id=user_id)
self.memory_cache[cache_key] = memory
await self._save_memory(memory)
await self._save_conversation(memory)
return memory
async def set_title(self, session_id: str, user_id: str, title: str):
"""设置会话标题"""
memory = await self.get_memory(session_id=session_id, user_id=user_id)
"""
设置会话标题
"""
memory = await self.get_conversation(session_id=session_id, user_id=user_id)
memory.title = title
memory.updated_at = datetime.now()
await self._save_memory(memory)
await self._save_conversation(memory)
async def get_title(self, session_id: str, user_id: str) -> Optional[str]:
"""获取会话标题"""
memory = await self.get_memory(session_id=session_id, user_id=user_id)
"""
获取会话标题
"""
memory = await self.get_conversation(session_id=session_id, user_id=user_id)
return memory.title
async def list_sessions(self, user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""列出历史会话摘要(按更新时间倒序)
"""
列出历史会话摘要(按更新时间倒序)
- 当启用Redis时遍历 `agent_memory:*` 键并读取摘要
- 当未启用Redis时基于内存缓存返回
@@ -138,7 +181,7 @@ class ConversationMemoryManager:
for m in sorted_list
]
async def add_memory(
async def add_conversation(
self,
session_id: str,
user_id: str,
@@ -146,8 +189,10 @@ class ConversationMemoryManager:
content: str,
metadata: Optional[Dict[str, Any]] = None
):
"""添加消息到记忆"""
memory = await self.get_memory(session_id=session_id, user_id=user_id)
"""
添加消息到记忆
"""
memory = await self.get_conversation(session_id=session_id, user_id=user_id)
message = {
"role": role,
@@ -167,7 +212,7 @@ class ConversationMemoryManager:
recent_messages = memory.messages[-(max_messages - len(system_messages)):]
memory.messages = system_messages + recent_messages
await self._save_memory(memory)
await self._save_conversation(memory)
logger.debug(f"消息已添加到记忆: session_id={session_id}, user_id={user_id}, role={role}")
@@ -176,19 +221,18 @@ class ConversationMemoryManager:
session_id: str,
user_id: str
) -> List[Dict[str, Any]]:
"""为Agent获取最近的消息仅内存缓存
"""
为Agent获取最近的消息仅内存缓存
如果消息Token数量超过模型最大上下文长度的阀值会自动进行摘要裁剪
"""
cache_key = f"{user_id}:{session_id}" if user_id else session_id
cache_key = self._get_memory_key(session_id, user_id)
memory = self.memory_cache.get(cache_key)
if not memory:
return []
# 获取所有消息
messages = memory.messages
return messages
return memory.messages
async def get_recent_messages(
self,
@@ -197,8 +241,10 @@ class ConversationMemoryManager:
limit: int = 10,
role_filter: Optional[list] = None
) -> List[Dict[str, Any]]:
"""获取最近的消息"""
memory = await self.get_memory(session_id=session_id, user_id=user_id)
"""
获取最近的消息
"""
memory = await self.get_conversation(session_id=session_id, user_id=user_id)
messages = memory.messages
if role_filter:
@@ -207,36 +253,41 @@ class ConversationMemoryManager:
return messages[-limit:] if messages else []
async def get_context(self, session_id: str, user_id: str) -> Dict[str, Any]:
"""获取会话上下文"""
memory = await self.get_memory(session_id=session_id, user_id=user_id)
"""
获取会话上下文
"""
memory = await self.get_conversation(session_id=session_id, user_id=user_id)
return memory.context
async def clear_memory(self, session_id: str, user_id: str):
"""清空会话记忆"""
"""
清空会话记忆
"""
cache_key = f"{user_id}:{session_id}" if user_id else session_id
if cache_key in self.memory_cache:
del self.memory_cache[cache_key]
if settings.CACHE_BACKEND_TYPE == "redis":
redis_key = f"agent_memory:{user_id}:{session_id}" if user_id else f"agent_memory:{session_id}"
redis_key = self._get_redis_key(session_id, user_id)
await self.redis_helper.delete(redis_key, region="AI_AGENT")
logger.info(f"会话记忆已清空: session_id={session_id}, user_id={user_id}")
async def _save_memory(self, memory: ConversationMemory):
"""保存记忆到存储
Redis中的记忆会自动通过TTL机制过期无需手动清理
def _save_memory(self, memory: ConversationMemory):
"""
# 更新内存缓存
cache_key = f"{memory.user_id}:{memory.session_id}" if memory.user_id else memory.session_id
保存记忆到内存
"""
cache_key = self._get_memory_key(memory.session_id, memory.user_id)
self.memory_cache[cache_key] = memory
# 保存到Redis设置TTL自动过期
async def _save_redis(self, memory: ConversationMemory):
"""
保存记忆到Redis
"""
if settings.CACHE_BACKEND_TYPE == "redis":
try:
memory_dict = memory.model_dump()
redis_key = f"agent_memory:{memory.user_id}:{memory.session_id}" if memory.user_id else f"agent_memory:{memory.session_id}"
redis_key = self._get_redis_key(memory.session_id, memory.user_id)
ttl = int(timedelta(days=settings.LLM_REDIS_MEMORY_RETENTION_DAYS).total_seconds())
await self.redis_helper.set(
redis_key,
@@ -247,8 +298,22 @@ class ConversationMemoryManager:
except Exception as e:
logger.warning(f"保存记忆到Redis失败: {e}")
async def _save_conversation(self, memory: ConversationMemory):
"""
保存记忆到存储
Redis中的记忆会自动通过TTL机制过期无需手动清理
"""
# 更新内存缓存
self._save_memory(memory)
# 保存到Redis设置TTL自动过期
await self._save_redis(memory)
async def _cleanup_expired_memories(self):
"""清理内存中过期记忆的后台任务
"""
清理内存中过期记忆的后台任务
注意Redis中的记忆通过TTL机制自动过期这里只清理内存缓存
"""
@@ -278,3 +343,5 @@ class ConversationMemoryManager:
break
except Exception as e:
logger.error(f"清理记忆时发生错误: {e}")
conversation_manager = ConversationMemoryManager()

View File

@@ -1,70 +1,72 @@
You are MoviePilot's AI assistant, specialized in helping users manage media resources including subscriptions, searching, downloading, and organization.
You are an AI media assistant powered by MoviePilot, specialized in managing home media ecosystems. Your expertise covers searching for movies/TV shows, managing subscriptions, overseeing downloads, and organizing media libraries.
## Your Identity and Capabilities
All your responses must be in **Chinese (中文)**.
You are an AI agent for the MoviePilot media management system with the following core capabilities:
You act as a proactive agent. Your goal is to fully resolve the user's media-related requests autonomously. Do not end your turn until the task is complete or you are blocked and require user feedback.
### Media Management Capabilities
- **Search Media Resources**: Search for movies, TV shows, anime, and other media content based on user requirements
- **Add Subscriptions**: Create subscription rules for media content that users are interested in
- **Manage Downloads**: Search and add torrent resources to downloaders
- **Query Status**: Check subscription status, download progress, and media library status
Core Capabilities:
1. Media Search & Recognition
- Identify movies, TV shows, and anime across various metadata providers.
- Recognize media info from fuzzy filenames or incomplete titles.
2. Subscription Management
- Create complex rules for automated downloading of new episodes.
- Monitor trending movies/shows for automated suggestions.
3. Download Control
- Intelligent torrent searching across private/public trackers.
- Filter resources by quality (4K/1080p), codec (H265/H264), and release groups.
4. System Status & Organization
- Monitor download progress and server health.
- Manage file transfers, renaming, and library cleanup.
### Intelligent Interaction Capabilities
- **Natural Language Understanding**: Understand user requests in natural language (Chinese/English)
- **Context Memory**: Remember conversation history and user preferences
- **Smart Recommendations**: Recommend related media content based on user preferences
- **Task Execution**: Automatically execute complex media management tasks
<communication>
- Use Markdown for structured data like movie lists, download statuses, or technical details.
- Avoid wrapping the entire response in a single code block. Use `inline code` for titles or parameters and ```code blocks``` for structured logs or data only when necessary.
- ALWAYS use backticks for media titles (e.g., `Interstellar`), file paths, or specific parameters.
- Optimize your writing for clarity and readability, using bold text for key information.
- Provide comprehensive details for media (year, rating, resolution) to help users make informed decisions.
- Do not stop for approval for read-only operations. Only stop for critical actions like starting a download or deleting a subscription.
## Working Principles
Important Notes:
- User-Centric: Your tone should be helpful, professional, and media-savvy.
- No Coding Hallucinations: You are NOT a coding assistant. Do not offer code snippets, IDE tips, or programming help. Focus entirely on the MoviePilot media ecosystem.
- Contextual Memory: Remember if the user preferred a specific version previously and prioritize similar results in future searches.
</communication>
1. **Always respond in Chinese**: All responses must be in Chinese
2. **Proactive Task Completion**: Understand user needs and proactively use tools to complete related operations
3. **Provide Detailed Information**: Explain what you're doing when executing operations
4. **Safety First**: Confirm user intent before performing download operations
5. **Continuous Learning**: Remember user preferences and habits to provide personalized service
<status_update_spec>
Definition: Provide a brief progress narrative (1-3 sentences) explaining what you have searched, what you found, and what you are about to execute.
- **Immediate Execution**: If you state an intention to perform an action (e.g., "I'll search for the movie"), execute the corresponding tool call in the same turn.
- Use natural tenses: "I've found...", "I'm checking...", "I will now add...".
- Skip redundant updates if no significant progress has been made since the last message.
</status_update_spec>
## Common Operation Workflows
<summary_spec>
At the end of your session/turn, provide a concise summary of your actions.
- Highlight key results: "Subscribed to `Stranger Things`", "Added `Avatar` 4K to download queue".
- Use bullet points for multiple actions.
- Do not repeat the internal execution steps; focus on the outcome for the user.
</summary_spec>
### Add Subscription Workflow
1. Understand the media content the user wants to subscribe to
2. Search for related media information
3. Create subscription rules
4. Confirm successful subscription
<flow>
1. Media Discovery: Start by identifying the exact media metadata (TMDB ID, Season/Episode) using search tools.
2. Context Checking: Verify current status (Is it already in the library? Is it already subscribed?).
3. Action Execution: Perform the requested task (Subscribe, Search Torrents, etc.) with a brief status update.
4. Final Confirmation: Summarize the final state and wait for the next user command.
</flow>
### Search and Download Workflow
1. Understand user requirements (movie names, TV show names, etc.)
2. Search for related media information
3. Search for related torrent resources by media info
4. Filter suitable resources
5. Add to downloader
<tool_calling_strategy>
- Parallel Execution: You MUST call independent tools in parallel. For example, search for torrents on multiple sites or check both subscription and download status at once.
- Information Depth: If a search returns ambiguous results, use `query_media_detail` or `recognize_media` to resolve the ambiguity before proceeding.
- Proactive Fallback: If `search_media` fails, try `search_web` or fuzzy search with `recognize_media`. Do not ask the user for help unless all automated search methods are exhausted.
</tool_calling_strategy>
### Query Status Workflow
1. Understand what information the user wants to know
2. Query related data
3. Organize and present results
<media_management_rules>
1. Download Safety: You MUST present a list of found torrents (including size, seeds, and quality) and obtain the user's explicit consent before initiating any download.
2. Subscription Logic: When adding a subscription, always check for the best matching quality profile based on user history or the default settings.
3. Library Awareness: Always check if the user already has the content in their library to avoid duplicate downloads.
4. Error Handling: If a site is down or a tool returns an error, explain the situation in plain Chinese (e.g., "站点响应超时") and suggest an alternative (e.g., "尝试从其他站点进行搜索").
</media_management_rules>
## Tool Usage Guidelines
### Tool Usage Principles
- Use tools proactively to complete user requests
- Always explain what you're doing when using tools
- Provide detailed results and explanations
- Handle errors gracefully and suggest alternatives
- Confirm user intent before performing download operations
### Response Format
- Always respond in Chinese
- Use clear and friendly language
- Provide structured information when appropriate
- Include relevant details about media content (title, year, type, etc.)
- Explain the results of tool operations clearly
## Important Notes
- Always confirm user intent before performing download operations
- If search results are not ideal, proactively adjust search strategies
- Maintain a friendly and professional tone
- Seek solutions proactively when encountering problems
- Remember user preferences and provide personalized recommendations
- Handle errors gracefully and provide helpful suggestions
<markdown_spec>
Specific markdown rules:
{markdown_spec}
</markdown_spec>

View File

@@ -1,13 +1,15 @@
"""提示词管理器"""
from pathlib import Path
from typing import Dict
from app.log import logger
from app.schemas import ChannelCapability, ChannelCapabilities, MessageChannel, ChannelCapabilityManager
class PromptManager:
"""提示词管理器"""
"""
提示词管理器
"""
def __init__(self, prompts_dir: str = None):
if prompts_dir is None:
@@ -17,22 +19,20 @@ class PromptManager:
self.prompts_cache: Dict[str, str] = {}
def load_prompt(self, prompt_name: str) -> str:
"""加载指定的提示词"""
"""
加载指定的提示词
"""
if prompt_name in self.prompts_cache:
return self.prompts_cache[prompt_name]
prompt_file = self.prompts_dir / prompt_name
try:
with open(prompt_file, 'r', encoding='utf-8') as f:
content = f.read().strip()
# 缓存提示词
self.prompts_cache[prompt_name] = content
logger.info(f"提示词加载成功: {prompt_name},长度:{len(content)} 字符")
return content
except FileNotFoundError:
logger.error(f"提示词文件不存在: {prompt_file}")
raise
@@ -46,73 +46,43 @@ class PromptManager:
:param channel: 消息渠道Telegram、微信、Slack等
:return: 提示词内容
"""
# 基础提示词
base_prompt = self.load_prompt("Agent Prompt.txt")
# 根据渠道添加特定的格式说明
if channel:
channel_format_info = self._get_channel_format_info(channel)
if channel_format_info:
base_prompt += f"\n\n## Current Message Channel Format Requirements\n\n{channel_format_info}"
# 识别渠道
msg_channel = next((c for c in MessageChannel if c.value.lower() == channel.lower()), None) if channel else None
if msg_channel:
# 获取渠道能力说明
caps = ChannelCapabilityManager.get_capabilities(msg_channel)
if caps:
base_prompt = base_prompt.replace(
"{markdown_spec}",
self._generate_formatting_instructions(caps)
)
return base_prompt
@staticmethod
def _get_channel_format_info(channel: str) -> str:
def _generate_formatting_instructions(caps: ChannelCapabilities) -> str:
"""
获取渠道特定的格式说明
:param channel: 消息渠道
:return: 格式说明文本
根据渠道能力动态生成格式指令
"""
channel_lower = channel.lower() if channel else ""
if "telegram" in channel_lower:
return """Messages are being sent through the **Telegram** channel. You must follow these format requirements:
**Supported Formatting:**
- **Bold text**: Use `*text*` (single asterisk, not double asterisks)
- **Italic text**: Use `_text_` (underscore)
- **Code**: Use `` `text` `` (backtick)
- **Links**: Use `[text](url)` format
- **Strikethrough**: Use `~text~` (tilde)
**IMPORTANT - Headings and Lists:**
- **DO NOT use heading syntax** (`#`, `##`, `###`) - Telegram MarkdownV2 does NOT support it
- **Instead, use bold text for headings**: `*Heading Text*` followed by a blank line
- **DO NOT use list syntax** (`-`, `*`, `+` at line start) - these will be escaped and won't display as lists
- **For lists**, use plain text with line breaks, or use bold for list item labels: `*Item 1:* description`
**Examples:**
- ❌ Wrong heading: `# Main Title` or `## Subtitle`
- ✅ Correct heading: `*Main Title*` (followed by blank line) or `*Subtitle*` (followed by blank line)
- ❌ Wrong list: `- Item 1` or `* Item 2`
- ✅ Correct list format: `*Item 1:* description` or use plain text with line breaks
**Special Characters:**
- Avoid using special characters that need escaping in MarkdownV2: `_*[]()~`>#+-=|{}.!` unless they are part of the formatting syntax
- Keep formatting simple, avoid nested formatting to ensure proper rendering in Telegram"""
elif "wechat" in channel_lower or "微信" in channel:
return """Messages are being sent through the **WeChat** channel. Please follow these format requirements:
- WeChat does NOT support Markdown formatting. Use plain text format only.
- Do NOT use any Markdown syntax (such as `**bold**`, `*italic*`, `` `code` `` etc.)
- Use plain text descriptions. You can organize content using line breaks and punctuation
- Links can be provided directly as URLs, no Markdown link format needed
- Keep messages concise and clear, use natural Chinese expressions"""
elif "slack" in channel_lower:
return """Messages are being sent through the **Slack** channel. Please follow these format requirements:
- Slack supports Markdown formatting
- Use `*text*` for bold
- Use `_text_` for italic
- Use `` `text` `` for code
- Link format: `<url|text>` or `[text](url)`"""
# 其他渠道使用标准Markdown
return None
instructions = []
if ChannelCapability.RICH_TEXT not in caps.capabilities:
instructions.append("- Formatting: Use **Plain Text ONLY**. The channel does NOT support Markdown.")
instructions.append(
"- No Markdown Symbols: NEVER use `**`, `*`, `__`, or `[` blocks. Use natural text to emphasize (e.g., using ALL CAPS or separators).")
instructions.append(
"- Lists: Use plain text symbols like `>` or `*` at the start of lines, followed by manual line breaks.")
instructions.append("- Links: Paste URLs directly as text.")
return "\n".join(instructions)
def clear_cache(self):
"""清空缓存"""
"""
清空缓存
"""
self.prompts_cache.clear()
logger.info("提示词缓存已清空")
prompt_manager = PromptManager()

View File

@@ -1,11 +1,11 @@
"""MoviePilot工具基类"""
import json
from abc import ABCMeta, abstractmethod
from typing import Callable, Any, Optional
from typing import Any, Optional
from langchain.tools import BaseTool
from pydantic import PrivateAttr
from app.agent import StreamingCallbackHandler
from app.agent import StreamingCallbackHandler, conversation_manager
from app.chain import ChainBase
from app.log import logger
from app.schemas import Notification
@@ -16,7 +16,9 @@ class ToolChain(ChainBase):
class MoviePilotTool(BaseTool, metaclass=ABCMeta):
"""MoviePilot专用工具基类"""
"""
MoviePilot专用工具基类
"""
_session_id: str = PrivateAttr()
_user_id: str = PrivateAttr()
@@ -34,25 +36,65 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
pass
async def _arun(self, **kwargs) -> str:
"""异步运行工具"""
# 发送运行工具前的消息
"""
异步运行工具
"""
# 获取工具调用前的agent消息
agent_message = await self._callback_handler.get_message()
if agent_message:
await self.send_tool_message(agent_message, title="MoviePilot助手")
# 发送执行工具说明
# 优先使用工具自定义的提示消息,如果没有则使用 explanation
# 记忆工具调用
await conversation_manager.add_conversation(
session_id=self._session_id,
user_id=self._user_id,
role="tool_call",
content=agent_message,
metadata={
"call_id": self.__class__.__name__,
"tool_name": self.__class__.__name__,
"parameters": kwargs
}
)
# 获取执行工具说明,优先使用工具自定义的提示消息,如果没有则使用 explanation
tool_message = self.get_tool_message(**kwargs)
if not tool_message:
explanation = kwargs.get("explanation")
if explanation:
tool_message = explanation
# 合并agent消息和工具执行消息一起发送
messages = []
if agent_message:
messages.append(agent_message)
if tool_message:
formatted_message = f"⚙️ => {tool_message}"
await self.send_tool_message(formatted_message)
messages.append(f"⚙️ => {tool_message}")
# 发送合并后的消息
if messages:
merged_message = "\n\n".join(messages)
await self.send_tool_message(merged_message, title="MoviePilot助手")
logger.debug(f'Executing tool {self.name} with args: {kwargs}')
result = await self.run(**kwargs)
logger.debug(f'Tool {self.name} executed with result: {result}')
# 记忆工具调用结果
if isinstance(result, str):
formated_result = result
elif isinstance(result, (int, float)):
formated_result = str(result)
else:
formated_result = json.dumps(result, ensure_ascii=False, indent=2)
await conversation_manager.add_conversation(
session_id=self._session_id,
user_id=self._user_id,
role="tool_result",
content=formated_result,
metadata={
"call_id": self.__class__.__name__
}
)
return result
def get_tool_message(self, **kwargs) -> Optional[str]:
@@ -75,17 +117,23 @@ class MoviePilotTool(BaseTool, metaclass=ABCMeta):
raise NotImplementedError
def set_message_attr(self, channel: str, source: str, username: str):
"""设置消息属性"""
"""
设置消息属性
"""
self._channel = channel
self._source = source
self._username = username
def set_callback_handler(self, callback_handler: StreamingCallbackHandler):
"""设置回调处理器"""
"""
设置回调处理器
"""
self._callback_handler = callback_handler
async def send_tool_message(self, message: str, title: str = ""):
"""发送工具消息"""
"""
发送工具消息
"""
await ToolChain().async_post_message(
Notification(
channel=self._channel,

View File

@@ -1,5 +1,3 @@
"""MoviePilot工具工厂"""
from typing import List, Callable
from app.agent.tools.impl.add_download import AddDownloadTool
@@ -27,6 +25,7 @@ from app.agent.tools.impl.search_person_credits import SearchPersonCreditsTool
from app.agent.tools.impl.recognize_media import RecognizeMediaTool
from app.agent.tools.impl.scrape_metadata import ScrapeMetadataTool
from app.agent.tools.impl.query_episode_schedule import QueryEpisodeScheduleTool
from app.agent.tools.impl.query_media_detail import QueryMediaDetailTool
from app.agent.tools.impl.search_torrents import SearchTorrentsTool
from app.agent.tools.impl.search_web import SearchWebTool
from app.agent.tools.impl.send_message import SendMessageTool
@@ -46,13 +45,17 @@ from .base import MoviePilotTool
class MoviePilotToolFactory:
"""MoviePilot工具工厂"""
"""
MoviePilot工具工厂
"""
@staticmethod
def create_tools(session_id: str, user_id: str,
channel: str = None, source: str = None, username: str = None,
callback_handler: Callable = None) -> List[MoviePilotTool]:
"""创建MoviePilot工具列表"""
"""
创建MoviePilot工具列表
"""
tools = []
tool_definitions = [
SearchMediaTool,
@@ -61,6 +64,7 @@ class MoviePilotToolFactory:
RecognizeMediaTool,
ScrapeMetadataTool,
QueryEpisodeScheduleTool,
QueryMediaDetailTool,
AddSubscribeTool,
UpdateSubscribeTool,
SearchSubscribeTool,

View File

@@ -1,7 +1,7 @@
"""查询下载工具"""
import json
from typing import Optional, Type
from typing import Optional, Type, List, Union
from pydantic import BaseModel, Field
@@ -9,6 +9,8 @@ from app.agent.tools.base import MoviePilotTool
from app.chain.download import DownloadChain
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.log import logger
from app.schemas import TransferTorrent, DownloadingTorrent
from app.schemas.types import TorrentStatus
class QueryDownloadTasksInput(BaseModel):
@@ -27,6 +29,28 @@ class QueryDownloadTasksTool(MoviePilotTool):
description: str = "Query download status and list download tasks. Can query all active downloads, or search for specific tasks by hash or title. Shows download progress, completion status, and task details from configured downloaders."
args_schema: Type[BaseModel] = QueryDownloadTasksInput
@staticmethod
def _get_all_torrents(download_chain: DownloadChain, downloader: Optional[str] = None) -> List[Union[TransferTorrent, DownloadingTorrent]]:
"""
查询所有状态的任务(包括下载中和已完成的任务)
"""
all_torrents = []
# 查询正在下载的任务
downloading_torrents = download_chain.list_torrents(
downloader=downloader,
status=TorrentStatus.DOWNLOADING
) or []
all_torrents.extend(downloading_torrents)
# 查询已完成的任务(可转移状态)
transfer_torrents = download_chain.list_torrents(
downloader=downloader,
status=TorrentStatus.TRANSFER
) or []
all_torrents.extend(transfer_torrents)
return all_torrents
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
downloader = kwargs.get("downloader")
@@ -60,7 +84,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
# 如果提供了hash直接查询该hash的任务不限制状态
if hash:
torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash])
torrents = download_chain.list_torrents(downloader=downloader, hashs=[hash]) or []
if not torrents:
return f"未找到hash为 {hash} 的下载任务(该任务可能已完成、已删除或不存在)"
# 转换为DownloadingTorrent格式
@@ -84,14 +108,25 @@ class QueryDownloadTasksTool(MoviePilotTool):
elif title:
# 如果提供了title查询所有任务并搜索匹配的标题
# 查询所有状态的任务
all_torrents = download_chain.list_torrents(downloader=downloader) or []
all_torrents = self._get_all_torrents(download_chain, downloader)
filtered_downloads = []
title_lower = title.lower()
for torrent in all_torrents:
# 检查标题或名称是否匹配
if (title.lower() in (torrent.title or "").lower()) or \
(title.lower() in (torrent.name or "").lower()):
# 获取下载历史信息
history = DownloadHistoryOper().get_by_hash(torrent.hash)
# 获取下载历史信息
history = DownloadHistoryOper().get_by_hash(torrent.hash)
# 检查标题或名称是否匹配(包括下载历史中的标题)
matched = False
# 检查torrent的title和name字段
if (title_lower in (torrent.title or "").lower()) or \
(title_lower in (torrent.name or "").lower()):
matched = True
# 检查下载历史中的标题
if history and history.title:
if title_lower in history.title.lower():
matched = True
if matched:
if history:
torrent.media = {
"tmdbid": history.tmdbid,
@@ -110,7 +145,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
# 根据status决定查询方式
if status == "downloading":
# 如果status为下载中使用downloading方法
downloads = download_chain.downloading(name=downloader)
downloads = download_chain.downloading(name=downloader) or []
filtered_downloads = []
for dl in downloads:
if downloader and dl.downloader != downloader:
@@ -119,7 +154,7 @@ class QueryDownloadTasksTool(MoviePilotTool):
else:
# 其他状态completed、paused、all使用list_torrents查询所有任务
# 查询所有状态的任务
all_torrents = download_chain.list_torrents(downloader=downloader) or []
all_torrents = self._get_all_torrents(download_chain, downloader)
filtered_downloads = []
for torrent in all_torrents:
if downloader and torrent.downloader != downloader:

View File

@@ -0,0 +1,120 @@
"""查询媒体详情工具"""
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from app.agent.tools.base import MoviePilotTool
from app.chain.media import MediaChain
from app.log import logger
from app.schemas import MediaType
class QueryMediaDetailInput(BaseModel):
"""查询媒体详情工具的输入参数模型"""
explanation: str = Field(..., description="Clear explanation of why this tool is being used in the current context")
tmdb_id: int = Field(..., description="TMDB ID of the media (movie or TV series)")
media_type: str = Field(..., description="Media type: 'movie' or 'tv'")
class QueryMediaDetailTool(MoviePilotTool):
name: str = "query_media_detail"
description: str = "Query detailed media information from TMDB by ID and media_type. IMPORTANT: Convert search results type: '电影''movie', '电视剧''tv'. Returns core metadata including title, year, overview, status, genres, directors, actors, and season count for TV series."
args_schema: Type[BaseModel] = QueryMediaDetailInput
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据查询参数生成友好的提示消息"""
tmdb_id = kwargs.get("tmdb_id")
return f"正在查询媒体详情: TMDB ID {tmdb_id}"
async def run(self, tmdb_id: int, media_type: str, **kwargs) -> str:
logger.info(f"执行工具: {self.name}, 参数: tmdb_id={tmdb_id}, media_type={media_type}")
try:
media_chain = MediaChain()
mtype = None
if media_type:
if media_type.lower() == 'movie':
mtype = MediaType.MOVIE
elif media_type.lower() == 'tv':
mtype = MediaType.TV
mediainfo = await media_chain.async_recognize_media(tmdbid=tmdb_id, mtype=mtype)
if not mediainfo:
return json.dumps({
"success": False,
"message": f"未找到 TMDB ID {tmdb_id} 的媒体信息"
}, ensure_ascii=False)
# 精简 genres - 只保留名称
genres = [g.get("name") for g in (mediainfo.genres or []) if g.get("name")]
# 精简 directors - 只保留姓名和职位
directors = [
{
"name": d.get("name"),
"job": d.get("job")
}
for d in (mediainfo.directors or [])
if d.get("name")
]
# 精简 actors - 只保留姓名和角色
actors = [
{
"name": a.get("name"),
"character": a.get("character")
}
for a in (mediainfo.actors or [])
if a.get("name")
]
# 构建基础媒体详情信息
result = {
"success": True,
"tmdb_id": tmdb_id,
"type": mediainfo.type.value if mediainfo.type else None,
"title": mediainfo.title,
"year": mediainfo.year,
"overview": mediainfo.overview,
"status": mediainfo.status,
"genres": genres,
"directors": directors,
"actors": actors
}
# 如果是电视剧,添加电视剧特有信息
if mediainfo.type == MediaType.TV:
# 精简 season_info - 只保留基础摘要
season_info = [
{
"season_number": s.get("season_number"),
"name": s.get("name"),
"episode_count": s.get("episode_count"),
"air_date": s.get("air_date")
}
for s in (mediainfo.season_info or [])
if s.get("season_number") is not None
]
result.update({
"number_of_seasons": mediainfo.number_of_seasons,
"number_of_episodes": mediainfo.number_of_episodes,
"first_air_date": mediainfo.first_air_date,
"last_air_date": mediainfo.last_air_date,
"season_info": season_info
})
return json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
error_message = f"查询媒体详情失败: {str(e)}"
logger.error(f"查询媒体详情失败: {e}", exc_info=True)
return json.dumps({
"success": False,
"message": error_message,
"tmdb_id": tmdb_id
}, ensure_ascii=False)

View File

@@ -10,6 +10,7 @@ from app.agent.tools.base import MoviePilotTool
from app.chain.search import SearchChain
from app.log import logger
from app.schemas.types import MediaType
from app.utils.string import StringUtils
class SearchTorrentsInput(BaseModel):
@@ -99,7 +100,7 @@ class SearchTorrentsTool(MoviePilotTool):
if t.torrent_info:
simplified["torrent_info"] = {
"title": t.torrent_info.title,
"size": t.torrent_info.size,
"size": StringUtils.format_size(t.torrent_info.size),
"seeders": t.torrent_info.seeders,
"peers": t.torrent_info.peers,
"site_name": t.torrent_info.site_name,

View File

@@ -29,7 +29,7 @@ class UpdateSubscribeInput(BaseModel):
include: Optional[str] = Field(None, description="Include filter as regular expression (optional)")
exclude: Optional[str] = Field(None, description="Exclude filter as regular expression (optional)")
filter: Optional[str] = Field(None, description="Filter rule as regular expression (optional)")
state: Optional[str] = Field(None, description="Subscription state: 'R' for enabled, 'P' for disabled, 'S' for paused (optional)")
state: Optional[str] = Field(None, description="Subscription state: 'R' for enabled, 'P' for pending, 'S' for stoped (optional)")
sites: Optional[List[int]] = Field(None, description="List of site IDs to search from (optional)")
downloader: Optional[str] = Field(None, description="Downloader name (optional)")
save_path: Optional[str] = Field(None, description="Save path for downloaded files (optional)")

View File

@@ -1,8 +1,5 @@
"""MoviePilot工具管理器
用于HTTP API调用工具
"""
import json
import uuid
from typing import Any, Dict, List, Optional
from app.agent.tools.factory import MoviePilotToolFactory
@@ -10,7 +7,9 @@ from app.log import logger
class ToolDefinition:
"""工具定义"""
"""
工具定义
"""
def __init__(self, name: str, description: str, input_schema: Dict[str, Any]):
self.name = name
@@ -19,9 +18,11 @@ class ToolDefinition:
class MoviePilotToolsManager:
"""MoviePilot工具管理器用于HTTP API"""
"""
MoviePilot工具管理器用于HTTP API
"""
def __init__(self, user_id: str = "api_user", session_id: str = "api_session"):
def __init__(self, user_id: str = "api_user", session_id: str = uuid.uuid4()):
"""
初始化工具管理器
@@ -35,7 +36,9 @@ class MoviePilotToolsManager:
self._load_tools()
def _load_tools(self):
"""加载所有MoviePilot工具"""
"""
加载所有MoviePilot工具
"""
try:
# 创建工具实例
self.tools = MoviePilotToolFactory.create_tools(
@@ -44,7 +47,7 @@ class MoviePilotToolsManager:
channel=None,
source="api",
username="API Client",
callback_handler=None
callback_handler=None,
)
logger.info(f"成功加载 {len(self.tools)} 个工具")
except Exception as e:
@@ -96,6 +99,76 @@ class MoviePilotToolsManager:
return tool
return None
@staticmethod
def _normalize_arguments(tool_instance: Any, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""
根据工具的参数schema规范化参数类型
Args:
tool_instance: 工具实例
arguments: 原始参数
Returns:
规范化后的参数
"""
# 获取工具的参数schema
args_schema = getattr(tool_instance, 'args_schema', None)
if not args_schema:
return arguments
# 获取schema中的字段定义
try:
schema = args_schema.model_json_schema()
properties = schema.get("properties", {})
except Exception as e:
logger.warning(f"获取工具schema失败: {e}")
return arguments
# 规范化参数
normalized = {}
for key, value in arguments.items():
if key not in properties:
# 参数不在schema中保持原样
normalized[key] = value
continue
field_info = properties[key]
field_type = field_info.get("type")
# 处理 anyOf 类型(例如 Optional[int] 会生成 anyOf
any_of = field_info.get("anyOf")
if any_of and not field_type:
# 从 anyOf 中提取实际类型
for type_option in any_of:
if "type" in type_option and type_option["type"] != "null":
field_type = type_option["type"]
break
# 根据类型进行转换
if field_type == "integer" and isinstance(value, str):
try:
normalized[key] = int(value)
except (ValueError, TypeError):
logger.warning(f"无法将参数 {key}='{value}' 转换为整数,保持原值")
normalized[key] = None
elif field_type == "number" and isinstance(value, str):
try:
normalized[key] = float(value)
except (ValueError, TypeError):
logger.warning(f"无法将参数 {key}='{value}' 转换为浮点数,保持原值")
normalized[key] = None
elif field_type == "boolean":
if isinstance(value, str):
normalized[key] = value.lower() in ("true", "1", "yes", "on")
elif isinstance(value, (int, float)):
normalized[key] = value != 0
else:
normalized[key] = True
else:
normalized[key] = value
return normalized
async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> str:
"""
调用工具
@@ -116,14 +189,25 @@ class MoviePilotToolsManager:
return error_msg
try:
# 规范化参数类型
normalized_arguments = self._normalize_arguments(tool_instance, arguments)
# 调用工具的run方法
result = await tool_instance.run(**arguments)
result = await tool_instance.run(**normalized_arguments)
# 确保返回字符串
if isinstance(result, str):
return result
formated_result = result
elif isinstance(result, int, float):
formated_result = str(result)
else:
return json.dumps(result, ensure_ascii=False, indent=2)
try:
formated_result = json.dumps(result, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"结果转换为JSON失败: {e}, 使用字符串表示")
formated_result = str(result)
return formated_result
except Exception as e:
logger.error(f"调用工具 {tool_name} 时发生错误: {e}", exc_info=True)
error_msg = json.dumps({
@@ -185,3 +269,6 @@ class MoviePilotToolsManager:
"properties": properties,
"required": required
}
moviepilot_tool_manager = MoviePilotToolsManager()

View File

@@ -2,11 +2,12 @@ from fastapi import APIRouter
from app.api.endpoints import login, user, webhook, message, site, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
api_router.include_router(user.router, prefix="/user", tags=["user"])
api_router.include_router(mfa.router, prefix="/mfa", tags=["mfa"])
api_router.include_router(site.router, prefix="/site", tags=["site"])
api_router.include_router(message.router, prefix="/message", tags=["message"])
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])

View File

@@ -29,7 +29,14 @@ def login_access_token(
mfa_code=otp_password)
if not success:
raise HTTPException(status_code=401, detail=user_or_message)
# 如果是需要MFA验证返回特殊标识
if user_or_message == "MFA_REQUIRED":
raise HTTPException(
status_code=401,
detail="需要双重验证,请提供验证码或使用通行密钥",
headers={"X-MFA-Required": "true"}
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
# 用户等级
level = SitesHelper().auth_level
@@ -50,7 +57,7 @@ def login_access_token(
avatar=user_or_message.avatar,
level=level,
permissions=user_or_message.permissions or {},
widzard=show_wizard
wizard=show_wizard
)

View File

@@ -1,43 +1,241 @@
"""工具API端点
通过HTTP API暴露MoviePilot的智能体工具功能
"""
from typing import List, Any, Dict, Annotated, Union
from typing import List, Any, Dict, Annotated
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, Response
from app import schemas
from app.agent.tools.manager import MoviePilotToolsManager
from app.agent.tools.manager import moviepilot_tool_manager
from app.core.security import verify_apikey
from app.log import logger
# 导入版本号
try:
from version import APP_VERSION
except ImportError:
APP_VERSION = "unknown"
router = APIRouter()
# 全局工具管理器实例单例模式按用户ID缓存
_tools_managers: Dict[str, MoviePilotToolsManager] = {}
# MCP 协议版本
MCP_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2024-11-05"]
MCP_PROTOCOL_VERSION = MCP_PROTOCOL_VERSIONS[0] # 默认使用最新版本
def get_tools_manager(user_id: str = "mcp_user", session_id: str = "mcp_session") -> MoviePilotToolsManager:
def create_jsonrpc_response(request_id: Union[str, int, None], result: Any) -> Dict[str, Any]:
"""
获取工具管理器实例按用户ID缓存
创建 JSON-RPC 成功响应
"""
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": result
}
return response
def create_jsonrpc_error(request_id: Union[str, int, None], code: int, message: str, data: Any = None) -> Dict[
str, Any]:
"""
创建 JSON-RPC 错误响应
"""
error = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": code,
"message": message
}
}
if data is not None:
error["error"]["data"] = data
return error
@router.post("", summary="MCP JSON-RPC 端点", response_model=None)
async def mcp_jsonrpc(
request: Request,
_: Annotated[str, Depends(verify_apikey)] = None
) -> Union[JSONResponse, Response]:
"""
MCP 标准 JSON-RPC 2.0 端点
Args:
user_id: 用户ID
session_id: 会话ID
Returns:
MoviePilotToolsManager实例
处理所有 MCP 协议消息(初始化、工具列表、工具调用等)
"""
global _tools_managers
# 使用用户ID作为缓存键
cache_key = f"{user_id}_{session_id}"
if cache_key not in _tools_managers:
_tools_managers[cache_key] = MoviePilotToolsManager(
user_id=user_id,
session_id=session_id
try:
body = await request.json()
except Exception as e:
logger.error(f"解析请求体失败: {e}")
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(None, -32700, "Parse error", str(e))
)
return _tools_managers[cache_key]
# 验证 JSON-RPC 格式
if not isinstance(body, dict) or body.get("jsonrpc") != "2.0":
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(body.get("id"), -32600, "Invalid Request")
)
method = body.get("method")
params = body.get("params", {})
request_id = body.get("id")
# 如果有 id则为请求没有 id 则为通知
is_notification = request_id is None
try:
# 处理初始化请求
if method == "initialize":
result = await handle_initialize(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理已初始化通知
elif method == "notifications/initialized":
if is_notification:
return Response(status_code=204)
else:
return JSONResponse(
status_code=400,
content={"error": "initialized must be a notification"}
)
# 处理工具列表请求
if method == "tools/list":
result = await handle_tools_list()
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理工具调用请求
elif method == "tools/call":
result = await handle_tools_call(params)
return JSONResponse(content=create_jsonrpc_response(request_id, result))
# 处理 ping 请求
elif method == "ping":
return JSONResponse(content=create_jsonrpc_response(request_id, {}))
# 未知方法
else:
return JSONResponse(
content=create_jsonrpc_error(request_id, -32601, f"Method not found: {method}")
)
except ValueError as e:
logger.warning(f"MCP 请求参数错误: {e}")
return JSONResponse(
status_code=400,
content=create_jsonrpc_error(request_id, -32602, "Invalid params", str(e))
)
except Exception as e:
logger.error(f"处理 MCP 请求失败: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content=create_jsonrpc_error(request_id, -32603, "Internal error", str(e))
)
async def handle_initialize(params: Dict[str, Any]) -> Dict[str, Any]:
"""
处理初始化请求
"""
protocol_version = params.get("protocolVersion")
client_info = params.get("clientInfo", {})
logger.info(f"MCP 初始化请求: 客户端={client_info.get('name')}, 协议版本={protocol_version}")
# 版本协商:选择客户端和服务器都支持的版本
negotiated_version = MCP_PROTOCOL_VERSION
if protocol_version in MCP_PROTOCOL_VERSIONS:
# 客户端版本在支持列表中,使用客户端版本
negotiated_version = protocol_version
logger.info(f"使用客户端协议版本: {negotiated_version}")
else:
# 客户端版本不支持,使用服务器默认版本
logger.warning(f"协议版本不匹配: 客户端={protocol_version}, 使用服务器版本={negotiated_version}")
return {
"protocolVersion": negotiated_version,
"capabilities": {
"tools": {
"listChanged": False # 暂不支持工具列表变更通知
},
"logging": {}
},
"serverInfo": {
"name": "MoviePilot",
"version": APP_VERSION,
"description": "MoviePilot MCP Server - 电影自动化管理工具",
},
"instructions": "MoviePilot MCP 服务器,提供媒体管理、订阅、下载等工具。"
}
async def handle_tools_list() -> Dict[str, Any]:
"""
处理工具列表请求
"""
tools = moviepilot_tool_manager.list_tools()
# 转换为 MCP 工具格式
mcp_tools = []
for tool in tools:
mcp_tool = {
"name": tool.name,
"description": tool.description,
"inputSchema": tool.input_schema
}
mcp_tools.append(mcp_tool)
return {
"tools": mcp_tools
}
async def handle_tools_call(params: Dict[str, Any]) -> Dict[str, Any]:
"""
处理工具调用请求
"""
tool_name = params.get("name")
arguments = params.get("arguments", {})
if not tool_name:
raise ValueError("Missing tool name")
try:
result_text = await moviepilot_tool_manager.call_tool(tool_name, arguments)
return {
"content": [
{
"type": "text",
"text": result_text
}
]
}
except Exception as e:
logger.error(f"工具调用失败: {tool_name}, 错误: {e}", exc_info=True)
return {
"content": [
{
"type": "text",
"text": f"错误: {str(e)}"
}
],
"isError": True
}
@router.delete("", summary="终止 MCP 会话", response_model=None)
async def delete_mcp_session(
_: Annotated[str, Depends(verify_apikey)] = None
) -> Union[JSONResponse, Response]:
"""
终止 MCP 会话(无状态模式下仅返回成功)
"""
return Response(status_code=204)
# ==================== 兼容的 RESTful API 端点 ====================
@router.get("/tools", summary="列出所有可用工具", response_model=List[Dict[str, Any]])
async def list_tools(
@@ -49,9 +247,8 @@ async def list_tools(
返回每个工具的名称、描述和参数定义
"""
try:
manager = get_tools_manager()
# 获取所有工具定义
tools = manager.list_tools()
tools = moviepilot_tool_manager.list_tools()
# 转换为字典格式
tools_list = []
@@ -72,7 +269,7 @@ async def list_tools(
@router.post("/tools/call", summary="调用工具", response_model=schemas.ToolCallResponse)
async def call_tool(
request: schemas.ToolCallRequest,
_: Annotated[str, Depends(verify_apikey)] = None
) -> Any:
"""
调用指定的工具
@@ -81,11 +278,8 @@ async def call_tool(
工具执行结果
"""
try:
# 使用当前用户ID创建管理器实例
manager = get_tools_manager()
# 调用工具
result_text = await manager.call_tool(request.tool_name, request.arguments)
result_text = await moviepilot_tool_manager.call_tool(request.tool_name, request.arguments)
return schemas.ToolCallResponse(
success=True,
@@ -111,9 +305,8 @@ async def get_tool_info(
工具的详细信息,包括名称、描述和参数定义
"""
try:
manager = get_tools_manager()
# 获取所有工具
tools = manager.list_tools()
tools = moviepilot_tool_manager.list_tools()
# 查找指定工具
for tool in tools:
@@ -144,9 +337,8 @@ async def get_tool_schema(
工具的JSON Schema定义
"""
try:
manager = get_tools_manager()
# 获取所有工具
tools = manager.list_tools()
tools = moviepilot_tool_manager.list_tools()
# 查找指定工具
for tool in tools:

View File

@@ -82,7 +82,7 @@ def exists(media_in: schemas.MediaInfo,
mediainfo.from_dict(media_in.model_dump())
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)
if not existsinfo:
return []
return {}
if media_in.season:
return {
media_in.season: existsinfo.seasons.get(media_in.season) or []

498
app/api/endpoints/mfa.py Normal file
View File

@@ -0,0 +1,498 @@
"""
MFA (Multi-Factor Authentication) API 端点
包含 OTP 和 PassKey 相关功能
"""
from datetime import timedelta
from typing import Any, Annotated, Optional
from app.helper.sites import SitesHelper
from fastapi import APIRouter, Depends, HTTPException, Body
from sqlalchemy.ext.asyncio import AsyncSession
from app import schemas
from app.core import security
from app.core.config import settings
from app.db import get_async_db
from app.db.models.passkey import PassKey
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user, get_current_active_user_async
from app.helper.passkey import PassKeyHelper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.otp import OtpUtils
router = APIRouter()
# ==================== 辅助函数 ====================
def _build_credential_list(passkeys: list[PassKey]) -> list[dict[str, Any]]:
"""
构建凭证列表
:param passkeys: PassKey 列表
:return: 凭证字典列表
"""
return [
{
'credential_id': pk.credential_id,
'transports': pk.transports
}
for pk in passkeys
] if passkeys else []
def _extract_and_standardize_credential_id(credential: dict) -> str:
"""
从凭证中提取并标准化 credential_id
:param credential: 凭证字典
:return: 标准化后的 credential_id
:raises ValueError: 如果凭证无效
"""
credential_id_raw = credential.get('id') or credential.get('rawId')
if not credential_id_raw:
raise ValueError("无效的凭证")
return PassKeyHelper.standardize_credential_id(credential_id_raw)
def _verify_passkey_and_update(
credential: dict,
challenge: str,
passkey: PassKey
) -> tuple[bool, int]:
"""
验证 PassKey 并更新使用时间和签名计数
:param credential: 凭证字典
:param challenge: 挑战值
:param passkey: PassKey 对象
:return: (验证是否成功, 新的签名计数)
"""
success, new_sign_count = PassKeyHelper.verify_authentication_response(
credential=credential,
expected_challenge=challenge,
credential_public_key=passkey.public_key,
credential_current_sign_count=passkey.sign_count
)
if success:
passkey.update_last_used(db=None, sign_count=new_sign_count)
return success, new_sign_count
async def _check_user_has_passkey(db: AsyncSession, user_id: int) -> bool:
"""
检查用户是否有 PassKey
:param db: 数据库会话
:param user_id: 用户 ID
:return: 是否有 PassKey
"""
return bool(await PassKey.async_get_by_user_id(db=db, user_id=user_id))
# ==================== 请求模型 ====================
class OtpVerifyRequest(schemas.BaseModel):
"""OTP验证请求"""
uri: str
otpPassword: str
class OtpDisableRequest(schemas.BaseModel):
"""OTP禁用请求"""
password: str
class PassKeyDeleteRequest(schemas.BaseModel):
"""PassKey删除请求"""
passkey_id: int
password: str
# ==================== 通用 MFA 接口 ====================
@router.get('/status/{username}', summary='判断用户是否开启双重验证(MFA)', response_model=schemas.Response)
async def mfa_status(username: str, db: AsyncSession = Depends(get_async_db)) -> Any:
"""
检查指定用户是否启用了任何双重验证方式OTP 或 PassKey
"""
user: User = await User.async_get_by_name(db, username)
if not user:
return schemas.Response(success=False)
# 检查是否启用了OTP
has_otp = user.is_otp
# 检查是否有PassKey
has_passkey = await _check_user_has_passkey(db, user.id)
# 只要有任何一种验证方式,就需要双重验证
return schemas.Response(success=(has_otp or has_passkey))
# ==================== OTP 相关接口 ====================
@router.post('/otp/generate', summary='生成 OTP 验证 URI', response_model=schemas.Response)
def otp_generate(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> Any:
"""生成 OTP 密钥及对应的 URI"""
secret, uri = OtpUtils.generate_secret_key(current_user.name)
return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri})
@router.post('/otp/verify', summary='绑定并验证 OTP', response_model=schemas.Response)
async def otp_verify(
data: OtpVerifyRequest,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async)
) -> Any:
"""验证用户输入的 OTP 码,验证通过后正式开启 OTP 验证"""
if not OtpUtils.is_legal(data.uri, data.otpPassword):
return schemas.Response(success=False, message="验证码错误")
await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(data.uri))
return schemas.Response(success=True)
@router.post('/otp/disable', summary='关闭当前用户的 OTP 验证', response_model=schemas.Response)
async def otp_disable(
data: OtpDisableRequest,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async)
) -> Any:
"""关闭当前用户的 OTP 验证功能"""
# 安全检查:如果存在 PassKey默认不允许关闭 OTP除非配置允许
has_passkey = await _check_user_has_passkey(db, current_user.id)
if has_passkey and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP:
return schemas.Response(
success=False,
message="您已注册通行密钥,为了防止域名配置变更导致无法登录,请先删除所有通行密钥再关闭 OTP 验证"
)
# 验证密码
if not security.verify_password(data.password, str(current_user.hashed_password)):
return schemas.Response(success=False, message="密码错误")
await current_user.async_update_otp_by_name(db, current_user.name, False, "")
return schemas.Response(success=True)
# ==================== PassKey 相关接口 ====================
class PassKeyRegistrationStart(schemas.BaseModel):
"""PassKey注册开始请求"""
name: str = "通行密钥"
class PassKeyRegistrationFinish(schemas.BaseModel):
"""PassKey注册完成请求"""
credential: dict
challenge: str
name: str = "通行密钥"
class PassKeyAuthenticationStart(schemas.BaseModel):
"""PassKey认证开始请求"""
username: Optional[str] = None
class PassKeyAuthenticationFinish(schemas.BaseModel):
"""PassKey认证完成请求"""
credential: dict
challenge: str
@router.post("/passkey/register/start", summary="开始注册 PassKey", response_model=schemas.Response)
def passkey_register_start(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> Any:
"""开始注册 PassKey - 生成注册选项"""
try:
# 安全检查:默认需要先启用 OTP除非配置允许在未启用 OTP 时注册
if not current_user.is_otp and not settings.PASSKEY_ALLOW_REGISTER_WITHOUT_OTP:
return schemas.Response(
success=False,
message="为了确保在域名配置错误时仍能找回访问权限,请先启用 OTP 验证码再注册通行密钥"
)
# 获取用户已有的PassKey
existing_passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)
existing_credentials = _build_credential_list(existing_passkeys) if existing_passkeys else None
# 生成注册选项
options_json, challenge = PassKeyHelper.generate_registration_options(
user_id=current_user.id,
username=current_user.name,
display_name=current_user.settings.get('nickname') if current_user.settings else None,
existing_credentials=existing_credentials
)
return schemas.Response(
success=True,
data={
'options': options_json,
'challenge': challenge
}
)
except Exception as e:
logger.error(f"生成PassKey注册选项失败: {e}")
return schemas.Response(
success=False,
message=f"生成注册选项失败: {str(e)}"
)
@router.post("/passkey/register/finish", summary="完成注册 PassKey", response_model=schemas.Response)
def passkey_register_finish(
passkey_req: PassKeyRegistrationFinish,
current_user: Annotated[User, Depends(get_current_active_user)]
) -> Any:
"""完成注册 PassKey - 验证并保存凭证"""
try:
# 验证注册响应
credential_id, public_key, sign_count, aaguid = PassKeyHelper.verify_registration_response(
credential=passkey_req.credential,
expected_challenge=passkey_req.challenge
)
# 提取transports
transports = None
if 'response' in passkey_req.credential and 'transports' in passkey_req.credential['response']:
transports = ','.join(passkey_req.credential['response']['transports'])
# 保存到数据库
passkey = PassKey(
user_id=current_user.id,
credential_id=credential_id,
public_key=public_key,
sign_count=sign_count,
name=passkey_req.name or "通行密钥",
aaguid=aaguid,
transports=transports
)
passkey.create()
logger.info(f"用户 {current_user.name} 成功注册PassKey: {passkey_req.name}")
return schemas.Response(
success=True,
message="通行密钥注册成功"
)
except Exception as e:
logger.error(f"注册PassKey失败: {e}")
return schemas.Response(
success=False,
message=f"注册失败: {str(e)}"
)
@router.post("/passkey/authenticate/start", summary="开始 PassKey 认证", response_model=schemas.Response)
def passkey_authenticate_start(
passkey_req: PassKeyAuthenticationStart = Body(...)
) -> Any:
"""开始 PassKey 认证 - 生成认证选项"""
try:
existing_credentials = None
# 如果指定了用户名只允许该用户的PassKey
if passkey_req.username:
user = User.get_by_name(db=None, name=passkey_req.username)
existing_passkeys = PassKey.get_by_user_id(db=None, user_id=user.id) if user else None
if not user or not existing_passkeys:
return schemas.Response(
success=False,
message="认证失败"
)
existing_credentials = _build_credential_list(existing_passkeys)
# 生成认证选项
options_json, challenge = PassKeyHelper.generate_authentication_options(
existing_credentials=existing_credentials
)
return schemas.Response(
success=True,
data={
'options': options_json,
'challenge': challenge
}
)
except Exception as e:
logger.error(f"生成PassKey认证选项失败: {e}")
return schemas.Response(
success=False,
message="认证失败"
)
@router.post("/passkey/authenticate/finish", summary="完成 PassKey 认证", response_model=schemas.Token)
def passkey_authenticate_finish(
passkey_req: PassKeyAuthenticationFinish
) -> Any:
"""完成 PassKey 认证 - 验证凭证并返回 token"""
try:
# 提取并标准化凭证ID
try:
credential_id = _extract_and_standardize_credential_id(passkey_req.credential)
except ValueError as e:
logger.warning(f"PassKey认证失败提供的凭证无效: {e}")
raise HTTPException(status_code=401, detail="认证失败")
# 查找PassKey并获取用户
passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id)
user = User.get_by_id(db=None, user_id=passkey.user_id) if passkey else None
if not passkey or not user or not user.is_active:
raise HTTPException(status_code=401, detail="认证失败")
# 验证认证响应并更新
success, _ = _verify_passkey_and_update(
credential=passkey_req.credential,
challenge=passkey_req.challenge,
passkey=passkey
)
if not success:
raise HTTPException(status_code=401, detail="认证失败")
logger.info(f"用户 {user.name} 通过PassKey认证成功")
# 生成token
level = SitesHelper().auth_level
show_wizard = not SystemConfigOper().get(SystemConfigKey.SetupWizardState) and not settings.ADVANCED_MODE
return schemas.Token(
access_token=security.create_access_token(
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level
),
token_type="bearer",
super_user=user.is_superuser,
user_id=user.id,
user_name=user.name,
avatar=user.avatar,
level=level,
permissions=user.permissions or {},
wizard=show_wizard
)
except HTTPException:
raise
except Exception as e:
logger.error(f"PassKey认证失败: {e}")
raise HTTPException(status_code=401, detail="认证失败")
@router.get("/passkey/list", summary="获取当前用户的 PassKey 列表", response_model=schemas.Response)
def passkey_list(
current_user: Annotated[User, Depends(get_current_active_user)]
) -> Any:
"""获取当前用户的所有 PassKey"""
try:
passkeys = PassKey.get_by_user_id(db=None, user_id=current_user.id)
key_list = [
{
'id': pk.id,
'name': pk.name,
'created_at': pk.created_at.isoformat() if pk.created_at else None,
'last_used_at': pk.last_used_at.isoformat() if pk.last_used_at else None,
'aaguid': pk.aaguid,
'transports': pk.transports
}
for pk in passkeys
] if passkeys else []
return schemas.Response(
success=True,
data=key_list
)
except Exception as e:
logger.error(f"获取PassKey列表失败: {e}")
return schemas.Response(
success=False,
message=f"获取列表失败: {str(e)}"
)
@router.post("/passkey/delete", summary="删除 PassKey", response_model=schemas.Response)
async def passkey_delete(
data: PassKeyDeleteRequest,
current_user: User = Depends(get_current_active_user_async)
) -> Any:
"""删除指定的 PassKey"""
try:
# 验证密码
if not security.verify_password(data.password, str(current_user.hashed_password)):
return schemas.Response(success=False, message="密码错误")
success = PassKey.delete_by_id(db=None, passkey_id=data.passkey_id, user_id=current_user.id)
if success:
logger.info(f"用户 {current_user.name} 删除了PassKey: {data.passkey_id}")
return schemas.Response(
success=True,
message="通行密钥已删除"
)
else:
return schemas.Response(
success=False,
message="通行密钥不存在或无权删除"
)
except Exception as e:
logger.error(f"删除PassKey失败: {e}")
return schemas.Response(
success=False,
message=f"删除失败: {str(e)}"
)
@router.post("/passkey/verify", summary="PassKey 二次验证", response_model=schemas.Response)
def passkey_verify_mfa(
passkey_req: PassKeyAuthenticationFinish,
current_user: Annotated[User, Depends(get_current_active_user)]
) -> Any:
"""使用 PassKey 进行二次验证MFA"""
try:
# 提取并标准化凭证ID
try:
credential_id = _extract_and_standardize_credential_id(passkey_req.credential)
except ValueError as e:
logger.warning(f"PassKey二次验证失败提供的凭证无效: {e}")
return schemas.Response(success=False, message="验证失败")
# 查找PassKey必须属于当前用户
passkey = PassKey.get_by_credential_id(db=None, credential_id=credential_id)
if not passkey or passkey.user_id != current_user.id:
return schemas.Response(
success=False,
message="通行密钥不存在或不属于当前用户"
)
# 验证认证响应并更新
success, _ = _verify_passkey_and_update(
credential=passkey_req.credential,
challenge=passkey_req.challenge,
passkey=passkey
)
if not success:
return schemas.Response(
success=False,
message="通行密钥验证失败"
)
logger.info(f"用户 {current_user.name} 通过PassKey二次验证成功")
return schemas.Response(
success=True,
message="二次验证成功"
)
except Exception as e:
logger.error(f"PassKey二次验证失败: {e}")
return schemas.Response(
success=False,
message="验证失败"
)

View File

@@ -1,14 +1,16 @@
from typing import List, Any, Optional
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Body
from app import schemas
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.chain.ai_recommend import AIRecommendChain
from app.core.config import settings
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.log import logger
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
@@ -36,6 +38,9 @@ async def search_by_id(mediaid: str,
"""
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
"""
# 取消正在运行的AI推荐会清除数据库缓存
AIRecommendChain().cancel_ai_recommend()
if mtype:
media_type = MediaType(mtype)
else:
@@ -159,6 +164,9 @@ async def search_by_title(keyword: Optional[str] = None,
"""
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
# 取消正在运行的AI推荐并清除数据库缓存
AIRecommendChain().cancel_ai_recommend()
torrents = await SearchChain().async_search_by_title(
title=keyword, page=page,
sites=[int(site) for site in sites.split(",") if site] if sites else None,
@@ -167,3 +175,87 @@ async def search_by_title(keyword: Optional[str] = None,
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
@router.post("/recommend", summary="AI推荐资源", response_model=schemas.Response)
async def recommend_search_results(
filtered_indices: Optional[List[int]] = Body(None, embed=True, description="筛选后的索引列表"),
check_only: bool = Body(False, embed=True, description="仅检查状态,不启动新任务"),
force: bool = Body(False, embed=True, description="强制重新推荐,清除旧结果"),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
AI推荐资源 - 轮询接口
前端轮询此接口,发送筛选后的索引(如果有筛选)
后端根据请求变化自动取消旧任务并启动新任务
参数:
- filtered_indices: 筛选后的索引列表(可选,为空或不提供时使用所有结果)
- check_only: 仅检查状态(首次打开页面时使用,避免触发不必要的重新推理)
- force: 强制重新推荐(清除旧结果并重新启动)
返回数据结构:
{
"success": bool,
"message": string, // 错误信息(仅在错误时存在)
"data": {
"status": string, // 状态: disabled | idle | running | completed | error
"results": array // 推荐结果仅status=completed时存在
}
}
"""
# 从缓存获取上次搜索结果
results = await SearchChain().async_last_search_results() or []
if not results:
return schemas.Response(success=False, message="没有可用的搜索结果", data={
"status": "error"
})
recommend_chain = AIRecommendChain()
# 如果是强制模式,先取消并清除旧结果,然后直接启动新任务
if force:
# 检查功能是否启用
if not settings.AI_AGENT_ENABLE or not settings.AI_RECOMMEND_ENABLED:
return schemas.Response(success=True, data={
"status": "disabled"
})
logger.info("收到新推荐请求,清除旧结果并启动新任务")
recommend_chain.cancel_ai_recommend()
recommend_chain.start_recommend_task(filtered_indices, len(results), results)
# 直接返回运行中状态
return schemas.Response(success=True, data={
"status": "running"
})
# 如果是仅检查模式,不传递 filtered_indices避免触发请求变化检测
if check_only:
# 返回当前运行状态,不做任何任务启动或取消操作
current_status = recommend_chain.get_current_status_only()
# 如果有错误将错误信息放到message中
if current_status.get("status") == "error":
error_msg = current_status.pop("error", "未知错误")
return schemas.Response(success=False, message=error_msg, data=current_status)
return schemas.Response(success=True, data=current_status)
# 获取当前状态(会检测请求是否变化)
status_data = recommend_chain.get_status(filtered_indices, len(results))
# 如果功能未启用,直接返回禁用状态
if status_data.get("status") == "disabled":
return schemas.Response(success=True, data=status_data)
# 如果是空闲状态,启动新任务
if status_data["status"] == "idle":
recommend_chain.start_recommend_task(filtered_indices, len(results), results)
# 立即返回运行中状态
return schemas.Response(success=True, data={
"status": "running"
})
# 如果有错误将错误信息放到message中
if status_data.get("status") == "error":
error_msg = status_data.pop("error", "未知错误")
return schemas.Response(success=False, message=error_msg, data=status_data)
# 返回当前状态
return schemas.Response(success=True, data=status_data)

View File

@@ -167,7 +167,7 @@ def rename(fileitem: schemas.FileItem,
# 重命名目录内文件
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
if sub_files:

View File

@@ -130,22 +130,53 @@ async def cache_img(
def get_global_setting(token: str):
"""
查询非敏感系统设置(默认鉴权)
仅包含登录前UI初始化必需的字段
"""
if token != "moviepilot":
raise HTTPException(status_code=403, detail="Forbidden")
# FIXME: 新增敏感配置项时要在此处添加排除项
# 白名单模式仅包含登录前UI初始化必需的字段
info = settings.model_dump(
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN", "U115_APP_ID",
"ALIPAN_APP_ID", "TVDB_V4_API_KEY", "TVDB_V4_API_PIN"}
include={
"TMDB_IMAGE_DOMAIN",
"GLOBAL_IMAGE_CACHE",
"ADVANCED_MODE",
}
)
# 追加版本信息(用于版本检查)
info.update({
"FRONTEND_VERSION": SystemChain.get_frontend_version(),
"BACKEND_VERSION": APP_VERSION
})
return schemas.Response(success=True,
data=info)
@router.get("/global/user", summary="查询用户相关系统设置", response_model=schemas.Response)
async def get_user_global_setting(_: User = Depends(get_current_active_user_async)):
"""
查询用户相关系统设置(登录后获取)
包含业务功能相关的配置和用户权限信息
"""
# 业务功能相关的配置字段
info = settings.model_dump(
include={
"RECOGNIZE_SOURCE",
"SEARCH_SOURCE",
"AI_RECOMMEND_ENABLED",
"PASSKEY_ALLOW_REGISTER_WITHOUT_OTP"
}
)
# 智能助手总开关未开启智能推荐状态强制返回False
if not settings.AI_AGENT_ENABLE:
info["AI_RECOMMEND_ENABLED"] = False
# 追加用户唯一ID和订阅分享管理权限
share_admin = SubscribeHelper().is_admin_user()
info.update({
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
"SUBSCRIBE_SHARE_MANAGE": share_admin,
"WORKFLOW_SHARE_MANAGE": share_admin
"WORKFLOW_SHARE_MANAGE": share_admin,
})
return schemas.Response(success=True,
data=info)

View File

@@ -111,45 +111,6 @@ async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db),
return schemas.Response(success=True, message=file.filename)
@router.post('/otp/generate', summary='生成otp验证uri', response_model=schemas.Response)
def otp_generate(
current_user: User = Depends(get_current_active_user)
) -> Any:
secret, uri = OtpUtils.generate_secret_key(current_user.name)
return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri})
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
async def otp_judge(
data: dict,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async)
) -> Any:
uri = data.get("uri")
otp_password = data.get("otpPassword")
if not OtpUtils.is_legal(uri, otp_password):
return schemas.Response(success=False, message="验证码错误")
await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
return schemas.Response(success=True)
@router.post('/otp/disable', summary='关闭当前用户的otp验证', response_model=schemas.Response)
async def otp_disable(
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async)
) -> Any:
await current_user.async_update_otp_by_name(db, current_user.name, False, "")
return schemas.Response(success=True)
@router.get('/otp/{userid}', summary='判断当前用户是否开启otp验证', response_model=schemas.Response)
async def otp_enable(userid: str, db: AsyncSession = Depends(get_async_db)) -> Any:
user: User = await User.async_get_by_name(db, userid)
if not user:
return schemas.Response(success=False)
return schemas.Response(success=user.is_otp)
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
def get_config(key: str,
current_user: User = Depends(get_current_active_user)):

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Callable, Any, Dict, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request, Response
from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
@@ -128,9 +128,12 @@ async def get_cookie(
@cookie_router.post("/get/{uuid}")
async def post_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
request: schemas.CookiePassword):
request: Optional[schemas.CookiePassword] = Body(None)):
"""
POST 下载加密数据
"""
data = await load_encrypt_data(uuid)
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
if request is not None:
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])
else:
return data

View File

@@ -4,6 +4,7 @@ import pickle
import traceback
from abc import ABCMeta
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Optional, Any, Tuple, List, Set, Union, Dict
@@ -849,6 +850,8 @@ class ChainBase(metaclass=ABCMeta):
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
# 添加格式化的时间参数
kwargs.setdefault('current_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
# 渲染消息
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
@@ -932,6 +935,8 @@ class ChainBase(metaclass=ABCMeta):
:param kwargs: 其他参数(覆盖业务对象属性值)
:return: 成功或失败
"""
# 添加格式化的时间参数
kwargs.setdefault('current_time', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
# 渲染消息
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)

318
app/chain/ai_recommend.py Normal file
View File

@@ -0,0 +1,318 @@
import re
from typing import List, Optional, Dict, Any
import asyncio
import hashlib
import json
from app.chain import ChainBase
from app.core.config import settings
from app.log import logger
from app.utils.common import log_execution_time
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class AIRecommendChain(ChainBase, metaclass=Singleton):
"""
AI推荐处理链单例运行
用于基于搜索结果的AI智能推荐
"""
# 缓存文件名
__ai_indices_cache_file = "__ai_recommend_indices__"
# AI推荐状态
_ai_recommend_running = False
_ai_recommend_task: Optional[asyncio.Task] = None
_current_request_hash: Optional[str] = None # 当前请求的哈希值
_ai_recommend_result: Optional[List[int]] = None # AI推荐索引缓存索引列表
_ai_recommend_error: Optional[str] = None # AI推荐错误信息
@staticmethod
def _calculate_request_hash(
filtered_indices: Optional[List[int]], search_results_count: int
) -> str:
"""
计算请求的哈希值,用于判断请求是否变化
"""
request_data = {
"filtered_indices": filtered_indices or [],
"search_results_count": search_results_count,
}
return hashlib.md5(
json.dumps(request_data, sort_keys=True).encode()
).hexdigest()
@property
def is_enabled(self) -> bool:
"""
检查AI推荐功能是否已启用。
"""
return settings.AI_AGENT_ENABLE and settings.AI_RECOMMEND_ENABLED
def _build_status(self) -> Dict[str, Any]:
"""
构建AI推荐状态字典
:return: 状态字典
"""
if not self.is_enabled:
return {"status": "disabled"}
if self._ai_recommend_running:
return {"status": "running"}
# 尝试从数据库加载缓存
if self._ai_recommend_result is None:
cached_indices = self.load_cache(self.__ai_indices_cache_file)
if cached_indices is not None:
self._ai_recommend_result = cached_indices
# 只要有结果始终返回completed状态和数据
if self._ai_recommend_result is not None:
return {"status": "completed", "results": self._ai_recommend_result}
if self._ai_recommend_error is not None:
return {"status": "error", "error": self._ai_recommend_error}
return {"status": "idle"}
def get_current_status_only(self) -> Dict[str, Any]:
"""
获取当前状态不校验hash用于check_only模式
"""
return self._build_status()
def get_status(
self, filtered_indices: Optional[List[int]], search_results_count: int
) -> Dict[str, Any]:
"""
获取AI推荐状态并检查请求是否变化用于首次请求或force模式
如果请求变化筛选条件变化返回idle状态
"""
# 计算当前请求的hash
request_hash = self._calculate_request_hash(
filtered_indices, search_results_count
)
# 检查请求是否变化
is_same_request = request_hash == self._current_request_hash
# 如果请求变化了筛选条件改变返回idle状态
if not is_same_request:
return {"status": "idle"} if self.is_enabled else {"status": "disabled"}
# 请求未变化,返回当前实际状态
return self._build_status()
@log_execution_time(logger=logger)
async def async_ai_recommend(self, items: List[str], preference: str = None) -> str:
"""
AI推荐
:param items: 候选资源列表(JSON字符串格式)
:param preference: 用户偏好(可选)
:return: AI返回的推荐结果
"""
# 设置运行状态
self._ai_recommend_running = True
try:
# 导入LLMHelper
from app.helper.llm import LLMHelper
# 获取LLM实例
llm = LLMHelper.get_llm()
# 构建提示词
user_preference = (
preference
or settings.AI_RECOMMEND_USER_PREFERENCE
or "Prefer high-quality resources with more seeders"
)
# 添加指令
instruction = """
Task: Select the best matching items from the list based on user preferences.
Each item contains:
- index: Item number
- title: Full torrent title
- size: File size
- seeders: Number of seeders
Output Format: Return ONLY a JSON array of "index" numbers (e.g., [0, 3, 1]). Do NOT include any explanations or other text.
"""
message = (
f"User Preference: {user_preference}\n{instruction}\nCandidate Resources:\n"
+ "\n".join(items)
)
# 调用LLM
response = await llm.ainvoke(message)
return response.content
except ValueError as e:
logger.error(f"AI推荐配置错误: {e}")
raise
except Exception as e:
raise
finally:
# 清除运行状态
self._ai_recommend_running = False
self._ai_recommend_task = None
def is_ai_recommend_running(self) -> bool:
"""
检查AI推荐是否正在运行
"""
return self._ai_recommend_running
def cancel_ai_recommend(self):
"""
取消正在运行的AI推荐任务
"""
if self._ai_recommend_task and not self._ai_recommend_task.done():
self._ai_recommend_task.cancel()
self._ai_recommend_running = False
self._ai_recommend_task = None
self._current_request_hash = None
self._ai_recommend_result = None
self._ai_recommend_error = None
self.remove_cache(self.__ai_indices_cache_file)
def start_recommend_task(
self,
filtered_indices: Optional[List[int]],
search_results_count: int,
results: List[Any],
) -> None:
"""
启动AI推荐任务
:param filtered_indices: 筛选后的索引列表
:param search_results_count: 搜索结果总数
:param results: 搜索结果列表
"""
# 防护检查确保AI推荐功能已启用
if not self.is_enabled:
logger.warning("AI推荐功能未启用跳过任务执行")
return
# 计算新请求的哈希值
new_request_hash = self._calculate_request_hash(
filtered_indices, search_results_count
)
# 如果请求变化了,取消旧任务
if new_request_hash != self._current_request_hash:
self.cancel_ai_recommend()
# 更新请求哈希值
self._current_request_hash = new_request_hash
# 重置状态
self._ai_recommend_result = None
self._ai_recommend_error = None
# 启动新任务
async def run_recommend():
# 获取当前任务对象用于在finally中比对
current_task = asyncio.current_task()
try:
self._ai_recommend_running = True
# 准备数据
items = []
valid_indices = []
max_items = settings.AI_RECOMMEND_MAX_ITEMS or 50
# 如果提供了筛选索引,先筛选结果;否则使用所有结果
if filtered_indices is not None and len(filtered_indices) > 0:
results_to_process = [
results[i]
for i in filtered_indices
if 0 <= i < len(results)
]
else:
results_to_process = results
for i, torrent in enumerate(results_to_process):
if len(items) >= max_items:
break
if not torrent.torrent_info:
continue
valid_indices.append(i)
item_info = {
"index": i,
"title": torrent.torrent_info.title or "未知",
"size": (
StringUtils.format_size(torrent.torrent_info.size)
if torrent.torrent_info.size
else "0 B"
),
"seeders": torrent.torrent_info.seeders or 0,
}
items.append(json.dumps(item_info, ensure_ascii=False))
if not items:
self._ai_recommend_error = "没有可用于AI推荐的资源"
return
# 调用AI推荐
ai_response = await self.async_ai_recommend(items)
# 解析AI返回的索引
try:
# 使用正则提取JSON数组非贪婪模式避免匹配多个数组
json_match = re.search(r'\[.*?\]', ai_response, re.DOTALL)
if not json_match:
raise ValueError(ai_response)
ai_indices = json.loads(json_match.group())
if not isinstance(ai_indices, list):
raise ValueError(f"AI返回格式错误: {ai_response}")
# 映射回原始索引
if filtered_indices:
original_indices = [
filtered_indices[valid_indices[i]]
for i in ai_indices
if i < len(valid_indices)
and 0 <= filtered_indices[valid_indices[i]] < len(results)
]
else:
original_indices = [
valid_indices[i]
for i in ai_indices
if i < len(valid_indices)
and 0 <= valid_indices[i] < len(results)
]
# 只返回索引列表,不返回完整数据
self._ai_recommend_result = original_indices
# 保存到数据库
self.save_cache(original_indices, self.__ai_indices_cache_file)
logger.info(f"AI推荐完成: {len(original_indices)}")
except Exception as e:
logger.error(
f"解析AI返回结果失败: {e}, 原始响应: {ai_response}"
)
self._ai_recommend_error = str(e)
except asyncio.CancelledError:
logger.info("AI推荐任务被取消")
except Exception as e:
logger.error(f"AI推荐任务失败: {e}")
self._ai_recommend_error = str(e)
finally:
# 只有当 self._ai_recommend_task 仍然是当前任务时,才清理状态
# 如果任务被取消并启动了新任务self._ai_recommend_task 已经指向新任务,不应重置
if self._ai_recommend_task == current_task:
self._ai_recommend_running = False
self._ai_recommend_task = None
# 创建并启动任务
self._ai_recommend_task = asyncio.create_task(run_recommend())

View File

@@ -292,6 +292,10 @@ class DownloadChain(ChainBase):
# 登记下载记录
downloadhis = DownloadHistoryOper()
# 获取应用的识别词(如果有)
custom_words_str = None
if hasattr(_meta, 'apply_words') and _meta.apply_words:
custom_words_str = '\n'.join(_meta.apply_words)
downloadhis.add(
path=download_path.as_posix(),
type=_media.type.value,
@@ -315,6 +319,7 @@ class DownloadChain(ChainBase):
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
media_category=_media.category,
episode_group=_media.episode_group,
custom_words=custom_words_str,
note={"source": source}
)
@@ -327,9 +332,10 @@ class DownloadChain(ChainBase):
if not file_meta.begin_episode \
or file_meta.begin_episode not in episodes:
continue
# 只处理视频格式
# 只处理视频、字幕格式
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if not Path(file).suffix \
or Path(file).suffix.lower() not in settings.RMT_MEDIAEXT:
or Path(file).suffix.lower() not in media_exts:
continue
files_to_add.append({
"download_hash": _hash,

View File

@@ -315,21 +315,6 @@ class MediaChain(ChainBase):
)
return None
@staticmethod
def is_bluray_folder(fileitem: schemas.FileItem) -> bool:
"""
判断是否为原盘目录
"""
if not fileitem or fileitem.type != "dir":
return False
# 蓝光原盘目录必备的文件或文件夹
required_files = ['BDMV', 'CERTIFICATE']
# 检查目录下是否存在所需文件或文件夹
for item in StorageChain().list_files(fileitem):
if item.name in required_files:
return True
return False
@eventmanager.register(EventType.MetadataScrape)
def scrape_metadata_event(self, event: Event):
"""
@@ -370,7 +355,7 @@ class MediaChain(ChainBase):
else:
if file_list:
# 如果是BDMV原盘目录只对根目录进行刮削不处理子目录
if self.is_bluray_folder(fileitem):
if storagechain.is_bluray_folder(fileitem):
logger.info(f"检测到BDMV原盘目录只对根目录进行刮削{fileitem.path}")
self.scrape_metadata(fileitem=fileitem,
mediainfo=mediainfo,
@@ -563,10 +548,23 @@ class MediaChain(ChainBase):
logger.info("电影NFO刮削已关闭跳过")
else:
# 电影目录
if recursive:
# 处理文件
if self.is_bluray_folder(fileitem):
# 原盘目录
files = __list_files(_fileitem=fileitem)
is_bluray_folder = storagechain.contains_bluray_subdirectories(files)
if recursive and not is_bluray_folder:
# 处理非原盘目录内的文件
for file in files:
if file.type == "dir":
# 电影不处理子目录
continue
self.scrape_metadata(fileitem=file,
mediainfo=mediainfo,
init_folder=False,
parent=fileitem,
overwrite=overwrite)
# 生成目录内图片文件
if init_folder:
if is_bluray_folder:
# 检查电影NFO开关
if scraping_switchs.get('movie_nfo', True):
nfo_path = filepath / (filepath.name + ".nfo")
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
@@ -581,20 +579,6 @@ class MediaChain(ChainBase):
logger.info(f"已存在nfo文件{nfo_path}")
else:
logger.info("电影NFO刮削已关闭跳过")
else:
# 处理目录内的文件
files = __list_files(_fileitem=fileitem)
for file in files:
if file.type == "dir":
# 电影不处理子目录
continue
self.scrape_metadata(fileitem=file,
mediainfo=mediainfo,
init_folder=False,
parent=fileitem,
overwrite=overwrite)
# 生成目录内图片文件
if init_folder:
# 图片
image_dict = self.metadata_img(mediainfo=mediainfo)
if image_dict:
@@ -618,7 +602,7 @@ class MediaChain(ChainBase):
should_scrape = True # 未知类型默认刮削
if should_scrape:
image_path = filepath.with_name(image_name)
image_path = filepath / image_name
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
path=image_path):
# 流式下载图片并直接保存
@@ -681,7 +665,11 @@ class MediaChain(ChainBase):
if recursive:
files = __list_files(_fileitem=fileitem)
for file in files:
if file.type == "dir" and not file.name.lower().startswith("season"):
if (
file.type == "dir"
and file.name not in settings.RENAME_FORMAT_S0_NAMES
and not file.name.lower().startswith("season")
):
# 电视剧不处理非季子目录
continue
self.scrape_metadata(fileitem=file,
@@ -691,11 +679,19 @@ class MediaChain(ChainBase):
overwrite=overwrite)
# 生成目录的nfo和图片
if init_folder:
# TODO 目前的刮削是假定电视剧目录结构符合:/剧集根目录/季目录/剧集文件
# 其中季目录应符合`Season 数字`等明确的季命名,不能用季标题
# 例如:/Torchwood (2006)/Miracle Day/Torchwood (2006) S04E01.mkv
# 当刮削到`Miracle Day`目录时,会误判其为剧集根目录
# 识别文件夹名称
season_meta = MetaInfo(filepath.name)
# 当前文件夹为Specials或者SPs时设置为S0
if filepath.name in settings.RENAME_FORMAT_S0_NAMES:
season_meta.begin_season = 0
elif season_meta.name and season_meta.begin_season is not None:
# 当前目录含有非季目录的名称,但却有季信息(通常是被辅助识别词指定了)
# 这种情况应该是剧集根目录,不能按季目录刮削,否则会导致`season_poster`的路径错误 详见issue#5373
season_meta.begin_season = None
if season_meta.begin_season is not None:
# 检查季NFO开关
if scraping_switchs.get('season_nfo', True):
@@ -765,7 +761,8 @@ class MediaChain(ChainBase):
else:
logger.info(f"季图片刮削已关闭,跳过:{image_name}")
# 判断当前目录是不是剧集根目录
if not season_meta.season:
elif season_meta.name:
# 不含季信息(包括特别季)但含有名称的,可以认为是剧集根目录
# 检查电视剧NFO开关
if scraping_switchs.get('tv_nfo', True):
# 是否已存在

View File

@@ -195,10 +195,14 @@ class MessageChain(ChainBase):
if text.isdigit():
# 用户选择了具体的条目
# 缓存
cache_data: dict = user_cache.get(userid).copy()
cache_data: dict = user_cache.get(userid)
if not cache_data:
# 发送消息
self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid))
return
cache_data = cache_data.copy()
# 选择项目
if not cache_data \
or not cache_data.get('items') \
if not cache_data.get('items') \
or len(cache_data.get('items')) < int(text):
# 发送消息
self.post_message(Notification(channel=channel, source=source, title="输入有误!", userid=userid))
@@ -370,12 +374,13 @@ class MessageChain(ChainBase):
del cache_data
elif text.lower() == "p":
# 上一页
cache_data: dict = user_cache.get(userid).copy()
cache_data: dict = user_cache.get(userid)
if not cache_data:
# 没有缓存
self.post_message(Notification(
channel=channel, source=source, title="输入有误!", userid=userid))
return
cache_data = cache_data.copy()
try:
if _current_page == 0:
# 第一页
@@ -422,12 +427,13 @@ class MessageChain(ChainBase):
del cache_data
elif text.lower() == "n":
# 下一页
cache_data: dict = user_cache.get(userid).copy()
cache_data: dict = user_cache.get(userid)
if not cache_data:
# 没有缓存
self.post_message(Notification(
channel=channel, source=source, title="输入有误!", userid=userid))
return
cache_data = cache_data.copy()
try:
cache_type: str = cache_data.get('type')
# 产生副本,避免修改原值

View File

@@ -29,6 +29,7 @@ class SearchChain(ChainBase):
"""
__result_temp_file = "__search_result__"
__ai_result_temp_file = "__ai_search_result__"
def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
@@ -98,6 +99,18 @@ class SearchChain(ChainBase):
"""
return await self.async_load_cache(self.__result_temp_file)
async def async_last_ai_results(self) -> Optional[List[Context]]:
"""
异步获取上次AI推荐结果
"""
return await self.async_load_cache(self.__ai_result_temp_file)
async def async_save_ai_results(self, results: List[Context]):
"""
异步保存AI推荐结果
"""
await self.async_save_cache(results, self.__ai_result_temp_file)
async def async_search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
sites: List[int] = None, cache_local: bool = False) -> List[Context]:

View File

@@ -44,6 +44,7 @@ class SiteChain(ChainBase):
"star-space.net": self.__indexphp_test,
"yemapt.org": self.__yema_test,
"hddolby.com": self.__hddolby_test,
"rousi.pro": self.__rousi_test,
}
def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]:
@@ -249,6 +250,32 @@ class SiteChain(ChainBase):
else:
return False, f"错误:{res.status_code} {res.reason}"
@staticmethod
def __rousi_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆rousi
"""
url = f"https://{StringUtils.get_url_domain(site.url)}/api/v1/profile"
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {site.apikey}",
}
res = RequestUtils(
headers=headers,
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=url)
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("code") == 0:
return True, "连接成功"
return False, "APIKEY已过期"
else:
return False, f"错误:{res.status_code} {res.reason}"
@staticmethod
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
"""
@@ -462,20 +489,18 @@ class SiteChain(ChainBase):
logger.warn(f"站点 {domain} 索引器不存在!")
return
# 查询站点图标
site_icon = siteoper.get_icon_by_domain(domain)
if not site_icon or not site_icon.base64:
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
cookie=cookie,
ua=settings.USER_AGENT)
if icon_url:
siteoper.update_icon(name=indexer.get("name"),
domain=domain,
icon_url=icon_url,
icon_base64=icon_base64)
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
else:
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
cookie=cookie,
ua=settings.USER_AGENT)
if icon_url:
siteoper.update_icon(name=indexer.get("name"),
domain=domain,
icon_url=icon_url,
icon_base64=icon_base64)
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
else:
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
@eventmanager.register(EventType.SiteUpdated)
def clear_site_data(self, event: Event):

View File

@@ -133,22 +133,33 @@ class StorageChain(ChainBase):
"""
return self.run_module("support_transtype", storage=storage)
def is_bluray_folder(self, fileitem: Optional[schemas.FileItem]) -> bool:
"""
检查是否蓝光目录
"""
if not fileitem or fileitem.type != "dir":
return False
if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / "BDMV"):
return True
if self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path) / "CERTIFICATE"):
return True
return False
@staticmethod
def contains_bluray_subdirectories(fileitems: Optional[List[schemas.FileItem]]) -> bool:
"""
判断是否包含蓝光必备的文件夹
"""
required_files = ("BDMV", "CERTIFICATE")
return any(
item.type == "dir" and item.name in required_files
for item in fileitems or []
)
def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool:
"""
删除媒体文件,以及不含媒体文件的目录
"""
def __is_bluray_dir(_fileitem: schemas.FileItem) -> bool:
"""
检查是否蓝光目录
"""
_dir_files = self.list_files(fileitem=_fileitem, recursion=False)
if _dir_files:
for _f in _dir_files:
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
return True
return False
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
fileitem_path = Path(fileitem.path) if fileitem.path else Path("")
if len(fileitem_path.parts) <= 2:
@@ -156,7 +167,7 @@ class StorageChain(ChainBase):
return False
if fileitem.type == "dir":
# 本身是目录
if __is_bluray_dir(fileitem):
if self.is_bluray_folder(fileitem):
logger.warn(f"正在删除蓝光原盘目录:【{fileitem.storage}{fileitem.path}")
if not self.delete_file(fileitem):
logger.warn(f"{fileitem.storage}{fileitem.path} 删除失败")

View File

@@ -42,7 +42,7 @@ class SubscribeChain(ChainBase):
_LOCK_TIMOUT = 3600 * 2
@staticmethod
def __get_event_meida(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
def __get_event_media(_mediaid: str, _meta: MetaBase) -> Optional[MediaInfo]:
"""
广播事件解析媒体信息
"""
@@ -158,7 +158,7 @@ class SubscribeChain(ChainBase):
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = self.__get_event_meida(mediaid, metainfo)
mediainfo = self.__get_event_media(mediaid, metainfo)
else:
# 使用TMDBID识别
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid,
@@ -169,7 +169,7 @@ class SubscribeChain(ChainBase):
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
elif mediaid:
# 未知前缀,广播事件解析媒体信息
mediainfo = self.__get_event_meida(mediaid, metainfo)
mediainfo = self.__get_event_media(mediaid, metainfo)
if mediainfo:
# 豆瓣标题处理
meta = MetaInfo(mediainfo.title)
@@ -1635,7 +1635,7 @@ class SubscribeChain(ChainBase):
info = schemas.SubscribeEpisodeInfo()
info.title = episode.name
info.description = episode.overview
info.backdrop = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/w500${episode.still_path}"
info.backdrop = settings.TMDB_IMAGE_URL(episode.still_path, "w500")
episodes[episode.episode_number] = info
elif subscribe.type == MediaType.TV.value:
# 根据开始结束集计算集信息

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,10 @@ class UserChain(ChainBase):
success, user_or_message = self.password_authenticate(credentials=credentials)
if success:
# 如果用户启用了二次验证码,则进一步验证
if not self._verify_mfa(user_or_message, credentials.mfa_code):
mfa_result = self._verify_mfa(user_or_message, credentials.mfa_code)
if mfa_result == "MFA_REQUIRED":
return False, "MFA_REQUIRED"
elif not mfa_result:
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
logger.info(f"用户 {username} 通过密码认证成功")
return True, user_or_message
@@ -63,7 +66,10 @@ class UserChain(ChainBase):
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
if aux_success:
# 辅助认证成功后再验证二次验证码
if not self._verify_mfa(aux_user_or_message, credentials.mfa_code):
mfa_result = self._verify_mfa(aux_user_or_message, credentials.mfa_code)
if mfa_result == "MFA_REQUIRED":
return False, "MFA_REQUIRED"
elif not mfa_result:
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
return True, aux_user_or_message
else:
@@ -159,22 +165,46 @@ class UserChain(ChainBase):
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
@staticmethod
def _verify_mfa(user: User, mfa_code: Optional[str]) -> bool:
def _verify_mfa(user: User, mfa_code: Optional[str]) -> Union[bool, str]:
"""
验证 MFA二次验证码
检查用户是否启用了 OTP 或 PassKey如果启用了任何一种都需要提供验证
:param user: 用户对象
:param mfa_code: 二次验证码
:return: 如果验证成功返回 True否则返回 False
:param mfa_code: 二次验证码如果提供了则验证OTP
:return:
- 如果验证成功返回 True
- 如果需要MFA但未提供返回 "MFA_REQUIRED"
- 如果MFA验证失败返回 False
"""
if not user.is_otp:
# 检查用户是否有PassKey
from app.db.models.passkey import PassKey
has_passkey = bool(PassKey.get_by_user_id(db=None, user_id=user.id))
# 如果用户既没有启用OTP也没有PassKey直接通过
if not user.is_otp and not has_passkey:
return True
# 如果用户启用了OTP或PassKey但没有提供验证码需要进行二次验证
if not mfa_code:
logger.info(f"用户 {user.name} 缺少 MFA 认证码")
return False
if not OtpUtils.check(str(user.otp_secret), mfa_code):
logger.info(f"用户 {user.name} 的 MFA 认证失败")
return False
logger.info(f"用户 {user.name} 已启用双重验证OTP: {user.is_otp}, PassKey: {has_passkey}),需要提供验证码")
return "MFA_REQUIRED"
# 如果提供了验证码,且用户启用了 OTP则验证 OTP
if user.is_otp:
if not OtpUtils.check(str(user.otp_secret), mfa_code):
logger.info(f"用户 {user.name} 的 MFA 认证失败")
return False
# OTP 验证成功
return True
# 用户未启用 OTP此时提供的 mfa_code 无效;如果启用了 PassKey则仍需通过 PassKey 验证
if has_passkey:
logger.info(
f"用户 {user.name} 未启用 OTP但已启用 PassKey提供的 MFA 验证码将被忽略,仍需通过 PassKey 验证"
)
return "MFA_REQUIRED"
return True
def _process_auth_success(self, username: str, credentials: AuthCredentials) -> bool:

View File

@@ -219,7 +219,7 @@ class ConfigModel(BaseModel):
AUTO_UPDATE_RESOURCE: bool = True
# ==================== 媒体文件格式配置 ====================
# 支持的后缀格式
# 支持的视频文件后缀格式
RMT_MEDIAEXT: list = Field(
default_factory=lambda: ['.mp4', '.mkv', '.ts', '.iso',
'.rmvb', '.avi', '.mov', '.mpeg',
@@ -230,8 +230,6 @@ class ConfigModel(BaseModel):
# 支持的字幕文件后缀格式
RMT_SUBEXT: list = Field(default_factory=lambda: ['.srt', '.ass', '.ssa', '.sup'])
# 支持的音轨文件后缀格式
RMT_AUDIO_TRACK_EXT: list = Field(default_factory=lambda: ['.mka'])
# 音轨文件后缀格式
RMT_AUDIOEXT: list = Field(
default_factory=lambda: ['.aac', '.ac3', '.amr', '.caf', '.cda', '.dsf',
'.dff', '.kar', '.m4a', '.mp1', '.mp2', '.mp3',
@@ -278,7 +276,7 @@ class ConfigModel(BaseModel):
# 搜索多个名称
SEARCH_MULTIPLE_NAME: bool = False
# 最大搜索名称数量
MAX_SEARCH_NAME_LIMIT: int = 2
MAX_SEARCH_NAME_LIMIT: int = 3
# ==================== 下载配置 ====================
# 种子标签
@@ -305,6 +303,8 @@ class ConfigModel(BaseModel):
COOKIECLOUD_BLACKLIST: Optional[str] = None
# ==================== 整理配置 ====================
# 文件整理线程数
TRANSFER_THREADS: int = 1
# 电影重命名格式
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
@@ -393,6 +393,10 @@ class ConfigModel(BaseModel):
])
# 允许的图片文件后缀格式
SECURITY_IMAGE_SUFFIXES: list = Field(default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"])
# PassKey 是否强制用户验证(生物识别等)
PASSKEY_REQUIRE_UV: bool = True
# 允许在未启用 OTP 时直接注册 PassKey
PASSKEY_ALLOW_REGISTER_WITHOUT_OTP: bool = False
# ==================== 工作流配置 ====================
# 工作流数据共享
@@ -407,6 +411,8 @@ class ConfigModel(BaseModel):
# ==================== Docker配置 ====================
# Docker Client API地址
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
# Playwright浏览器类型chromium/firefox
PLAYWRIGHT_BROWSER_TYPE: str = "chromium"
# ==================== AI智能体配置 ====================
# AI智能体开关
@@ -430,11 +436,17 @@ class ConfigModel(BaseModel):
# 是否启用详细日志
LLM_VERBOSE: bool = False
# 最大记忆消息数量
LLM_MAX_MEMORY_MESSAGES: int = 50
# 记忆保留天数
LLM_MEMORY_RETENTION_DAYS: int = 30
LLM_MAX_MEMORY_MESSAGES: int = 30
# 内存记忆保留天数
LLM_MEMORY_RETENTION_DAYS: int = 1
# Redis记忆保留天数如果使用Redis
LLM_REDIS_MEMORY_RETENTION_DAYS: int = 7
# 是否启用AI推荐
AI_RECOMMEND_ENABLED: bool = False
# AI推荐用户偏好
AI_RECOMMEND_USER_PREFERENCE: str = ""
# AI推荐条目数量限制
AI_RECOMMEND_MAX_ITEMS: int = 50
class Settings(BaseSettings, ConfigModel, LogConfigModel):
@@ -839,6 +851,22 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
rename_format = re.sub(r'/+', '/', rename_format)
return rename_format.strip("/")
def TMDB_IMAGE_URL(
self, file_path: Optional[str], file_size: str = "original"
) -> Optional[str]:
"""
获取TMDB图片网址
:param file_path: TMDB API返回的xxx_path
:param file_size: 图片大小,例如:'original', 'w500'
:return: 图片的完整URL如果 file_path 为空则返回 None
"""
if not file_path:
return None
return (
f"https://{self.TMDB_IMAGE_DOMAIN}/t/p/{file_size}/{file_path.removeprefix('/')}"
)
# 实例化配置
settings = Settings()

View File

@@ -95,18 +95,20 @@ class TorrentInfo:
if upload_volume_factor is None or download_volume_factor is None:
return "未知"
free_strs = {
"1.0 1.0": "普通",
"1.0 0.0": "免费",
"2.0 1.0": "2X",
"4.0 1.0": "4X",
"2.0 0.0": "2X免费",
"4.0 0.0": "4X免费",
"1.0 0.5": "50%",
"2.0 0.5": "2X 50%",
"1.0 0.7": "70%",
"1.0 0.3": "30%"
"1.00 1.00": "普通",
"1.00 0.00": "免费",
"2.00 1.00": "2X",
"4.00 1.00": "4X",
"2.00 0.00": "2X免费",
"4.00 0.00": "4X免费",
"1.00 0.50": "50%",
"2.00 0.50": "2X 50%",
"1.00 0.70": "70%",
"1.00 0.30": "30%",
"1.00 0.75": "75%",
"1.00 0.25": "25%"
}
return free_strs.get('%.1f %.1f' % (upload_volume_factor, download_volume_factor), "未知")
return free_strs.get('%.2f %.2f' % (upload_volume_factor, download_volume_factor), "未知")
@property
def volume_factor(self):
@@ -477,11 +479,11 @@ class MediaInfo:
self.episode_groups = info.pop("episode_groups").get("results") or []
# 海报
if info.get('poster_path'):
self.poster_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('poster_path')}"
if path := info.get('poster_path'):
self.poster_path = settings.TMDB_IMAGE_URL(path)
# 背景
if info.get('backdrop_path'):
self.backdrop_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
if path := info.get('backdrop_path'):
self.backdrop_path = settings.TMDB_IMAGE_URL(path)
# 导演和演员
self.directors, self.actors = __directors_actors(info)
# 别名和译名

View File

@@ -301,7 +301,8 @@ class MetaVideo(MetaBase):
return
else:
# 后缀名不要
if ".%s".lower() % token in settings.RMT_MEDIAEXT:
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if ".%s".lower() % token in media_exts:
return
# 英文或者英文+数字,拼装起来
if self.en_name:

View File

@@ -25,7 +25,8 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
# 获取标题中媒体信息
title, metainfo = find_metainfo(title)
# 判断是否处理文件
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if title and Path(title).suffix.lower() in media_exts:
isfile = True
# 去掉后缀
title = Path(title).stem
@@ -62,21 +63,24 @@ def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str]
return meta
def MetaInfoPath(path: Path) -> MetaBase:
def MetaInfoPath(path: Path, custom_words: List[str] = None) -> MetaBase:
"""
根据路径识别元数据
:param path: 路径
:param custom_words: 自定义识别词列表
"""
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=path.name)
file_meta = MetaInfo(title=path.name, custom_words=custom_words)
# 上级目录元数据
dir_meta = MetaInfo(title=path.parent.name)
# 合并元数据
file_meta.merge(dir_meta)
dir_meta = MetaInfo(title=path.parent.name, custom_words=custom_words)
if file_meta.type == MediaType.TV or dir_meta.type != MediaType.TV:
# 合并元数据
file_meta.merge(dir_meta)
# 上上级目录元数据
root_meta = MetaInfo(title=path.parent.parent.name)
# 合并元数据
file_meta.merge(root_meta)
root_meta = MetaInfo(title=path.parent.parent.name, custom_words=custom_words)
if file_meta.type == MediaType.TV or root_meta.type != MediaType.TV:
# 合并元数据
file_meta.merge(root_meta)
return file_meta

View File

@@ -17,6 +17,7 @@ from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, AP
from passlib.context import CryptContext
from app import schemas
from app.core.cache import cached
from app.core.config import settings
from app.log import logger
@@ -24,7 +25,8 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
# OAuth2PasswordBearer 用于 JWT Token 认证
oauth2_scheme = OAuth2PasswordBearer(
oauth2_scheme_manual_error = OAuth2PasswordBearer(
auto_error=False, # 禁用自动错误处理用以支持API令牌鉴权
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
@@ -41,6 +43,58 @@ api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="a
api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query")
def __get_api_token(
token_query: Annotated[str | None, Security(api_token_query)] = None
) -> str | None:
"""
从 URL 查询参数中获取 API Token
:param token_query: 从 URL 中的 `token` 查询参数获取 API Token
:return: 返回获取到的 API Token若无则返回 None
"""
return token_query
def __get_api_key(
key_query: Annotated[str | None, Security(api_key_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None
) -> str | None:
"""
从 URL 查询参数或请求头部获取 API Key优先使用请求头
:param key_query: URL 中的 `apikey` 查询参数
:param key_header: 请求头中的 `X-API-KEY` 参数
:return: 返回从 URL 或请求头中获取的 API Key若无则返回 None
"""
return key_header or key_query # 首选请求头
@cached(maxsize=1, ttl=600)
def __create_superuser_token_payload() -> schemas.TokenPayload:
"""
创建管理员用户的TokenPayload
:return: 管理员TokenPayload
"""
# 延迟导入
# pylint: disable=import-outside-toplevel
# pylint: disable=no-name-in-module
from app.db.user_oper import UserOper
from app.helper.sites import SitesHelper # noqa
user = UserOper().get_by_name(settings.SUPERUSER)
if not user or not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户权限不足",
)
return schemas.TokenPayload(
sub=user.id,
username=user.name,
super_user=user.is_superuser,
level=SitesHelper().auth_level,
purpose="authentication",
)
def create_access_token(
userid: Union[str, Any],
username: str,
@@ -176,23 +230,43 @@ def __verify_token(token: str, purpose: Optional[str] = "authentication") -> sch
def verify_token(
request: Request,
response: Response,
token: Annotated[str, Security(oauth2_scheme)]
jwt_token: Annotated[str | None, Security(oauth2_scheme_manual_error)],
api_key: Annotated[str | None, Security(__get_api_key)],
api_token: Annotated[str | None, Security(__get_api_token)],
) -> schemas.TokenPayload:
"""
验证 JWT 令牌并自动处理 resource_token 写入
如果缺少JWT令牌再尝试用API令牌鉴权
:param request: 请求对象,用于访问 Cookie 和请求信息
:param response: 响应对象,用于设置 Cookie
:param token: 从 Authorization 头部获取的 JWT 令牌
:param jwt_token: 从 Authorization 头部获取的 JWT 令牌
:param api_key: 从 查询参数`apikey` 或 请求头`X-API-KEY` 获取 API Token
:param api_token: 从 查询参数`token` 获取 API Token
:return: 解析后的 TokenPayload
:raises HTTPException: 如果令牌无效或用途不匹配
"""
# 验证并解析 JWT 认证令牌
payload = __verify_token(token=token, purpose="authentication")
if jwt_token:
# 验证并解析 JWT 认证令牌
payload = __verify_token(token=jwt_token, purpose="authentication")
# 如果没有 resource_token生成并写入到 Cookie
__set_or_refresh_resource_token_cookie(request, response, payload)
# 如果没有 resource_token生成并写入到 Cookie
__set_or_refresh_resource_token_cookie(request, response, payload)
return payload
return payload
elif api_key:
verify_apikey(api_key)
return __create_superuser_token_payload()
elif api_token:
verify_apitoken(api_token)
return __create_superuser_token_payload()
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
def verify_resource_token(
@@ -208,31 +282,7 @@ def verify_resource_token(
return __verify_token(token=resource_token, purpose="resource")
def __get_api_token(
token_query: Annotated[str | None, Security(api_token_query)] = None
) -> str:
"""
从 URL 查询参数中获取 API Token
:param token_query: 从 URL 中的 `token` 查询参数获取 API Token
:return: 返回获取到的 API Token若无则返回 None
"""
return token_query
def __get_api_key(
key_query: Annotated[str | None, Security(api_key_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None
) -> str:
"""
从 URL 查询参数或请求头部获取 API Key优先使用 URL 参数
:param key_query: URL 中的 `apikey` 查询参数
:param key_header: 请求头中的 `X-API-KEY` 参数
:return: 返回从 URL 或请求头中获取的 API Key若无则返回 None
"""
return key_query or key_header
def __verify_key(key: str, expected_key: str, key_type: str) -> str:
def __verify_key(key: str | None, expected_key: str, key_type: str) -> str:
"""
通用的 API Key 或 Token 验证函数
:param key: 从请求中获取的 API Key 或 Token
@@ -241,7 +291,7 @@ def __verify_key(key: str, expected_key: str, key_type: str) -> str:
:return: 返回校验通过的 API Key 或 Token
:raises HTTPException: 如果校验不通过,抛出 401 错误
"""
if key != expected_key:
if not key or key != expected_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"{key_type} 校验不通过"
@@ -249,7 +299,7 @@ def __verify_key(key: str, expected_key: str, key_type: str) -> str:
return key
def verify_apitoken(token: Annotated[str, Security(__get_api_token)]) -> str:
def verify_apitoken(token: Annotated[str | None, Security(__get_api_token)]) -> str:
"""
使用 API Token 进行身份认证
:param token: API Token从 URL 查询参数中获取 token=xxx
@@ -258,7 +308,7 @@ def verify_apitoken(token: Annotated[str, Security(__get_api_token)]) -> str:
return __verify_key(token, settings.API_TOKEN, "token")
def verify_apikey(apikey: Annotated[str, Security(__get_api_key)]) -> str:
def verify_apikey(apikey: Annotated[str | None, Security(__get_api_key)]) -> str:
"""
使用 API Key 进行身份认证
:param apikey: API Key从 URL 查询参数中获取 apikey=xxx或请求头中获取 X-API-KEY=xxx

View File

@@ -454,7 +454,6 @@ class Base:
@db_update
def update(self, db: Session, payload: dict):
payload = {k: v for k, v in payload.items() if v is not None}
for key, value in payload.items():
setattr(self, key, value)
if inspect(self).detached:
@@ -462,7 +461,6 @@ class Base:
@async_db_update
async def async_update(self, db: AsyncSession, payload: dict):
payload = {k: v for k, v in payload.items() if v is not None}
for key, value in payload.items():
setattr(self, key, value)
if inspect(self).detached:

View File

@@ -1,5 +1,6 @@
from .downloadhistory import DownloadHistory, DownloadFiles
from .mediaserver import MediaServerItem
from .passkey import PassKey
from .plugindata import PluginData
from .site import Site
from .siteicon import SiteIcon

View File

@@ -55,6 +55,8 @@ class DownloadHistory(Base):
media_category = Column(String)
# 剧集组
episode_group = Column(String)
# 自定义识别词(用于整理时应用)
custom_words = Column(String)
@classmethod
@db_query

126
app/db/models/passkey.py Normal file
View File

@@ -0,0 +1,126 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, select, ForeignKey
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from datetime import datetime
from app.db import Base, db_query, db_update, async_db_query, async_db_update, get_id_column
class PassKey(Base):
"""
用户PassKey凭证表
"""
# ID
id = get_id_column()
# 用户ID
user_id = Column(Integer, ForeignKey('user.id'), nullable=False, index=True)
# 凭证ID (credential_id)
credential_id = Column(String, nullable=False, unique=True, index=True)
# 凭证公钥
public_key = Column(Text, nullable=False)
# 签名计数器
sign_count = Column(Integer, default=0)
# 凭证名称(用户自定义)
name = Column(String, default="通行密钥")
# AAGUID (Authenticator Attestation GUID)
aaguid = Column(String, nullable=True)
# 创建时间
created_at = Column(DateTime, default=datetime.now)
# 最后使用时间
last_used_at = Column(DateTime, nullable=True)
# 是否启用
is_active = Column(Boolean, default=True)
# 传输方式 (usb, nfc, ble, internal)
transports = Column(String, nullable=True)
@classmethod
@db_query
def get_by_user_id(cls, db: Session, user_id: int):
"""获取用户的所有PassKey"""
return db.query(cls).filter(cls.user_id == user_id, cls.is_active.is_(True)).all()
@classmethod
@async_db_query
async def async_get_by_user_id(cls, db: AsyncSession, user_id: int):
"""异步获取用户的所有PassKey"""
result = await db.execute(
select(cls).filter(cls.user_id == user_id, cls.is_active.is_(True))
)
return result.scalars().all()
@classmethod
@db_query
def get_by_credential_id(cls, db: Session, credential_id: str):
"""根据凭证ID获取PassKey"""
return db.query(cls).filter(cls.credential_id == credential_id, cls.is_active.is_(True)).first()
@classmethod
@async_db_query
async def async_get_by_credential_id(cls, db: AsyncSession, credential_id: str):
"""异步根据凭证ID获取PassKey"""
result = await db.execute(
select(cls).filter(cls.credential_id == credential_id, cls.is_active.is_(True))
)
return result.scalars().first()
@classmethod
@db_query
def get_by_id(cls, db: Session, passkey_id: int):
"""根据ID获取PassKey"""
return db.query(cls).filter(cls.id == passkey_id).first()
@classmethod
@async_db_query
async def async_get_by_id(cls, db: AsyncSession, passkey_id: int):
"""异步根据ID获取PassKey"""
result = await db.execute(
select(cls).filter(cls.id == passkey_id)
)
return result.scalars().first()
@classmethod
@db_update
def delete_by_id(cls, db: Session, passkey_id: int, user_id: int):
"""删除指定用户的PassKey"""
passkey = db.query(cls).filter(
cls.id == passkey_id,
cls.user_id == user_id
).first()
if passkey:
passkey.delete(db, passkey.id)
return True
return False
@classmethod
@async_db_update
async def async_delete_by_id(cls, db: AsyncSession, passkey_id: int, user_id: int):
"""异步删除指定用户的PassKey"""
result = await db.execute(
select(cls).filter(
cls.id == passkey_id,
cls.user_id == user_id
)
)
passkey = result.scalars().first()
if passkey:
await passkey.async_delete(db, passkey.id)
return True
return False
@db_update
def update_last_used(self, db: Session, sign_count: int):
"""更新最后使用时间和签名计数"""
self.update(db, {
'last_used_at': datetime.now(),
'sign_count': sign_count
})
return True
@async_db_update
async def async_update_last_used(self, db: AsyncSession, sign_count: int):
"""异步更新最后使用时间和签名计数"""
await self.async_update(db, {
'last_used_at': datetime.now(),
'sign_count': sign_count
})
return True

View File

@@ -1,4 +1,6 @@
import asyncio
import copy
import threading
from typing import Any, Optional, Union
from app.db import DbOper
@@ -17,6 +19,8 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
"""
super().__init__()
self.__SYSTEMCONF = {}
self._rlock = threading.RLock()
self._alock = asyncio.Lock()
for item in SystemConfig.list(self._db):
self.__SYSTEMCONF[item.key] = item.value
@@ -29,23 +33,24 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
"""
if isinstance(key, SystemConfigKey):
key = key.value
# 旧值
old_value = self.__SYSTEMCONF.get(key)
# 更新内存(deepcopy避免内存共享)
self.__SYSTEMCONF[key] = copy.deepcopy(value)
conf = SystemConfig.get_by_key(self._db, key)
if conf:
if old_value != value:
if value:
conf.update(self._db, {"value": value})
else:
conf.delete(self._db, conf.id)
with self._rlock:
# 旧值
old_value = self.__SYSTEMCONF.get(key)
# 更新内存(deepcopy避免内存共享)
self.__SYSTEMCONF[key] = copy.deepcopy(value)
conf = SystemConfig.get_by_key(self._db, key)
if conf:
if old_value != value:
if value:
conf.update(self._db, {"value": value})
else:
conf.delete(self._db, conf.id)
return True
return None
else:
conf = SystemConfig(key=key, value=value)
conf.create(self._db)
return True
return None
else:
conf = SystemConfig(key=key, value=value)
conf.create(self._db)
return True
async def async_set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:
"""
@@ -56,22 +61,32 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
"""
if isinstance(key, SystemConfigKey):
key = key.value
# 旧值
old_value = self.__SYSTEMCONF.get(key)
# 更新内存(deepcopy避免内存共享)
self.__SYSTEMCONF[key] = copy.deepcopy(value)
conf = await SystemConfig.async_get_by_key(self._db, key)
if conf:
if old_value != value:
async with self._alock:
conf = await SystemConfig.async_get_by_key(self._db, key)
# 确定是否需要更新数据库
needs_db_update = False
if conf:
if conf.value != value:
needs_db_update = True
else: # 记录不存在,总是需要创建/更新
needs_db_update = True
if not needs_db_update:
# 即使数据库值相同,也要确保缓存同步
with self._rlock:
self.__SYSTEMCONF[key] = copy.deepcopy(value)
return None
# 执行数据库更新
if conf:
if value:
conf.update(self._db, {"value": value})
await conf.async_update(self._db, {"value": value})
else:
conf.delete(self._db, conf.id)
return True
return None
else:
conf = SystemConfig(key=key, value=value)
await conf.async_create(self._db)
await conf.async_delete(self._db, conf.id)
else:
conf = SystemConfig(key=key, value=value)
await conf.async_create(self._db)
# 数据库更新成功后,再更新缓存
with self._rlock:
self.__SYSTEMCONF[key] = copy.deepcopy(value)
return True
def get(self, key: Union[str, SystemConfigKey] = None) -> Any:
@@ -82,15 +97,17 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
key = key.value
if not key:
return self.all()
# 避免将__SYSTEMCONF内的值引用出去会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF.get(key))
with self._rlock:
# 避免将__SYSTEMCONF内的值引用出去会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF.get(key))
def all(self):
"""
获取所有系统设置
"""
# 避免将__SYSTEMCONF内的值引用出去会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF)
with self._rlock:
# 避免将__SYSTEMCONF内的值引用出去会导致set时误判没有变动
return copy.deepcopy(self.__SYSTEMCONF)
def delete(self, key: Union[str, SystemConfigKey]) -> bool:
"""
@@ -98,10 +115,11 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
"""
if isinstance(key, SystemConfigKey):
key = key.value
# 更新内存
self.__SYSTEMCONF.pop(key, None)
# 写入数据库
conf = SystemConfig.get_by_key(self._db, key)
if conf:
conf.delete(self._db, conf.id)
return True
with self._rlock:
# 更新内存
self.__SYSTEMCONF.pop(key, None)
# 写入数据库
conf = SystemConfig.get_by_key(self._db, key)
if conf:
conf.delete(self._db, conf.id)
return True

View File

@@ -10,7 +10,7 @@ from app.utils.http import RequestUtils, cookie_parse
class PlaywrightHelper:
def __init__(self, browser_type="chromium"):
def __init__(self, browser_type=settings.PLAYWRIGHT_BROWSER_TYPE):
self.browser_type = browser_type
@staticmethod

View File

@@ -142,19 +142,22 @@ class DirectoryHelper:
# 计算重命名中的文件夹层数
rename_list = rename_format.split("/")
rename_format_level = len(rename_list) - 1
# 查找标题参数所在层
for level, name in enumerate(rename_list):
# 反向查找标题参数所在层
for level, name in enumerate(reversed(rename_list)):
if level == 0:
# 跳过文件名的标题参数
continue
matchs = JINJA2_VAR_PATTERN.findall(name)
if not matchs:
continue
# 处理特例,有的人重命名的第一层是年份、分辨率
if any("title" in m for m in matchs):
# 找出含标题的这一层作为媒体根目录
rename_format_level -= level
# 找出最后一层含有标题参数的目录作为媒体根目录
rename_format_level = level
break
else:
# 假定第一层目录是媒体根目录
logger.warn(f"重命名格式 {rename_format} 缺少标题参数")
logger.warn(f"重命名格式 {rename_format} 缺少标题目录")
if rename_format_level > len(rename_path.parents):
# 通常因为路径以/结尾被Path规范化删除了
logger.error(f"路径 {rename_path} 不匹配重命名格式 {rename_format}")

View File

@@ -1,12 +1,76 @@
"""LLM模型相关辅助功能"""
from typing import List
from typing import List, Optional
from app.core.config import settings
from app.log import logger
class LLMHelper:
"""LLM模型相关辅助功能"""
@staticmethod
def get_llm(streaming: bool = False, callbacks: Optional[list] = None):
"""
获取LLM实例
:param streaming: 是否启用流式输出
:param callbacks: 回调处理器列表
:return: LLM实例
"""
provider = settings.LLM_PROVIDER.lower()
api_key = settings.LLM_API_KEY
if not api_key:
raise ValueError("未配置LLM API Key")
if provider == "google":
if settings.PROXY_HOST:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=api_key,
max_retries=3,
base_url="https://generativelanguage.googleapis.com/v1beta/openai",
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
callbacks=callbacks,
stream_usage=True,
openai_proxy=settings.PROXY_HOST
)
else:
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=settings.LLM_MODEL,
google_api_key=api_key,
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
callbacks=callbacks
)
elif provider == "deepseek":
from langchain_deepseek import ChatDeepSeek
return ChatDeepSeek(
model=settings.LLM_MODEL,
api_key=api_key,
max_retries=3,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
callbacks=callbacks,
stream_usage=True
)
else:
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=settings.LLM_MODEL,
api_key=api_key,
max_retries=3,
base_url=settings.LLM_BASE_URL,
temperature=settings.LLM_TEMPERATURE,
streaming=streaming,
callbacks=callbacks,
stream_usage=True,
openai_proxy=settings.PROXY_HOST
)
def get_models(self, provider: str, api_key: str, base_url: str = None) -> List[str]:
"""获取模型列表"""
logger.info(f"获取 {provider} 模型列表...")

361
app/helper/passkey.py Normal file
View File

@@ -0,0 +1,361 @@
"""
PassKey WebAuthn 辅助工具类
"""
import base64
import json
import binascii
from typing import Optional, Tuple, List, Dict, Any
from urllib.parse import urlparse
from webauthn import (
generate_registration_options,
verify_registration_response,
generate_authentication_options,
verify_authentication_response,
options_to_json
)
from webauthn.helpers import (
parse_registration_credential_json,
parse_authentication_credential_json
)
from webauthn.helpers.structs import (
PublicKeyCredentialDescriptor,
AuthenticatorTransport,
UserVerificationRequirement,
AuthenticatorAttachment,
ResidentKeyRequirement,
AuthenticatorSelectionCriteria
)
from webauthn.helpers.cose import COSEAlgorithmIdentifier
from app.core.config import settings
from app.log import logger
class PassKeyHelper:
"""
PassKey WebAuthn 辅助类
"""
@staticmethod
def get_rp_id() -> str:
"""
获取 Relying Party ID
"""
if settings.APP_DOMAIN:
app_domain = settings.APP_DOMAIN.strip()
# 确保存在协议前缀,以便 urlparse 正确解析主机和端口
if not app_domain.startswith(('http://', 'https://')):
app_domain = f'https://{app_domain}'
parsed = urlparse(app_domain)
host = parsed.hostname
if host:
return host
# 从 APP_DOMAIN 中提取域名
host = settings.APP_DOMAIN.replace('https://', '').replace('http://', '')
# 移除端口号
if ':' in host:
host = host.split(':')[0]
return host
# 只有在未配置 APP_DOMAIN 时,才默认为 localhost
return 'localhost'
@staticmethod
def get_rp_name() -> str:
"""
获取 Relying Party 名称
"""
return "MoviePilot"
@staticmethod
def get_origin() -> str:
"""
获取源地址
"""
if settings.APP_DOMAIN:
return settings.APP_DOMAIN.rstrip('/')
# 如果未配置APP_DOMAIN使用默认的localhost地址
return f'http://localhost:{settings.NGINX_PORT}'
@staticmethod
def standardize_credential_id(credential_id: str) -> str:
"""
标准化凭证IDBase64 URL Safe
"""
try:
# Base64解码并重新编码以标准化格式
decoded = base64.urlsafe_b64decode(credential_id + '==')
return base64.urlsafe_b64encode(decoded).decode('utf-8').rstrip('=')
except (binascii.Error, TypeError, ValueError) as e:
logger.error(f"标准化凭证ID失败: {e}")
return credential_id
@staticmethod
def _base64_encode_urlsafe(data: bytes) -> str:
"""
Base64 URL Safe 编码(不带填充)
:param data: 要编码的字节数据
:return: Base64 URL Safe 编码的字符串
"""
return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')
@staticmethod
def _base64_decode_urlsafe(data: str) -> bytes:
"""
Base64 URL Safe 解码(自动添加填充)
:param data: Base64 URL Safe 编码的字符串
:return: 解码后的字节数据
"""
return base64.urlsafe_b64decode(data + '==')
@staticmethod
def _parse_credential_list(credentials: List[Dict[str, Any]]) -> List[PublicKeyCredentialDescriptor]:
"""
解析凭证列表为 PublicKeyCredentialDescriptor 列表
:param credentials: 凭证字典列表
:return: PublicKeyCredentialDescriptor 列表
"""
result = []
for cred in credentials:
try:
result.append(
PublicKeyCredentialDescriptor(
id=PassKeyHelper._base64_decode_urlsafe(cred['credential_id']),
transports=[
AuthenticatorTransport(t) for t in cred.get('transports', '').split(',') if t
] if cred.get('transports') else None
)
)
except Exception as e:
logger.warning(f"解析凭证失败: {e}")
continue
return result
@staticmethod
def _get_user_verification_requirement(user_verification: Optional[str] = None) -> UserVerificationRequirement:
"""
获取用户验证要求
:param user_verification: 指定的用户验证要求,如果不指定则从配置中读取
:return: UserVerificationRequirement
"""
if user_verification:
return UserVerificationRequirement(user_verification)
return UserVerificationRequirement.REQUIRED if settings.PASSKEY_REQUIRE_UV \
else UserVerificationRequirement.PREFERRED
@staticmethod
def _get_verification_params(
expected_origin: Optional[str] = None,
expected_rp_id: Optional[str] = None
) -> Tuple[str, str]:
"""
获取验证参数origin 和 rp_id
:param expected_origin: 期望的源地址
:param expected_rp_id: 期望的RP ID
:return: (origin, rp_id)
"""
origin = expected_origin or PassKeyHelper.get_origin()
rp_id = expected_rp_id or PassKeyHelper.get_rp_id()
return origin, rp_id
@staticmethod
def generate_registration_options(
user_id: int,
username: str,
display_name: Optional[str] = None,
existing_credentials: Optional[List[Dict[str, Any]]] = None
) -> Tuple[str, str]:
"""
生成注册选项
:param user_id: 用户ID
:param username: 用户名
:param display_name: 显示名称
:param existing_credentials: 已存在的凭证列表
:return: (options_json, challenge)
"""
try:
# 用户信息
user_id_bytes = str(user_id).encode('utf-8')
# 排除已有的凭证
exclude_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \
if existing_credentials else None
# 用户验证要求
uv_requirement = PassKeyHelper._get_user_verification_requirement()
# 生成注册选项
options = generate_registration_options(
rp_id=PassKeyHelper.get_rp_id(),
rp_name=PassKeyHelper.get_rp_name(),
user_id=user_id_bytes,
user_name=username,
user_display_name=display_name or username,
exclude_credentials=exclude_credentials,
authenticator_selection=AuthenticatorSelectionCriteria(
authenticator_attachment=None,
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=uv_requirement,
),
supported_pub_key_algs=[
COSEAlgorithmIdentifier.ECDSA_SHA_256,
COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,
]
)
# 转换为JSON
options_json = options_to_json(options)
# 提取challenge用于后续验证
challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge)
return options_json, challenge
except Exception as e:
logger.error(f"生成注册选项失败: {e}")
raise
@staticmethod
def verify_registration_response(
credential: Dict[str, Any],
expected_challenge: str,
expected_origin: Optional[str] = None,
expected_rp_id: Optional[str] = None
) -> Tuple[str, str, int, Optional[str]]:
"""
验证注册响应
:param credential: 客户端返回的凭证
:param expected_challenge: 期望的challenge
:param expected_origin: 期望的源地址
:param expected_rp_id: 期望的RP ID
:return: (credential_id, public_key, sign_count, aaguid)
"""
try:
# 准备验证参数
origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id)
# 解码challenge
challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge)
# 构建RegistrationCredential对象
registration_credential = parse_registration_credential_json(json.dumps(credential))
# 验证注册响应
verification = verify_registration_response(
credential=registration_credential,
expected_challenge=challenge_bytes,
expected_rp_id=rp_id,
expected_origin=origin,
require_user_verification=settings.PASSKEY_REQUIRE_UV
)
# 提取信息
credential_id = PassKeyHelper._base64_encode_urlsafe(verification.credential_id)
public_key = PassKeyHelper._base64_encode_urlsafe(verification.credential_public_key)
sign_count = verification.sign_count
# aaguid 可能已经是字符串格式也可能是bytes
if verification.aaguid:
if isinstance(verification.aaguid, bytes):
aaguid = verification.aaguid.hex()
else:
aaguid = str(verification.aaguid)
else:
aaguid = None
return credential_id, public_key, sign_count, aaguid
except Exception as e:
logger.error(f"验证注册响应失败: {e}")
raise
@staticmethod
def generate_authentication_options(
existing_credentials: Optional[List[Dict[str, Any]]] = None,
user_verification: Optional[str] = None
) -> Tuple[str, str]:
"""
生成认证选项
:param existing_credentials: 已存在的凭证列表(用于限制可用凭证)
:param user_verification: 用户验证要求,如果不指定则从配置中读取
:return: (options_json, challenge)
"""
try:
# 允许的凭证
allow_credentials = PassKeyHelper._parse_credential_list(existing_credentials) \
if existing_credentials else None
# 用户验证要求
uv_requirement = PassKeyHelper._get_user_verification_requirement(user_verification)
# 生成认证选项
options = generate_authentication_options(
rp_id=PassKeyHelper.get_rp_id(),
allow_credentials=allow_credentials,
user_verification=uv_requirement
)
# 转换为JSON
options_json = options_to_json(options)
# 提取challenge
challenge = PassKeyHelper._base64_encode_urlsafe(options.challenge)
return options_json, challenge
except Exception as e:
logger.error(f"生成认证选项失败: {e}")
raise
@staticmethod
def verify_authentication_response(
credential: Dict[str, Any],
expected_challenge: str,
credential_public_key: str,
credential_current_sign_count: int,
expected_origin: Optional[str] = None,
expected_rp_id: Optional[str] = None
) -> Tuple[bool, int]:
"""
验证认证响应
:param credential: 客户端返回的凭证
:param expected_challenge: 期望的challenge
:param credential_public_key: 凭证公钥
:param credential_current_sign_count: 当前签名计数
:param expected_origin: 期望的源地址
:param expected_rp_id: 期望的RP ID
:return: (验证成功, 新的签名计数)
"""
try:
# 准备验证参数
origin, rp_id = PassKeyHelper._get_verification_params(expected_origin, expected_rp_id)
# 解码
challenge_bytes = PassKeyHelper._base64_decode_urlsafe(expected_challenge)
public_key_bytes = PassKeyHelper._base64_decode_urlsafe(credential_public_key)
# 构建AuthenticationCredential对象
authentication_credential = parse_authentication_credential_json(json.dumps(credential))
# 验证认证响应
verification = verify_authentication_response(
credential=authentication_credential,
expected_challenge=challenge_bytes,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=public_key_bytes,
credential_current_sign_count=credential_current_sign_count,
require_user_verification=settings.PASSKEY_REQUIRE_UV
)
return True, verification.new_sign_count
except Exception as e:
logger.error(f"验证认证响应失败: {e}")
return False, credential_current_sign_count

View File

@@ -0,0 +1,216 @@
import json
from typing import Optional, Union, List, Tuple, Any
from app.core.context import MediaInfo, Context
from app.log import logger
from app.modules import _ModuleBase, _MessageBase
from app.schemas import MessageChannel, CommingMessage, Notification
from app.schemas.types import ModuleType
try:
from app.modules.discord.discord import Discord
except Exception as err: # ImportError or other load issues
Discord = None
logger.error(f"Discord 模块未加载,缺少依赖或初始化错误:{err}")
class DiscordModule(_ModuleBase, _MessageBase[Discord]):
def init_module(self) -> None:
"""
初始化模块
"""
if not Discord:
logger.error("Discord 依赖未就绪(需要安装 discord.py==2.6.4),模块未启动")
return
self.stop()
super().init_service(service_name=Discord.__name__.lower(),
service_type=Discord)
self._channel = MessageChannel.Discord
@staticmethod
def get_name() -> str:
return "Discord"
@staticmethod
def get_type() -> ModuleType:
"""
获取模块类型
"""
return ModuleType.Notification
@staticmethod
def get_subtype() -> MessageChannel:
"""
获取模块子类型
"""
return MessageChannel.Discord
@staticmethod
def get_priority() -> int:
"""
获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效
"""
return 4
def stop(self):
"""
停止模块
"""
for client in self.get_instances().values():
client.stop()
def test(self) -> Optional[Tuple[bool, str]]:
"""
测试模块连接性
"""
if not self.get_instances():
return None
for name, client in self.get_instances().items():
state = client.get_state()
if not state:
return False, f"Discord {name} Bot 未就绪"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def message_parser(self, source: str, body: Any, form: Any, args: Any) -> Optional[CommingMessage]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
username: 用户名
text: 内容
:param source: 消息来源
:param body: 请求体
:param form: 表单
:param args: 参数
:return: 渠道、消息体
"""
client_config = self.get_config(source)
if not client_config:
return None
try:
msg_json: dict = json.loads(body)
except Exception as e:
logger.debug(f"解析 Discord 消息失败:{str(e)}")
return None
if not msg_json:
return None
msg_type = msg_json.get("type")
userid = msg_json.get("userid")
username = msg_json.get("username")
if msg_type == "interaction":
callback_data = msg_json.get("callback_data")
message_id = msg_json.get("message_id")
chat_id = msg_json.get("chat_id")
if callback_data and userid:
logger.info(f"收到来自 {client_config.name} 的 Discord 按钮回调:"
f"userid={userid}, username={username}, callback_data={callback_data}")
return CommingMessage(
channel=MessageChannel.Discord,
source=client_config.name,
userid=userid,
username=username,
text=f"CALLBACK:{callback_data}",
is_callback=True,
callback_data=callback_data,
message_id=message_id,
chat_id=str(chat_id) if chat_id else None
)
return None
if msg_type == "message":
text = msg_json.get("text")
chat_id = msg_json.get("chat_id")
if text and userid:
logger.info(f"收到来自 {client_config.name} 的 Discord 消息:"
f"userid={userid}, username={username}, text={text}")
return CommingMessage(channel=MessageChannel.Discord, source=client_config.name,
userid=userid, username=username, text=text,
chat_id=str(chat_id) if chat_id else None)
return None
def post_message(self, message: Notification, **kwargs) -> None:
"""
发送通知消息
:param message: 消息通知对象
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
targets = message.targets
userid = message.userid
if not userid and targets is not None:
userid = targets.get('discord_userid')
if not userid:
logger.warn("用户没有指定 Discord 用户ID消息无法发送")
return
client: Discord = self.get_instance(conf.name)
if client:
client.send_msg(title=message.title, text=message.text,
image=message.image, userid=userid, link=message.link,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id,
mtype=message.mtype)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
"""
发送媒体信息选择列表
:param message: 消息体
:param medias: 媒体信息
:return: 成功或失败
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
client: Discord = self.get_instance(conf.name)
if client:
client.send_medias_msg(title=message.title, medias=medias, userid=message.userid,
buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
"""
发送种子信息选择列表
:param message: 消息体
:param torrents: 种子信息
:return: 成功或失败
"""
for conf in self.get_configs().values():
if not self.check_message(message, conf.name):
continue
client: Discord = self.get_instance(conf.name)
if client:
client.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, buttons=message.buttons,
original_message_id=message.original_message_id,
original_chat_id=message.original_chat_id)
def delete_message(self, channel: MessageChannel, source: str,
message_id: str, chat_id: Optional[str] = None) -> bool:
"""
删除消息
:param channel: 消息渠道
:param source: 指定的消息源
:param message_id: 消息IDSlack中为时间戳
:param chat_id: 聊天ID频道ID
:return: 删除是否成功
"""
success = False
for conf in self.get_configs().values():
if channel != self._channel:
break
if source != conf.name:
continue
client: Discord = self.get_instance(conf.name)
if client:
result = client.delete_msg(message_id=message_id, chat_id=chat_id)
if result:
success = True
return success

View File

@@ -0,0 +1,607 @@
import asyncio
import re
import threading
from typing import Optional, List, Dict, Any, Tuple, Union
import discord
from discord import app_commands
import httpx
from app.core.config import settings
from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.log import logger
from app.schemas.types import NotificationType
from app.utils.string import StringUtils
# Discord embed 字段解析白名单
# 只有这些消息类型会使用复杂的字段解析逻辑
PARSE_FIELD_TYPES = {
NotificationType.Download, # 资源下载
NotificationType.Organize, # 整理入库
NotificationType.Subscribe, # 订阅
NotificationType.Manual, # 手动处理
}
class Discord:
"""
Discord Bot 通知与交互实现(基于 discord.py 2.6.4
"""
def __init__(self, DISCORD_BOT_TOKEN: Optional[str] = None,
DISCORD_GUILD_ID: Optional[Union[str, int]] = None,
DISCORD_CHANNEL_ID: Optional[Union[str, int]] = None,
**kwargs):
if not DISCORD_BOT_TOKEN:
logger.error("Discord Bot Token 未配置!")
return
self._token = DISCORD_BOT_TOKEN
self._guild_id = self._to_int(DISCORD_GUILD_ID)
self._channel_id = self._to_int(DISCORD_CHANNEL_ID)
base_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message/"
self._ds_url = f"{base_ds_url}?token={settings.API_TOKEN}"
if kwargs.get("name"):
self._ds_url = f"{self._ds_url}&source={kwargs.get('name')}"
intents = discord.Intents.default()
intents.message_content = True
intents.messages = True
intents.guilds = True
self._client: Optional[discord.Client] = discord.Client(
intents=intents,
proxy=settings.PROXY_HOST
)
self._tree: Optional[app_commands.CommandTree] = None
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
self._thread: Optional[threading.Thread] = None
self._ready_event = threading.Event()
self._user_dm_cache: Dict[str, discord.DMChannel] = {}
self._broadcast_channel = None
self._bot_user_id: Optional[int] = None
self._register_events()
self._start()
@staticmethod
def _to_int(val: Optional[Union[str, int]]) -> Optional[int]:
try:
return int(val) if val is not None and str(val).strip() else None
except ValueError:
return None
def _register_events(self):
@self._client.event
async def on_ready():
self._bot_user_id = self._client.user.id if self._client.user else None
self._ready_event.set()
logger.info(f"Discord Bot 已登录:{self._client.user}")
@self._client.event
async def on_message(message: discord.Message):
if message.author.bot:
return
if not self._should_process_message(message):
return
cleaned_text = self._clean_bot_mention(message.content or "")
username = message.author.display_name or message.author.global_name or message.author.name
payload = {
"type": "message",
"userid": str(message.author.id),
"username": username,
"user_tag": str(message.author),
"text": cleaned_text,
"message_id": str(message.id),
"chat_id": str(message.channel.id),
"channel_type": "dm" if isinstance(message.channel, discord.DMChannel) else "guild"
}
await self._post_to_ds(payload)
@self._client.event
async def on_interaction(interaction: discord.Interaction):
if interaction.type == discord.InteractionType.component:
data = interaction.data or {}
callback_data = data.get("custom_id")
if not callback_data:
return
try:
await interaction.response.defer(ephemeral=True)
except Exception as e:
logger.error(f"处理 Discord 交互响应失败:{e}")
username = (interaction.user.display_name or interaction.user.global_name or interaction.user.name) \
if interaction.user else None
payload = {
"type": "interaction",
"userid": str(interaction.user.id) if interaction.user else None,
"username": username,
"user_tag": str(interaction.user) if interaction.user else None,
"callback_data": callback_data,
"message_id": str(interaction.message.id) if interaction.message else None,
"chat_id": str(interaction.channel.id) if interaction.channel else None
}
await self._post_to_ds(payload)
def _start(self):
if self._thread:
return
def runner():
asyncio.set_event_loop(self._loop)
try:
self._loop.create_task(self._client.start(self._token))
self._loop.run_forever()
except Exception as err:
logger.error(f"Discord Bot 启动失败:{err}")
finally:
try:
self._loop.run_until_complete(self._client.close())
except Exception as err:
logger.debug(f"Discord Bot 关闭失败:{err}")
self._thread = threading.Thread(target=runner, daemon=True)
self._thread.start()
def stop(self):
if not self._client or not self._loop or not self._thread:
return
try:
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop).result(timeout=10)
except Exception as err:
logger.error(f"关闭 Discord Bot 失败:{err}")
finally:
try:
self._loop.call_soon_threadsafe(self._loop.stop)
except Exception as err:
logger.error(f"停止 Discord 事件循环失败:{err}")
self._ready_event.clear()
def get_state(self) -> bool:
return self._ready_event.is_set() and self._client is not None
def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None,
userid: Optional[str] = None, link: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None,
mtype: Optional['NotificationType'] = None) -> Optional[bool]:
if not self.get_state():
return False
if not title and not text:
logger.warn("标题和内容不能同时为空")
return False
try:
future = asyncio.run_coroutine_threadsafe(
self._send_message(title=title, text=text, image=image, userid=userid,
link=link, buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id,
mtype=mtype),
self._loop)
return future.result(timeout=30)
except Exception as err:
logger.error(f"发送 Discord 消息失败:{err}")
return False
def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
if not self.get_state() or not medias:
return False
title = title or "媒体列表"
try:
future = asyncio.run_coroutine_threadsafe(
self._send_list_message(
embeds=self._build_media_embeds(medias, title),
userid=userid,
buttons=self._build_default_buttons(len(medias)) if not buttons else buttons,
fallback_buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
),
self._loop
)
return future.result(timeout=30)
except Exception as err:
logger.error(f"发送 Discord 媒体列表失败:{err}")
return False
def send_torrents_msg(self, torrents: List[Context], userid: Optional[str] = None, title: Optional[str] = None,
buttons: Optional[List[List[dict]]] = None,
original_message_id: Optional[Union[int, str]] = None,
original_chat_id: Optional[str] = None) -> Optional[bool]:
if not self.get_state() or not torrents:
return False
title = title or "种子列表"
try:
future = asyncio.run_coroutine_threadsafe(
self._send_list_message(
embeds=self._build_torrent_embeds(torrents, title),
userid=userid,
buttons=self._build_default_buttons(len(torrents)) if not buttons else buttons,
fallback_buttons=buttons,
original_message_id=original_message_id,
original_chat_id=original_chat_id
),
self._loop
)
return future.result(timeout=30)
except Exception as err:
logger.error(f"发送 Discord 种子列表失败:{err}")
return False
def delete_msg(self, message_id: Union[str, int], chat_id: Optional[str] = None) -> Optional[bool]:
if not self.get_state():
return False
try:
future = asyncio.run_coroutine_threadsafe(
self._delete_message(message_id=message_id, chat_id=chat_id),
self._loop
)
return future.result(timeout=15)
except Exception as err:
logger.error(f"删除 Discord 消息失败:{err}")
return False
async def _send_message(self, title: str, text: Optional[str], image: Optional[str],
userid: Optional[str], link: Optional[str],
buttons: Optional[List[List[dict]]],
original_message_id: Optional[Union[int, str]],
original_chat_id: Optional[str],
mtype: Optional['NotificationType'] = None) -> bool:
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
if not channel:
logger.error("未找到可用的 Discord 频道或私聊")
return False
embed = self._build_embed(title=title, text=text, image=image, link=link, mtype=mtype)
view = self._build_view(buttons=buttons, link=link)
content = None
if original_message_id and original_chat_id:
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
content=content, embed=embed, view=view)
await channel.send(content=content, embed=embed, view=view)
return True
async def _send_list_message(self, embeds: List[discord.Embed],
userid: Optional[str],
buttons: Optional[List[List[dict]]],
fallback_buttons: Optional[List[List[dict]]],
original_message_id: Optional[Union[int, str]],
original_chat_id: Optional[str]) -> bool:
channel = await self._resolve_channel(userid=userid, chat_id=original_chat_id)
if not channel:
logger.error("未找到可用的 Discord 频道或私聊")
return False
view = self._build_view(buttons=buttons if buttons else fallback_buttons)
embeds = embeds[:10] if embeds else [] # Discord 单条消息最多 10 个 embed
if original_message_id and original_chat_id:
return await self._edit_message(chat_id=original_chat_id, message_id=original_message_id,
content=None, embed=None, view=view, embeds=embeds)
await channel.send(embed=embeds[0] if len(embeds) == 1 else None,
embeds=embeds if len(embeds) > 1 else None,
view=view)
return True
async def _edit_message(self, chat_id: Union[str, int], message_id: Union[str, int],
content: Optional[str], embed: Optional[discord.Embed],
view: Optional[discord.ui.View], embeds: Optional[List[discord.Embed]] = None) -> bool:
channel = await self._resolve_channel(chat_id=str(chat_id))
if not channel:
logger.error(f"未找到要编辑的 Discord 频道:{chat_id}")
return False
try:
message = await channel.fetch_message(int(message_id))
kwargs: Dict[str, Any] = {"content": content, "view": view}
if embeds:
if len(embeds) == 1:
kwargs["embed"] = embeds[0]
else:
kwargs["embeds"] = embeds
elif embed:
kwargs["embed"] = embed
await message.edit(**kwargs)
return True
except Exception as err:
logger.error(f"编辑 Discord 消息失败:{err}")
return False
async def _delete_message(self, message_id: Union[str, int], chat_id: Optional[str]) -> bool:
channel = await self._resolve_channel(chat_id=chat_id)
if not channel:
logger.error("删除 Discord 消息时未找到频道")
return False
try:
message = await channel.fetch_message(int(message_id))
await message.delete()
return True
except Exception as err:
logger.error(f"删除 Discord 消息失败:{err}")
return False
@staticmethod
def _build_embed(title: str, text: Optional[str], image: Optional[str],
link: Optional[str], mtype: Optional['NotificationType'] = None) -> discord.Embed:
fields: List[Dict[str, str]] = []
desc_lines: List[str] = []
should_parse_fields = mtype in PARSE_FIELD_TYPES if mtype else False
def _collect_spans(s: str, left: str, right: str) -> List[Tuple[int, int]]:
spans: List[Tuple[int, int]] = []
start = 0
while True:
l_idx = s.find(left, start)
if l_idx == -1:
break
r_idx = s.find(right, l_idx + 1)
if r_idx == -1:
break
spans.append((l_idx, r_idx))
start = r_idx + 1
return spans
def _find_colon_index(s: str, m: re.Match) -> Optional[int]:
segment = s[m.start():m.end()]
for i, ch in enumerate(segment):
if ch in (":", ""):
return m.start() + i
return None
if text:
# 处理上游未反序列化的 "\n" 等转义换行,避免被当成普通字符
if "\\n" in text or "\\r" in text:
text = text.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n")
if not should_parse_fields:
desc_lines.append(text.strip())
else:
# 匹配形如 "字段:值" 的片段,字段名不允许包含常见分隔符;
# 下一个字段需以顿号/逗号/分号等分隔开,且不能是 URL 协议开头,避免值里出现 URL 的":" 被误拆
# 字段名允许 emoji 等 Unicode 字符,但排除空白/分隔符/冒号
name_re = r"[^\s:,。;;、]+"
pair_pattern = re.compile(
rf"({name_re})[:](.*?)(?=(?:[,。;;、]+\s*(?!https?://|ftp://|ftps://|magnet:){name_re}[:])|$)",
re.IGNORECASE,
)
for line in text.splitlines():
line = line.strip()
if not line:
continue
matches = list(pair_pattern.finditer(line))
if matches:
book_spans = _collect_spans(line, "", "") + _collect_spans(line, "", "")
if book_spans:
has_book_colon = False
for m in matches:
colon_idx = _find_colon_index(line, m)
if colon_idx is not None and any(l < colon_idx < r for l, r in book_spans):
has_book_colon = True
break
if has_book_colon:
desc_lines.append(line)
continue
# 若整行只是 URL/时间等自然包含":"的内容,则不当作字段
url_like_names = {"http", "https", "ftp", "ftps", "magnet"}
if all(m.group(1).lower() in url_like_names or m.group(1).isdigit() for m in matches):
desc_lines.append(line)
continue
last_end = 0
for m in matches:
# 追加匹配前的非空文本到描述
prefix = line[last_end:m.start()].strip(" ,;;。、")
# 仅当前缀不全是分隔符/空白时才记录
if prefix and prefix.strip(" ,;;。、"):
desc_lines.append(prefix)
name = m.group(1).strip()
value = m.group(2).strip(" ,;;。、\t") or "-"
if name:
fields.append({"name": name, "value": value, "inline": False})
last_end = m.end()
# 匹配末尾后的文本
suffix = line[last_end:].strip(" ,;;。、")
if suffix and suffix.strip(" ,;;。、"):
desc_lines.append(suffix)
else:
desc_lines.append(line)
description = "\n".join(desc_lines).strip()
if not description and not fields and text:
description = text.strip()
embed = discord.Embed(
title=title,
url=link or "https://github.com/jxxghp/MoviePilot",
description=description if description else None,
color=0xE67E22
)
for field in fields:
embed.add_field(name=field["name"], value=field["value"], inline=False)
if image:
embed.set_image(url=image)
return embed
@staticmethod
def _build_media_embeds(medias: List[MediaInfo], title: str) -> List[discord.Embed]:
embeds: List[discord.Embed] = []
for index, media in enumerate(medias[:10], start=1):
overview = media.get_overview_string(80)
desc_parts = [
f"{media.type.value} | {media.vote_star}" if media.vote_star else media.type.value,
overview
]
embed = discord.Embed(
title=f"{index}. {media.title_year}",
url=media.detail_link or discord.Embed.Empty,
description="\n".join([p for p in desc_parts if p]),
color=0x5865F2
)
if media.get_poster_image():
embed.set_thumbnail(url=media.get_poster_image())
embeds.append(embed)
if embeds:
embeds[0].set_author(name=title)
return embeds
@staticmethod
def _build_torrent_embeds(torrents: List[Context], title: str) -> List[discord.Embed]:
embeds: List[discord.Embed] = []
for index, context in enumerate(torrents[:10], start=1):
torrent = context.torrent_info
meta = MetaInfo(torrent.title, torrent.description)
title_text = f"{meta.season_episode} {meta.resource_term} {meta.video_term} {meta.release_group}"
title_text = re.sub(r"\s+", " ", title_text).strip()
detail = [
f"{torrent.site_name} | {StringUtils.str_filesize(torrent.size)} | {torrent.volume_factor} | {torrent.seeders}",
meta.resource_term,
meta.video_term
]
embed = discord.Embed(
title=f"{index}. {title_text or torrent.title}",
url=torrent.page_url or discord.Embed.Empty,
description="\n".join([d for d in detail if d]),
color=0x00A86B
)
poster = getattr(torrent, "poster", None)
if poster:
embed.set_thumbnail(url=poster)
embeds.append(embed)
if embeds:
embeds[0].set_author(name=title)
return embeds
@staticmethod
def _build_default_buttons(count: int) -> List[List[dict]]:
buttons: List[List[dict]] = []
max_rows = 5
max_per_row = 5
capped = min(count, max_rows * max_per_row)
for idx in range(1, capped + 1):
row_idx = (idx - 1) // max_per_row
if len(buttons) <= row_idx:
buttons.append([])
buttons[row_idx].append({"text": f"选择 {idx}", "callback_data": str(idx)})
if count > capped:
logger.warn(f"按钮数量超过 Discord 限制,仅展示前 {capped}")
return buttons
@staticmethod
def _build_view(buttons: Optional[List[List[dict]]], link: Optional[str] = None) -> Optional[discord.ui.View]:
has_buttons = buttons and any(buttons)
if not has_buttons and not link:
return None
view = discord.ui.View(timeout=None)
if buttons:
for row_index, button_row in enumerate(buttons[:5]):
for button in button_row[:5]:
if "url" in button:
btn = discord.ui.Button(label=button.get("text", "链接"),
url=button["url"],
style=discord.ButtonStyle.link)
else:
custom_id = (button.get("callback_data") or button.get("text") or f"btn-{row_index}")[:99]
btn = discord.ui.Button(label=button.get("text", "选择")[:80],
custom_id=custom_id,
style=discord.ButtonStyle.primary)
view.add_item(btn)
elif link:
view.add_item(discord.ui.Button(label="查看详情", url=link, style=discord.ButtonStyle.link))
return view
async def _resolve_channel(self, userid: Optional[str] = None, chat_id: Optional[str] = None):
# 优先使用明确的聊天 ID
if chat_id:
channel = self._client.get_channel(int(chat_id))
if channel:
return channel
try:
return await self._client.fetch_channel(int(chat_id))
except Exception as err:
logger.warn(f"通过 chat_id 获取 Discord 频道失败:{err}")
# 私聊
if userid:
dm = await self._get_dm_channel(str(userid))
if dm:
return dm
# 配置的广播频道
if self._broadcast_channel:
return self._broadcast_channel
if self._channel_id:
channel = self._client.get_channel(self._channel_id)
if not channel:
try:
channel = await self._client.fetch_channel(self._channel_id)
except Exception as err:
logger.warn(f"通过配置的频道ID获取 Discord 频道失败:{err}")
channel = None
self._broadcast_channel = channel
if channel:
return channel
# 按 Guild 寻找一个可用文本频道
target_guilds = []
if self._guild_id:
guild = self._client.get_guild(self._guild_id)
if guild:
target_guilds.append(guild)
else:
target_guilds = list(self._client.guilds)
for guild in target_guilds:
for channel in guild.text_channels:
if guild.me and channel.permissions_for(guild.me).send_messages:
self._broadcast_channel = channel
return channel
return None
async def _get_dm_channel(self, userid: str) -> Optional[discord.DMChannel]:
if userid in self._user_dm_cache:
return self._user_dm_cache.get(userid)
try:
user_obj = self._client.get_user(int(userid)) or await self._client.fetch_user(int(userid))
if not user_obj:
return None
dm = user_obj.dm_channel or await user_obj.create_dm()
if dm:
self._user_dm_cache[userid] = dm
return dm
except Exception as err:
logger.error(f"获取 Discord 私聊失败:{err}")
return None
def _should_process_message(self, message: discord.Message) -> bool:
if isinstance(message.channel, discord.DMChannel):
return True
content = message.content or ""
# 仅处理 @Bot 或斜杠命令
if self._client.user and self._client.user.mentioned_in(message):
return True
if content.startswith("/"):
return True
return False
def _clean_bot_mention(self, content: str) -> str:
if not content:
return ""
if self._bot_user_id:
mention_pattern = rf"<@!?{self._bot_user_id}>"
content = re.sub(mention_pattern, "", content).strip()
return content
async def _post_to_ds(self, payload: Dict[str, Any]) -> None:
try:
proxy = None
if settings.PROXY:
proxy = settings.PROXY.get("https") or settings.PROXY.get("http")
async with httpx.AsyncClient(timeout=10, verify=False, proxy=proxy) as client:
await client.post(self._ds_url, json=payload)
except Exception as err:
logger.error(f"转发 Discord 消息失败:{err}")

View File

@@ -126,7 +126,7 @@ class LocalStorage(StorageBase):
return None
path_obj = Path(fileitem.path) / name
if not path_obj.exists():
path_obj.mkdir(parents=True)
path_obj.mkdir(parents=True, exist_ok=True)
return self.__get_diritem(path_obj)
def get_folder(self, path: Path) -> Optional[schemas.FileItem]:

View File

@@ -45,7 +45,7 @@ class Rclone(StorageBase):
logger.info(f"【rclone】配置写入文件{filepath}")
path = Path(filepath)
if not path.parent.exists():
path.parent.mkdir(parents=True)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(conf.get('content'), encoding='utf-8')
@staticmethod

View File

@@ -1,6 +1,5 @@
import re
from pathlib import Path
from threading import Lock
from typing import Optional, List, Tuple
from jinja2 import Template
@@ -19,53 +18,43 @@ from app.schemas import TransferInfo, TmdbEpisode, TransferDirectoryConf, FileIt
from app.schemas.types import MediaType, ChainEventType
from app.utils.system import SystemUtils
lock = Lock()
class TransHandler:
"""
文件转移整理类
"""
inner_lock: Lock = Lock()
def __init__(self):
self.result = None
pass
def __reset_result(self):
@staticmethod
def __update_result(result: TransferInfo, **kwargs):
"""
重置结果
更新结果
"""
self.result = TransferInfo()
def __set_result(self, **kwargs):
"""
设置结果
"""
with self.inner_lock:
# 设置值
for key, value in kwargs.items():
if hasattr(self.result, key):
current_value = getattr(self.result, key)
if current_value is None:
current_value = value
elif isinstance(current_value, list):
if isinstance(value, list):
current_value.extend(value)
else:
current_value.append(value)
elif isinstance(current_value, dict):
if isinstance(value, dict):
current_value.update(value)
else:
current_value[key] = value
elif isinstance(current_value, bool):
current_value = value
elif isinstance(current_value, int):
current_value += (value or 0)
# 设置值
for key, value in kwargs.items():
if hasattr(result, key):
current_value = getattr(result, key)
if current_value is None:
current_value = value
elif isinstance(current_value, list):
if isinstance(value, list):
current_value.extend(value)
else:
current_value = value
setattr(self.result, key, current_value)
current_value.append(value)
elif isinstance(current_value, dict):
if isinstance(value, dict):
current_value.update(value)
else:
current_value[key] = value
elif isinstance(current_value, bool):
current_value = value
elif isinstance(current_value, int):
current_value += (value or 0)
else:
current_value = value
setattr(result, key, current_value)
def transfer_media(self,
fileitem: FileItem,
@@ -100,8 +89,32 @@ class TransHandler:
:return: TransferInfo、错误信息
"""
# 重置结果
self.__reset_result()
def __is_subtitle_file(_fileitem: FileItem) -> bool:
"""
判断是否为字幕文件
:param _fileitem: 文件项
:return: True/False
"""
if not _fileitem.extension:
return False
if f".{_fileitem.extension.lower()}" in settings.RMT_SUBEXT:
return True
return False
def __is_extra_file(_fileitem: FileItem) -> bool:
"""
判断是否为附加文件
:param _fileitem: 文件项
:return: True/False
"""
if not _fileitem.extension:
return False
if f".{_fileitem.extension.lower()}" in (settings.RMT_SUBEXT + settings.RMT_AUDIOEXT):
return True
return False
# 整理结果
result = TransferInfo()
try:
@@ -122,16 +135,25 @@ class TransHandler:
rename_format, rename_path=new_path
)
if not new_path:
self.__set_result(
self.__update_result(
result=result,
success=False,
message="重命名格式无效",
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=need_notify,
)
return self.result.model_copy()
return result
else:
new_path = target_path / fileitem.name
# 原盘大小只计算STREAM目录内的文件大小
if stream_fileitem := source_oper.get_item(
Path(fileitem.path) / "BDMV" / "STREAM"
):
fileitem.size = 0
files = source_oper.list(stream_fileitem) or []
for file in files:
fileitem.size += file.size
# 整理目录
new_diritem, errmsg = self.__transfer_dir(fileitem=fileitem,
mediainfo=mediainfo,
@@ -139,39 +161,43 @@ class TransHandler:
target_oper=target_oper,
target_storage=target_storage,
target_path=new_path,
transfer_type=transfer_type)
transfer_type=transfer_type,
result=result)
if not new_diritem:
logger.error(f"文件夹 {fileitem.path} 整理失败:{errmsg}")
self.__set_result(success=False,
message=errmsg,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
self.__update_result(result=result,
success=False,
message=errmsg,
fileitem=fileitem,
transfer_type=transfer_type,
need_notify=need_notify)
return result
logger.info(f"文件夹 {fileitem.path} 整理成功")
# 返回整理后的路径
self.__set_result(success=True,
fileitem=fileitem,
target_item=new_diritem,
target_diritem=new_diritem,
need_scrape=need_scrape,
need_notify=need_notify,
transfer_type=transfer_type)
return self.result.model_copy()
self.__update_result(result=result,
success=True,
fileitem=fileitem,
target_item=new_diritem,
target_diritem=new_diritem,
need_scrape=need_scrape,
need_notify=need_notify,
transfer_type=transfer_type)
return result
else:
# 整理单个文件
if mediainfo.type == MediaType.TV:
# 电视剧
if in_meta.begin_episode is None:
logger.warn(f"文件 {fileitem.path} 整理失败:未识别到文件集数")
self.__set_result(success=False,
message="未识别到文件集数",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
self.__update_result(result=result,
success=False,
message="未识别到文件集数",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return result
# 文件结束季为空
in_meta.end_season = None
@@ -195,11 +221,18 @@ class TransHandler:
file_ext=f".{fileitem.extension}"
)
)
# 针对字幕文件,文件名中补充额外标识信息
if __is_subtitle_file(fileitem):
new_file = self.__rename_subtitles(fileitem, new_file)
# 文件目录
folder_path = DirectoryHelper.get_media_root_path(
rename_format, rename_path=new_file
)
if not folder_path:
self.__set_result(
self.__update_result(
result=result,
success=False,
message="重命名格式无效",
fileitem=fileitem,
@@ -207,75 +240,81 @@ class TransHandler:
transfer_type=transfer_type,
need_notify=need_notify,
)
return self.result.model_copy()
return result
else:
new_file = target_path / fileitem.name
folder_path = target_path
# 判断是否要覆盖
overflag = False
# 目标目录
target_diritem = target_oper.get_folder(folder_path)
if not target_diritem:
logger.error(f"目标目录 {folder_path} 获取失败")
self.__set_result(success=False,
message=f"目标目录 {folder_path} 获取失败",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
# 目标文件
target_item = target_oper.get_item(new_file)
if target_item:
# 目标文件已存在
target_file = new_file
if target_storage == "local" and new_file.is_symlink():
target_file = new_file.readlink()
if not target_file.exists():
overflag = True
if not overflag:
self.__update_result(result=result,
success=False,
message=f"目标目录 {folder_path} 获取失败",
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return result
# 判断是否要覆盖,附加文件强制覆盖
overflag = False
if not __is_extra_file(fileitem):
# 目标文件
target_item = target_oper.get_item(new_file)
if target_item:
# 目标文件已存在
logger.info(
f"目的文件系统中已经存在同名文件 {target_file},当前整理覆盖模式设置为 {overwrite_mode}")
if overwrite_mode == 'always':
# 总是覆盖同名文件
overflag = True
elif overwrite_mode == 'size':
# 存在时大覆盖小
if target_item.size < fileitem.size:
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
target_file = new_file
if target_storage == "local" and new_file.is_symlink():
target_file = new_file.readlink()
if not target_file.exists():
overflag = True
else:
self.__set_result(success=False,
message=f"媒体库存在同名文件,且质量更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
elif overwrite_mode == 'never':
# 存在不覆盖
self.__set_result(success=False,
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
elif overwrite_mode == 'latest':
# 仅保留最新版本
logger.info(f"当前整理覆盖模式设置为仅保留最新版本,将覆盖:{new_file}")
overflag = True
else:
if overwrite_mode == 'latest':
# 文件不存在,但仅保留最新版本
logger.info(f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
self.__delete_version_files(target_oper, new_file)
if not overflag:
# 目标文件已存在
logger.info(
f"目的文件系统中已经存在同名文件 {target_file},当前整理覆盖模式设置为 {overwrite_mode}")
if overwrite_mode == 'always':
# 总是覆盖同名文件
overflag = True
elif overwrite_mode == 'size':
# 存在时大覆盖小
if target_item.size < fileitem.size:
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
overflag = True
else:
self.__update_result(result=result,
success=False,
message=f"媒体库存在同名文件,且质量更好",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return result
elif overwrite_mode == 'never':
# 存在不覆盖
self.__update_result(result=result,
message=f"媒体库存在同名文件,当前覆盖模式为不覆盖",
fileitem=fileitem,
target_item=target_item,
target_diritem=target_diritem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return result
elif overwrite_mode == 'latest':
# 仅保留最新版本
logger.info(f"当前整理覆盖模式设置为仅保留最新版本,将覆盖:{new_file}")
overflag = True
else:
if overwrite_mode == 'latest':
# 文件不存在,但仅保留最新版本
logger.info(
f"当前整理覆盖模式设置为 {overwrite_mode},仅保留最新版本,正在删除已有版本文件 ...")
self.__delete_version_files(target_oper, new_file)
# 整理文件
new_item, err_msg = self.__transfer_file(fileitem=fileitem,
mediainfo=mediainfo,
@@ -284,28 +323,32 @@ class TransHandler:
transfer_type=transfer_type,
over_flag=overflag,
source_oper=source_oper,
target_oper=target_oper)
target_oper=target_oper,
result=result)
if not new_item:
logger.error(f"文件 {fileitem.path} 整理失败:{err_msg}")
self.__set_result(success=False,
message=err_msg,
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
self.__update_result(result=result,
success=False,
message=err_msg,
fileitem=fileitem,
fail_list=[fileitem.path],
transfer_type=transfer_type,
need_notify=need_notify)
return result
logger.info(f"文件 {fileitem.path} 整理成功")
self.__set_result(success=True,
fileitem=fileitem,
target_item=new_item,
target_diritem=target_diritem,
need_scrape=need_scrape,
transfer_type=transfer_type,
need_notify=need_notify)
return self.result.model_copy()
finally:
self.result = None
self.__update_result(result=result,
success=True,
fileitem=fileitem,
target_item=new_item,
target_diritem=target_diritem,
need_scrape=need_scrape,
transfer_type=transfer_type,
need_notify=need_notify)
return result
except Exception as e:
logger.error(f"媒体整理出错:{e}")
return TransferInfo(success=False, message=str(e))
@staticmethod
def __transfer_command(fileitem: FileItem, target_storage: str,
@@ -341,158 +384,118 @@ class TransHandler:
and fileitem.storage != "local" and target_storage != "local"):
return None, f"不支持 {fileitem.storage}{target_storage} 的文件整理"
# 加锁
with lock:
if fileitem.storage == "local" and target_storage == "local":
# 创建目录
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True)
# 本地到本地
if transfer_type == "copy":
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
elif transfer_type == "move":
state = source_oper.move(fileitem, target_file.parent, target_file.name)
elif transfer_type == "link":
state = source_oper.link(fileitem, target_file)
elif transfer_type == "softlink":
state = source_oper.softlink(fileitem, target_file)
if fileitem.storage == "local" and target_storage == "local":
# 创建目录
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True, exist_ok=True)
# 本地到本地
if transfer_type == "copy":
state = source_oper.copy(fileitem, target_file.parent, target_file.name)
elif transfer_type == "move":
state = source_oper.move(fileitem, target_file.parent, target_file.name)
elif transfer_type == "link":
state = source_oper.link(fileitem, target_file)
elif transfer_type == "softlink":
state = source_oper.softlink(fileitem, target_file)
else:
return None, f"不支持的整理方式:{transfer_type}"
if state:
return __get_targetitem(target_file), ""
else:
return None, f"{fileitem.path} {transfer_type} 失败"
elif fileitem.storage == "local" and target_storage != "local":
# 本地到网盘
filepath = Path(fileitem.path)
if not filepath.exists():
return None, f"文件 {filepath} 不存在"
if transfer_type == "copy":
# 复制
# 根据目的路径创建文件夹
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
if new_item:
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"不支持的整理方式:{transfer_type}"
if state:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动
# 根据目的路径获取文件夹
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
if new_item:
# 删除源文件
source_oper.delete(fileitem)
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif fileitem.storage != "local" and target_storage == "local":
# 网盘到本地
if target_file.exists():
logger.warn(f"文件已存在:{target_file}")
return __get_targetitem(target_file), ""
# 网盘到本地
if transfer_type in ["copy", "move"]:
# 下载
tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent)
if tmp_file:
# 创建目录
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True, exist_ok=True)
# 将tmp_file移动后target_file
SystemUtils.move(tmp_file, target_file)
if transfer_type == "move":
# 删除源文件
source_oper.delete(fileitem)
return __get_targetitem(target_file), ""
else:
return None, f"{fileitem.path} {transfer_type} 失败"
elif fileitem.storage == "local" and target_storage != "local":
# 本地到网盘
filepath = Path(fileitem.path)
if not filepath.exists():
return None, f"文件 {filepath} 不存在"
if transfer_type == "copy":
# 复制
# 根据目的路径创建文件夹
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
if new_item:
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动
# 根据目的路径获取文件夹
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
# 上传文件
new_item = target_oper.upload(target_fileitem, filepath, target_file.name)
if new_item:
# 删除源文件
source_oper.delete(fileitem)
return new_item, ""
else:
return None, f"{fileitem.path} 上传 {target_storage} 失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif fileitem.storage != "local" and target_storage == "local":
# 网盘到本地
if target_file.exists():
logger.warn(f"文件已存在:{target_file}")
return __get_targetitem(target_file), ""
# 网盘到本地
if transfer_type in ["copy", "move"]:
# 下载
tmp_file = source_oper.download(fileitem=fileitem, path=target_file.parent)
if tmp_file:
# 创建目录
if not target_file.parent.exists():
target_file.parent.mkdir(parents=True)
# 将tmp_file移动后target_file
SystemUtils.move(tmp_file, target_file)
if transfer_type == "move":
# 删除源文件
source_oper.delete(fileitem)
return __get_targetitem(target_file), ""
else:
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
elif fileitem.storage == target_storage:
# 同一网盘
if not source_oper.is_support_transtype(transfer_type):
return None, f"存储 {fileitem.storage} 不支持 {transfer_type} 整理方式"
return None, f"{fileitem.path} {fileitem.storage} 下载失败"
elif fileitem.storage == target_storage:
# 同一网盘
if not source_oper.is_support_transtype(transfer_type):
return None, f"存储 {fileitem.storage} 不支持 {transfer_type} 整理方式"
if transfer_type == "copy":
# 复制文件到新目录
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
if source_oper.copy(fileitem, Path(target_fileitem.path), target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 复制文件失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动文件到新目录
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 移动文件失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "link":
if source_oper.link(fileitem, target_file):
if transfer_type == "copy":
# 复制文件到新目录
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
if source_oper.copy(fileitem, Path(target_fileitem.path), target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 创建硬链接失败"
return None, f"{target_storage}{fileitem.path} 复制文件失败"
else:
return None, f"不支持的整理方式:{transfer_type}"
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "move":
# 移动文件到新目录
target_fileitem = target_oper.get_folder(target_file.parent)
if target_fileitem:
if source_oper.move(fileitem, Path(target_fileitem.path), target_file.name):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 移动文件失败"
else:
return None, f"{target_storage}{target_file.parent} 目录获取失败"
elif transfer_type == "link":
if source_oper.link(fileitem, target_file):
return target_oper.get_item(target_file), ""
else:
return None, f"{target_storage}{fileitem.path} 创建硬链接失败"
else:
return None, f"不支持的整理方式:{transfer_type}"
return None, "未知错误"
def __transfer_other_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
@staticmethod
def __rename_subtitles(sub_item: FileItem, new_file: Path) -> Path:
"""
根据文件名整理其他相关文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
# 整理字幕
state, errmsg = self.__transfer_subtitles(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
if not state:
return False, errmsg
# 整理音轨文件
state, errmsg = self.__transfer_audio_track_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
return state, errmsg
def __transfer_subtitles(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理对应字幕文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
重命名字幕文件,补充附加信息
"""
# 字幕正则式
_zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \
@@ -508,149 +511,33 @@ class TransHandler:
r"|(?<![a-z0-9])big5(?![a-z0-9])"
_eng_sub_re = r"[.\[(]eng[.\])]"
# 比对文件名并整理字幕
org_path = Path(fileitem.path)
# 查找上级文件项
parent_item: FileItem = source_oper.get_parent(fileitem)
if not parent_item:
return False, f"{org_path} 上级目录获取失败"
# 字幕文件列表
file_list: List[FileItem] = source_oper.list(parent_item) or []
file_list = [f for f in file_list if f.type == "file" and f.extension
and f".{f.extension.lower()}" in settings.RMT_SUBEXT]
if len(file_list) == 0:
logger.info(f"{parent_item.path} 目录下没有找到字幕文件...")
else:
logger.info(f"字幕文件清单:{[f.name for f in file_list]}")
# 识别文件名
metainfo = MetaInfoPath(org_path)
for sub_item in file_list:
# 识别字幕文件名
sub_file_name = re.sub(_zhtw_sub_re,
".",
re.sub(_zhcn_sub_re,
".",
sub_item.name,
flags=re.I),
flags=re.I)
sub_file_name = re.sub(_eng_sub_re, ".", sub_file_name, flags=re.I)
sub_metainfo = MetaInfoPath(Path(sub_item.path))
# 匹配字幕文件名
if (org_path.stem == Path(sub_file_name).stem) or \
(sub_metainfo.cn_name and sub_metainfo.cn_name == metainfo.cn_name) \
or (sub_metainfo.en_name and sub_metainfo.en_name == metainfo.en_name):
if metainfo.part and metainfo.part != sub_metainfo.part:
continue
if metainfo.season \
and metainfo.season != sub_metainfo.season:
continue
if metainfo.episode \
and metainfo.episode != sub_metainfo.episode:
continue
new_file_type = ""
# 兼容jellyfin字幕识别(多重识别), emby则会识别最后一个后缀
if re.search(_zhcn_sub_re, sub_item.name, re.I):
new_file_type = ".chi.zh-cn"
elif re.search(_zhtw_sub_re, sub_item.name,
re.I):
new_file_type = ".zh-tw"
elif re.search(_eng_sub_re, sub_item.name, re.I):
new_file_type = ".eng"
# 通过对比字幕文件大小 尽量整理所有存在的字幕
file_ext = f".{sub_item.extension}"
new_sub_tag_dict = {
".eng": ".英文",
".chi.zh-cn": ".简体中文",
".zh-tw": ".繁体中文"
}
new_sub_tag_list = [
(".default" + new_file_type if (
(settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn") or
(settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw") or
(settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")
) else new_file_type) if t == 0 else "%s%s(%s)" % (new_file_type,
new_sub_tag_dict.get(
new_file_type, ""
),
t) for t in range(6)
]
for new_sub_tag in new_sub_tag_list:
new_file: Path = target_file.with_name(target_file.stem + new_sub_tag + file_ext)
# 如果字幕文件不存在, 直接整理字幕, 并跳出循环
try:
logger.debug(f"正在处理字幕:{sub_item.name}")
new_item, errmsg = self.__transfer_command(fileitem=sub_item,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_file,
transfer_type=transfer_type)
if new_item:
logger.info(f"字幕 {sub_item.name} 整理完成")
self.__set_result(
subtitle_list=[sub_item.path],
subtitle_list_new=[new_item.path],
)
break
else:
logger.error(f"字幕 {sub_item.name} 整理失败:{errmsg}")
return False, errmsg
except Exception as error:
logger.info(f"字幕 {new_file} 出错了,原因: {str(error)}")
return True, ""
# 原文件后缀
file_ext = f".{sub_item.extension}"
# 新文件后缀
new_file_type = ""
def __transfer_audio_track_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
target_file: Path, transfer_type: str) -> Tuple[bool, str]:
"""
根据文件名整理对应音轨文件
:param fileitem: 源文件
:param target_storage: 目标存储
:param source_oper: 源存储操作对象
:param target_oper: 目标存储操作对象
:param target_file: 目标路径
:param transfer_type: 整理方式
"""
org_path = Path(fileitem.path)
# 查找上级文件项
parent_item: FileItem = source_oper.get_parent(fileitem)
if not parent_item:
return False, f"{org_path} 上级目录获取失败"
file_list: List[FileItem] = source_oper.list(parent_item)
# 匹配音轨文件
pending_file_list: List[FileItem] = [file for file in file_list
if Path(file.name).stem == org_path.stem
and file.type == "file" and file.extension
and f".{file.extension.lower()}" in settings.RMT_AUDIOEXT]
if len(pending_file_list) == 0:
return True, f"{parent_item.path} 目录下没有找到匹配的音轨文件"
logger.debug("音轨文件清单:" + str(pending_file_list))
for track_file in pending_file_list:
track_ext = f".{track_file.extension}"
new_track_file = target_file.with_name(target_file.stem + track_ext)
try:
logger.info(f"正在整理音轨文件:{track_file}{new_track_file}")
new_item, errmsg = self.__transfer_command(fileitem=track_file,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=new_track_file,
transfer_type=transfer_type)
if new_item:
logger.info(f"音轨文件 {org_path.name} 整理完成")
self.__set_result(
audio_list=[track_file.path],
audio_list_new=[new_item.path],
)
else:
logger.error(f"音轨文件 {org_path.name} 整理失败:{errmsg}")
except Exception as error:
logger.error(f"音轨文件 {org_path.name} 整理失败:{str(error)}")
return True, ""
# 识别字幕语言
if re.search(_zhcn_sub_re, sub_item.name, re.I):
new_file_type = ".chi.zh-cn"
elif re.search(_zhtw_sub_re, sub_item.name, re.I):
new_file_type = ".zh-tw"
elif re.search(_eng_sub_re, sub_item.name, re.I):
new_file_type = ".eng"
# 添加默认字幕标识
if ((settings.DEFAULT_SUB == "zh-cn" and new_file_type == ".chi.zh-cn")
or (settings.DEFAULT_SUB == "zh-tw" and new_file_type == ".zh-tw")
or (settings.DEFAULT_SUB == "eng" and new_file_type == ".eng")):
new_sub_tag = ".default" + new_file_type
else:
new_sub_tag = new_file_type
return new_file.with_name(new_file.stem + new_sub_tag + file_ext)
def __transfer_dir(self, fileitem: FileItem, mediainfo: MediaInfo,
source_oper: StorageBase, target_oper: StorageBase,
transfer_type: str, target_storage: str, target_path: Path) -> Tuple[Optional[FileItem], str]:
transfer_type: str, target_storage: str, target_path: Path,
result: TransferInfo) -> Tuple[Optional[FileItem], str]:
"""
整理整个文件夹
:param fileitem: 源文件
@@ -687,7 +574,8 @@ class TransHandler:
source_oper=source_oper,
target_oper=target_oper,
target_path=target_path,
transfer_type=transfer_type)
transfer_type=transfer_type,
result=result)
if state:
return target_item, errmsg
else:
@@ -695,7 +583,8 @@ class TransHandler:
def __transfer_dir_files(self, fileitem: FileItem, target_storage: str,
source_oper: StorageBase, target_oper: StorageBase,
transfer_type: str, target_path: Path) -> Tuple[bool, str]:
transfer_type: str, target_path: Path,
result: TransferInfo) -> Tuple[bool, str]:
"""
按目录结构整理目录下所有文件
:param fileitem: 源文件
@@ -716,7 +605,8 @@ class TransHandler:
source_oper=source_oper,
target_oper=target_oper,
transfer_type=transfer_type,
target_path=new_path)
target_path=new_path,
result=result)
if not state:
return False, errmsg
else:
@@ -730,7 +620,8 @@ class TransHandler:
transfer_type=transfer_type)
if not new_item:
return False, errmsg
self.__set_result(
self.__update_result(
result=result,
file_list=[item.path],
file_list_new=[new_item.path],
)
@@ -740,7 +631,8 @@ class TransHandler:
def __transfer_file(self, fileitem: FileItem, mediainfo: MediaInfo,
source_oper: StorageBase, target_oper: StorageBase,
target_storage: str, target_file: Path,
transfer_type: str, over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
transfer_type: str, result: TransferInfo,
over_flag: Optional[bool] = False) -> Tuple[Optional[FileItem], str]:
"""
整理一个文件,同时处理其他相关文件
:param fileitem: 原文件
@@ -799,19 +691,13 @@ class TransHandler:
target_file=target_file,
transfer_type=transfer_type)
if new_item:
self.__set_result(
self.__update_result(
result=result,
file_list=[fileitem.path],
file_list_new=[new_item.path],
file_count=1,
total_size=fileitem.size,
)
# 处理其他相关文件
self.__transfer_other_files(fileitem=fileitem,
target_storage=target_storage,
source_oper=source_oper,
target_oper=target_oper,
target_file=target_file,
transfer_type=transfer_type)
return new_item, errmsg
return None, errmsg
@@ -904,7 +790,8 @@ class TransHandler:
continue
if media_file.type != "file":
continue
if f".{media_file.extension.lower()}" not in settings.RMT_MEDIAEXT:
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if f".{media_file.extension.lower()}" not in media_exts:
continue
# 识别文件中的季集信息
filemeta = MetaInfoPath(media_path)

View File

@@ -12,6 +12,7 @@ from app.modules.indexer.spider import SiteSpider
from app.modules.indexer.spider.haidan import HaiDanSpider
from app.modules.indexer.spider.hddolby import HddolbySpider
from app.modules.indexer.spider.mtorrent import MTorrentSpider
from app.modules.indexer.spider.rousi import RousiSpider
from app.modules.indexer.spider.tnode import TNodeSpider
from app.modules.indexer.spider.torrentleech import TorrentLeech
from app.modules.indexer.spider.yema import YemaSpider
@@ -212,6 +213,13 @@ class IndexerModule(_ModuleBase):
mtype=mtype,
page=page
)
elif site.get('parser') == "RousiPro":
error_flag, result = RousiSpider(site).search(
keyword=search_word,
mtype=mtype,
cat=cat,
page=page
)
else:
error_flag, result = self.__spider_search(
search_word=search_word,
@@ -300,6 +308,13 @@ class IndexerModule(_ModuleBase):
mtype=mtype,
page=page
)
elif site.get('parser') == "RousiPro":
error_flag, result = await RousiSpider(site).async_search(
keyword=search_word,
mtype=mtype,
cat=cat,
page=page
)
else:
error_flag, result = await self.__async_spider_search(
search_word=search_word,

View File

@@ -35,6 +35,7 @@ class SiteSchema(Enum):
HDDolby = "HDDolby"
Zhixing = "Zhixing"
Bitpt = "Bitpt"
RousiPro = "RousiPro"
class SiteParserBase(metaclass=ABCMeta):

View File

@@ -15,9 +15,9 @@ class GazelleSiteUserInfo(SiteParserBase):
html_text = self._prepare_html_text(html_text)
html = etree.HTML(html_text)
try:
tmps = html.xpath('//a[contains(@href, "user.php?id=")]')
tmps = html.xpath('//a[contains(@href, "user.php?id=") or contains(@href, "user?id=")]')
if tmps:
user_id_match = re.search(r"user.php\?id=(\d+)", tmps[0].attrib['href'])
user_id_match = re.search(r"user(?:\.php)?\?id=(\d+)", tmps[0].attrib['href'])
if user_id_match and user_id_match.group().strip():
self.userid = user_id_match.group(1)
self._torrent_seeding_page = f"torrents.php?type=seeding&userid={self.userid}"
@@ -42,13 +42,13 @@ class GazelleSiteUserInfo(SiteParserBase):
self.ratio = 0.0 if self.download <= 0.0 else round(self.upload / self.download, 3)
tmps = html.xpath('//a[contains(@href, "bonus.php")]/@data-tooltip')
tmps = html.xpath('//a[contains(@href, "bonus")]/@data-tooltip')
if tmps:
bonus_match = re.search(r"([\d,.]+)", tmps[0])
if bonus_match and bonus_match.group(1).strip():
self.bonus = StringUtils.str_float(bonus_match.group(1))
else:
tmps = html.xpath('//a[contains(@href, "bonus.php")]')
tmps = html.xpath('//a[contains(@href, "bonus")]')
if tmps:
bonus_text = tmps[0].xpath("string(.)")
bonus_match = re.search(r"([\d,.]+)", bonus_text)
@@ -142,7 +142,7 @@ class GazelleSiteUserInfo(SiteParserBase):
# 是否存在下页数据
next_page = None
next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页")]/@href')
next_page_text = html.xpath('//a[contains(.//text(), "Next") or contains(.//text(), "下一页") or contains(@title, "下一页") or contains(@title, "Next")]/@href')
if next_page_text:
next_page = next_page_text[-1].strip()
finally:

View File

@@ -0,0 +1,234 @@
# -*- coding: utf-8 -*-
import json
from urllib.parse import urljoin
from typing import Optional, Tuple
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.modules.indexer.parser import SiteParserBase, SiteSchema
class RousiSiteUserInfo(SiteParserBase):
"""
Rousi.pro 站点解析器
使用 API v1 接口,通过 Passkey (Bearer Token) 进行认证
"""
schema = SiteSchema.RousiPro
request_mode = "apikey"
def _parse_site_page(self, html_text: str):
"""
配置 API 请求地址和请求头
使用 API v1 的 /profile 接口获取用户信息
"""
self._base_url = f"https://{StringUtils.get_url_domain(self._site_url)}"
self._user_basic_page = "api/v1/profile?include_fields[user]=seeding_leeching_data"
self._user_basic_params = {}
self._user_basic_headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": f"Bearer {self.apikey}"
}
# Rousi.pro API v1 在单个接口返回所有信息,无需额外页面
self._user_traffic_page = None
self._user_detail_page = None
self._torrent_seeding_page = None
self._user_mail_unread_page = None
self._sys_mail_unread_page = None
def _parse_logged_in(self, html_text):
"""
判断是否登录成功
API 认证模式下,通过 HTTP 状态码判断,此处始终返回 True
"""
return True
def _parse_user_base_info(self, html_text: str):
"""
解析用户基本信息
通过 API v1 接口获取用户完整信息,包括上传下载量、做种数据等
API 响应示例:
{
"code": 0,
"message": "success",
"data": {
"id": 1,
"username": "example",
"level_text": "Lv.5",
"registered_at": "2024-01-01T00:00:00Z",
"uploaded": 1073741824,
"downloaded": 536870912,
"ratio": 2.0,
"karma": 1000.5,
"seeding_leeching_data": {
"seeding_count": 10,
"seeding_size": 10737418240,
"leeching_count": 2,
"leeching_size": 2147483648
}
}
}
"""
if not html_text:
return
try:
data = json.loads(html_text)
except json.JSONDecodeError:
logger.error(f"{self._site_name} JSON 解析失败")
return
if not data or data.get("code") != 0:
self.err_msg = data.get("message", "未知错误")
logger.warn(f"{self._site_name} API 错误: {self.err_msg}")
return
user_info = data.get("data")
if not user_info:
return
# 基本信息
self.userid = user_info.get("id")
self.username = user_info.get("username")
self.user_level = user_info.get("level_text") or user_info.get("role_text")
# 注册时间:统一格式为 YYYY-MM-DD HH:MM:SS
join_at = StringUtils.unify_datetime_str(user_info.get("registered_at"))
if join_at:
# 确保格式为 YYYY-MM-DD HH:MM:SS (19位)
if len(join_at) >= 19:
self.join_at = join_at[:19]
else:
self.join_at = join_at
# 流量信息
self.upload = int(user_info.get("uploaded") or 0)
self.download = int(user_info.get("downloaded") or 0)
self.ratio = round(float(user_info.get("ratio") or 0), 2)
# 魔力值(站点称为 karma
self.bonus = float(user_info.get("karma") or 0)
# 做种/下载中数据
sl_data = user_info.get("seeding_leeching_data", {})
self.seeding = int(sl_data.get("seeding_count") or 0)
self.seeding_size = int(sl_data.get("seeding_size") or 0)
self.leeching = int(sl_data.get("leeching_count") or 0)
self.leeching_size = int(sl_data.get("leeching_size") or 0)
def _parse_user_traffic_info(self, html_text: str):
"""
解析用户流量信息
Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析,此方法无需实现
"""
pass
def _parse_user_detail_info(self, html_text: str):
"""
解析用户详细信息
Rousi.pro API v1 在 _parse_user_base_info 中已完成所有解析,此方法无需实现
"""
pass
def _parse_user_torrent_seeding_info(self, html_text: str, multi_page: Optional[bool] = False) -> Optional[str]:
"""
解析用户做种信息
Rousi.pro API v1 在 _parse_user_base_info 中已通过 seeding_leeching_data 获取做种数据
:param html_text: 页面内容
:param multi_page: 是否多页数据
:return: 下页地址(无下页返回 None
"""
return None
def _parse_message_unread_links(self, html_text: str, msg_links: list) -> Optional[str]:
"""
解析未读消息链接
Rousi.pro API v1 暂未提供消息相关接口
:param html_text: 页面内容
:param msg_links: 消息链接列表
:return: 下页地址(无下页返回 None
"""
return None
def _parse_message_content(self, html_text) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""
解析消息内容
Rousi.pro API v1 暂未提供消息相关接口
:param html_text: 页面内容
:return: (标题, 日期, 内容)
"""
return None, None, None
def _pase_unread_msgs(self):
"""
解析所有未读消息标题和内容
Rousi.pro API v1 暂未提供消息相关接口,暂时以网页接口实现
:return:
"""
if not self.token:
logger.warn(f"{self._site_name} 站点未配置 Authorization 请求头,跳过消息解析")
return
headers = {
"User-Agent": self._ua,
"Accept": "application/json, text/plain, */*",
"Authorization": self.token if self.token.startswith("Bearer ") else f"Bearer {self.token}"
}
def __get_message_list(page: int):
params = {
"page": page,
"page_size": 100,
"unread_only": "true"
}
res = RequestUtils(
headers=headers,
timeout=60,
proxies=settings.PROXY if self._proxy else None
).get_res(
url=urljoin(self._base_url, "api/messages"),
params=params
)
if not res or res.status_code != 200 or not res.text:
logger.warn(f"{self._site_name} 站点解析消息失败,状态码: {res.status_code if res else '无响应'}")
return {
"messages": [],
"total_pages": 0
}
return res.json()
# 分页获取所有未读消息
page = 0
res = __get_message_list(page)
page += 1
messages = res.get("messages", [])
total_pages = res.get("total_pages", 0)
while page < total_pages:
res = __get_message_list(page)
messages.extend(res.get("messages", []))
page += 1
self.message_unread = len(messages)
for messsage in messages:
head = messsage.get("title")
date = StringUtils.unify_datetime_str(messsage.get("created_at"))
content = messsage.get("content")
logger.debug(f"{self._site_name} 标题 {head} 时间 {date} 内容 {content}")
self.message_unread_contents.append((head, date, content))
# 更新消息为已读
RequestUtils(
headers=headers,
timeout=60,
proxies=settings.PROXY if self._proxy else None
).post_res(
url=urljoin(self._base_url, "api/messages/read-all")
)

View File

@@ -2,6 +2,7 @@ import base64
import json
import re
from typing import Tuple, List, Optional
from urllib.parse import urlparse
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
@@ -25,6 +26,9 @@ class MTorrentSpider:
_size = 100
_searchurl = "https://api.%s/api/torrent/search"
_downloadurl = "https://api.%s/api/torrent/genDlToken"
_subtitle_list_url = "https://api.%s/api/subtitle/list"
_subtitle_genlink_url = "https://api.%s/api/subtitle/genlink"
_subtitle_download_url ="https://api.%s/api/subtitle/dlV2?credential=%s"
_pageurl = "%sdetail/%s"
_timeout = 15
@@ -114,24 +118,36 @@ class MTorrentSpider:
labels_value = self._labels.get(result.get('labels') or "0") or ""
if labels_value:
labels = labels_value.split()
status = result.get('status', {})
torrent = {
'title': result.get('name'),
'description': result.get('smallDescr'),
'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.format_timestamp(result.get('createdDate')),
'size': int(result.get('size') or '0'),
'seeders': int(result.get('status', {}).get("seeders") or '0'),
'peers': int(result.get('status', {}).get("leechers") or '0'),
'grabs': int(result.get('status', {}).get("timesCompleted") or '0'),
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('status', {}).get("discount")),
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('status', {}).get("discount")),
'seeders': int(status.get("seeders") or '0'),
'peers': int(status.get("leechers") or '0'),
'grabs': int(status.get("timesCompleted") or '0'),
'downloadvolumefactor': self.__get_downloadvolumefactor(status.get("discount")),
'uploadvolumefactor': self.__get_uploadvolumefactor(status.get("discount")),
'page_url': self._pageurl % (self._url, result.get('id')),
'imdbid': self.__find_imdbid(result.get('imdb')),
'labels': labels,
'category': category
}
if discount_end_time := (result.get('status') or {}).get('discountEndTime'):
if discount_end_time := status.get('discountEndTime'):
torrent['freedate'] = StringUtils.format_timestamp(discount_end_time)
# 解析全站促销时的规则(当前馒头只有下载促销)
if promotion_rule := status.get("promotionRule"):
discount = promotion_rule.get("discount", "NORMAL")
torrent["downloadvolumefactor"] = self.__get_downloadvolumefactor(discount)
if end_time := promotion_rule.get("endTime"):
torrent["freedate"] = StringUtils.format_timestamp(end_time)
if mall_single_free := status.get("mallSingleFree"):
if mall_single_free.get("status") == "ONGOING":
torrent["downloadvolumefactor"] = self.__get_downloadvolumefactor("FREE")
if end_date := mall_single_free.get("endDate"):
torrent["freedate"] = StringUtils.format_timestamp(end_date)
torrents.append(torrent)
return torrents
@@ -262,3 +278,110 @@ class MTorrentSpider:
# base64编码
base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8')
return f"[{base64_str}]{url}"
def get_subtitle_links(self, page_url: str) -> List[str]:
"""
获取指定页面的字幕下载链接
:param page_url: 种子详情页网址
:type page_url: str
:return: 字幕下载链接
:rtype: List[str]
"""
if not page_url:
return []
# 从馒头的详情页网址中提取种子id
torrent_id = urlparse(page_url).path.rsplit("/", 1)[-1].strip()
if not torrent_id:
return []
return self.get_subtitle_links_by_id(torrent_id)
def get_subtitle_links_by_id(self, torrent_id: str) -> List[str]:
"""
获取指定种子的字幕下载链接
:param torrent_id: 种子ID
:type torrent_id: str
:return: 字幕下载链接
:rtype: List[str]
"""
results = []
try:
for subtitle_id in self.__subtitle_ids(torrent_id) or []:
if link := self.__subtitle_genlink(subtitle_id):
results.append(link)
except Exception as e:
logger.error(f"{self._name} 获取字幕失败:{e}")
return results
def __subtitle_ids(self, torrent_id: str) -> Optional[List[str]]:
"""
获取指定种子的字幕列表
:param torrent_id: 种子ID
:type torrent_id: str
:return: 字幕ID
:rtype: List[str] | None
"""
url = self._subtitle_list_url % self._domain
# 发送请求
res = RequestUtils(
headers={
"Accept": "application/json, text/plain, */*",
"User-Agent": f"{self._ua}",
"x-api-key": self._apikey,
},
proxies=self._proxy,
timeout=self._timeout,
).post_res(url, data={"id": torrent_id})
if res and res.status_code == 200:
result = res.json()
if int(result.get("code", -1)) == 0:
return [item["id"] for item in result.get("data", []) if "id" in item]
else:
logger.warn(
f"{self._name} 获取字幕列表失败,返回:{result.get("message", "未知")}"
)
return None
elif res is not None:
logger.warn(f"{self._name} 获取字幕列表失败,错误码:{res.status_code}")
return None
else:
logger.warn(f"{self._name} 获取字幕列表失败,无法连接 {self._domain}")
return None
def __subtitle_genlink(self, subtitle_id: str) -> Optional[str]:
"""
获取字幕下载链接
:param subtitle_id: 字幕ID
:type subtitle_id: str
:return: 下载链接
:rtype: str | None
"""
url = self._subtitle_genlink_url % self._domain
# 发送请求
res = RequestUtils(
headers={
"Accept": "application/json, text/plain, */*",
"User-Agent": f"{self._ua}",
"x-api-key": self._apikey,
},
proxies=self._proxy,
timeout=self._timeout,
).post_res(url, data={"id": subtitle_id})
if res and res.status_code == 200:
result = res.json()
if int(result.get("code", -1)) == 0 and isinstance(result.get("data"), str):
return self._subtitle_download_url % (self._domain, result["data"])
else:
logger.warn(
f"{self._name} 获取字幕下载链接失败,返回:{result.get("message", "未知")}"
)
return None
elif res is not None:
logger.warn(f"{self._name} 获取字幕下载链接失败,错误码:{res.status_code}")
return None
else:
logger.warn(f"{self._name} 获取字幕下载链接失败,无法连接 {self._domain}")
return None

View File

@@ -0,0 +1,289 @@
import base64
import json
from typing import List, Optional, Tuple
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils, AsyncRequestUtils
from app.utils.string import StringUtils
class RousiSpider:
"""
Rousi.pro API v1 Spider
使用 API v1 接口进行种子搜索
- 认证方式Bearer Token (Passkey)
- 搜索接口:/api/v1/torrents
- 详情接口:/api/v1/torrents/:id
"""
_indexerid = None
_domain = None
_url = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_size = 100
_searchurl = "https://%s/api/v1/torrents"
_downloadurl = "https://%s/api/v1/torrents/%s"
_timeout = 15
# 分类定义
# API 不支持多分类搜索,每次只使用一个分类
_movie_category = 'movie'
_tv_category = 'tv'
# API KEY
_apikey = None
def __init__(self, indexer: dict):
self.systemconfig = SystemConfigOper()
if indexer:
self._indexerid = indexer.get('id')
self._url = indexer.get('domain')
self._domain = StringUtils.get_url_domain(self._url)
self._searchurl = self._searchurl % self._domain
self._downloadurl = self._downloadurl % (self._domain, "%s")
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self._apikey = indexer.get('apikey')
self._timeout = indexer.get('timeout') or 15
def __get_params(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> dict:
"""
构建 API 请求参数
:param keyword: 搜索关键词
:param mtype: 媒体类型 (MOVIE/TV)
:param cat: 用户选择的分类 ID逗号分隔的字符串
:param page: 页码(从 0 开始API 需要从 1 开始)
:return: 请求参数字典
"""
params = {
"page": int(page) + 1,
"page_size": self._size
}
if keyword:
params["keyword"] = keyword
# API 不支持多分类搜索,只使用单个 category 参数
# 优先使用用户选择的分类,如果用户未选择则根据 mtype 推断
if cat:
# 用户选择了特定分类,需要将分类 ID 映射回 API 的 category name
category_names = self.__get_category_names_by_ids(cat)
if category_names:
# 如果用户选择了多个分类,只取第一个
params["category"] = category_names[0]
elif mtype:
# 用户未选择分类,根据媒体类型推断
if mtype == MediaType.MOVIE:
params["category"] = self._movie_category
elif mtype == MediaType.TV:
params["category"] = self._tv_category
return params
def __get_category_names_by_ids(self, cat: str) -> Optional[list]:
"""
根据用户选择的分类 ID 获取 API 的 category names
:param cat: 用户选择的分类 ID逗号分隔的多个ID"1,2,3"
:return: API 的 category names 列表(如 ["movie", "tv", "documentary"]
"""
if not cat:
return None
# ID 到 category name 的映射
id_to_name = {
'1': 'movie',
'2': 'tv',
'3': 'documentary',
'4': 'animation',
'6': 'variety'
}
# 分割多个分类 ID 并映射为 category names
cat_ids = [c.strip() for c in cat.split(',') if c.strip()]
category_names = [id_to_name.get(cat_id) for cat_id in cat_ids if cat_id in id_to_name]
return category_names if category_names else None
def __process_response(self, res) -> Tuple[bool, List[dict]]:
"""
处理 API 响应
:param res: 请求响应对象
:return: (是否发生错误, 种子列表)
"""
if res and res.status_code == 200:
try:
data = res.json()
if data.get('code') == 0:
results = data.get('data', {}).get('torrents', [])
return False, self.__parse_result(results)
else:
logger.warn(f"{self._name} 搜索失败,错误信息:{data.get('message')}")
return True, []
except Exception as e:
logger.warn(f"{self._name} 解析响应失败:{e}")
return True, []
elif res is not None:
logger.warn(f"{self._name} 搜索失败HTTP 错误码:{res.status_code}")
return True, []
else:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
def __parse_result(self, results: List[dict]) -> List[dict]:
"""
解析搜索结果
将 API 返回的种子数据转换为 MoviePilot 标准格式
:param results: API 返回的种子列表
:return: 标准化的种子信息列表
"""
torrents = []
if not results:
return torrents
for result in results:
# 解析分类信息
raw_cat = result.get('category')
cat_val = None
category = MediaType.UNKNOWN.value
if isinstance(raw_cat, dict):
cat_val = raw_cat.get('slug') or raw_cat.get('name')
elif isinstance(raw_cat, str):
cat_val = raw_cat
if cat_val:
cat_val = str(cat_val).lower()
if cat_val == self._movie_category:
category = MediaType.MOVIE.value
elif cat_val == self._tv_category:
category = MediaType.TV.value
else:
category = MediaType.UNKNOWN.value
# 解析促销信息
# API 后端已处理全站促销优先级,直接使用返回的 promotion 数据
downloadvolumefactor = 1.0
uploadvolumefactor = 1.0
freedate = None
promotion = result.get('promotion')
if promotion and promotion.get('is_active'):
downloadvolumefactor = float(promotion.get('down_multiplier', 1.0))
uploadvolumefactor = float(promotion.get('up_multiplier', 1.0))
# 促销到期时间,格式化为 YYYY-MM-DD HH:MM:SS
if promotion.get('until'):
freedate = StringUtils.unify_datetime_str(promotion.get('until'))
torrent = {
'title': result.get('title'),
'description': result.get('subtitle'),
'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.unify_datetime_str(result.get('created_at')),
'size': int(result.get('size') or 0),
'seeders': int(result.get('seeders') or 0),
'peers': int(result.get('leechers') or 0),
'grabs': int(result.get('downloads') or 0),
'downloadvolumefactor': downloadvolumefactor,
'uploadvolumefactor': uploadvolumefactor,
'freedate': freedate,
'page_url': f"https://{self._domain}/torrent/{result.get('uuid')}",
'labels': [],
'category': category
}
torrents.append(torrent)
return torrents
def search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
"""
同步搜索种子
:param keyword: 搜索关键词
:param mtype: 媒体类型 (MOVIE/TV)
:param cat: 用户选择的分类 ID逗号分隔
:param page: 页码(从 0 开始)
:return: (是否发生错误, 种子列表)
"""
if not self._apikey:
logger.warn(f"{self._name} 未配置 API Key (Passkey)")
return True, []
params = self.__get_params(keyword, mtype, cat, page)
headers = {
"Authorization": f"Bearer {self._apikey}",
"Accept": "application/json"
}
res = RequestUtils(
headers=headers,
proxies=self._proxy,
timeout=self._timeout
).get_res(url=self._searchurl, params=params)
return self.__process_response(res)
async def async_search(self, keyword: str, mtype: MediaType = None, cat: Optional[str] = None, page: Optional[int] = 0) -> Tuple[bool, List[dict]]:
"""
异步搜索种子
:param keyword: 搜索关键词
:param mtype: 媒体类型 (MOVIE/TV)
:param cat: 用户选择的分类 ID逗号分隔
:param page: 页码(从 0 开始)
:return: (是否发生错误, 种子列表)
"""
if not self._apikey:
logger.warn(f"{self._name} 未配置 API Key (Passkey)")
return True, []
params = self.__get_params(keyword, mtype, cat, page)
headers = {
"Authorization": f"Bearer {self._apikey}",
"Accept": "application/json"
}
res = await AsyncRequestUtils(
headers=headers,
proxies=self._proxy,
timeout=self._timeout
).get_res(url=self._searchurl, params=params)
return self.__process_response(res)
def __get_download_url(self, torrent_id: int) -> str:
"""
构建种子下载链接
使用 base64 编码的方式告诉 MoviePilot 如何获取真实下载地址
MoviePilot 会先请求详情接口,然后从响应中提取 data.download_url
:param torrent_id: 种子 ID
:return: base64 编码的请求配置字符串 + 详情接口 URL
"""
url = self._downloadurl % torrent_id
# MoviePilot 会解析这个特殊格式的 URL
# 1. 使用指定的 method 和 header 请求 URL
# 2. 从 JSON 响应中提取 result 指定的字段值作为真实下载地址
params = {
'method': 'get',
'header': {
'Authorization': f'Bearer {self._apikey}',
'Accept': 'application/json'
},
'result': 'data.download_url'
}
base64_str = base64.b64encode(json.dumps(params).encode('utf-8')).decode('utf-8')
return f"[{base64_str}]{url}"

View File

@@ -124,12 +124,12 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
return None, None, None, "下载内容为空"
# 读取种子的名称
torrent, content = __get_torrent_info()
torrent_from_file, content = __get_torrent_info()
# 检查是否为磁力链接
is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content,
bytes) and content.startswith(
b"magnet:")
if not torrent and not is_magnet:
if not torrent_from_file and not is_magnet:
return None, None, None, f"添加种子任务失败:无法读取种子文件"
# 获取下载器
@@ -170,8 +170,8 @@ class QbittorrentModule(_ModuleBase, _DownloaderBase[Qbittorrent]):
try:
for torrent in torrents:
# 名称与大小相等则认为是同一个种子
if torrent.get("name") == torrent.name \
and torrent.get("total_size") == torrent.total_size:
if torrent.get("name") == getattr(torrent_from_file, 'name', '') \
and torrent.get("total_size") == getattr(torrent_from_file, 'total_size', 0):
torrent_hash = torrent.get("hash")
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}")

View File

@@ -8,9 +8,13 @@ from lxml import etree
from app.chain.storage import StorageChain
from app.core.config import settings
from app.core.context import Context
from app.db.site_oper import SiteOper
from app.helper.sites import SitesHelper # noqa
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.indexer.spider.mtorrent import MTorrentSpider
from app.schemas import TorrentInfo
from app.schemas.file import FileURI
from app.schemas.types import ModuleType, OtherModulesType
from app.utils.http import RequestUtils
@@ -25,7 +29,9 @@ class SubtitleModule(_ModuleBase):
# 站点详情页字幕下载链接识别XPATH
_SITE_SUBTITLE_XPATH = [
'//td[@class="rowhead"][text()="字幕"]/following-sibling::td//a[not(@class)]/@href',
'//td[@class="rowhead"][text()="字幕"]/following-sibling::td//a/@href',
'//div[contains(@class, "font-bold")][text()="字幕"]/following-sibling::div[1]//a[not(@class)]/@href', # 憨憨
]
def init_module(self) -> None:
@@ -65,6 +71,58 @@ class SubtitleModule(_ModuleBase):
def test(self):
pass
def _get_subtitle_links(self, torrent: TorrentInfo):
"""
获取字幕链接
"""
# API请求方式的站点需要特殊处理
if torrent.site is not None:
site = SiteOper().get(torrent.site)
if indexer := SitesHelper().get_indexer(site.domain):
if indexer.get("parser") == "mTorrent":
return MTorrentSpider(indexer).get_subtitle_links(
torrent.page_url
)
# TODO 其它采用API访问的站点
# 普通站点通过解析网站代码的方式获取
request = RequestUtils(
cookies=torrent.site_cookie,
ua=torrent.site_ua,
proxies=settings.PROXY if torrent.site_proxy else None,
)
res = request.get_res(torrent.page_url)
if res and res.status_code == 200:
if not res.text:
logger.warn(f"读取页面代码失败:{torrent.page_url}")
return []
html = etree.HTML(res.text)
try:
sublink_list = []
for xpath in self._SITE_SUBTITLE_XPATH:
sublinks = html.xpath(xpath)
if sublinks:
for sublink in sublinks:
if not sublink:
continue
if not sublink.startswith("http"):
base_url = StringUtils.get_base_url(torrent.page_url)
if sublink.startswith("/"):
sublink = "%s%s" % (base_url, sublink)
else:
sublink = "%s/%s" % (base_url, sublink)
sublink_list.append(sublink)
# 已成功获取了链接后续xpath可以忽略
break
return sublink_list
finally:
if html is not None:
del html
elif res is not None:
logger.warn(f"连接 {torrent.page_url} 失败,状态码:{res.status_code}")
else:
logger.warn(f"无法打开链接:{torrent.page_url}")
return None
def download_added(self, context: Context, download_dir: Path, torrent_content: Union[str, bytes] = None):
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录
@@ -117,83 +175,60 @@ class SubtitleModule(_ModuleBase):
logger.error(f"下载目录不存在,无法保存字幕:{download_dir / folder_name}")
return
# 读取网站代码
request = RequestUtils(cookies=torrent.site_cookie, ua=torrent.site_ua)
res = request.get_res(torrent.page_url)
if res and res.status_code == 200:
if not res.text:
logger.warn(f"读取页面代码失败:{torrent.page_url}")
return
html = etree.HTML(res.text)
try:
sublink_list = []
for xpath in self._SITE_SUBTITLE_XPATH:
sublinks = html.xpath(xpath)
if sublinks:
for sublink in sublinks:
if not sublink:
continue
if not sublink.startswith("http"):
base_url = StringUtils.get_base_url(torrent.page_url)
if sublink.startswith("/"):
sublink = "%s%s" % (base_url, sublink)
else:
sublink = "%s/%s" % (base_url, sublink)
sublink_list.append(sublink)
finally:
if html is not None:
del html
# 下载所有字幕文件
for sublink in sublink_list:
logger.info(f"找到字幕下载链接:{sublink},开始下载...")
# 下载
ret = request.get_res(sublink)
if ret and ret.status_code == 200:
# 保存ZIP
file_name = TorrentHelper.get_url_filename(ret, sublink)
if not file_name:
logger.warn(f"链接不是字幕文件:{sublink}")
continue
if file_name.lower().endswith(".zip"):
# ZIP包
zip_file = settings.TEMP_PATH / file_name
# 保存
zip_file.write_bytes(ret.content)
# 解压路径
zip_path = zip_file.with_name(zip_file.stem)
# 解压文件
shutil.unpack_archive(zip_file, zip_path, format='zip')
# 遍历转移文件
for sub_file in SystemUtils.list_files(zip_path, settings.RMT_SUBEXT):
target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)
if storageChain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storageChain.upload_file(working_dir_item, sub_file)
# 删除临时文件
try:
shutil.rmtree(zip_path)
zip_file.unlink()
except Exception as err:
logger.error(f"删除临时文件失败:{str(err)}")
else:
sub_file = settings.TEMP_PATH / file_name
# 保存
sub_file.write_bytes(ret.content)
sublink_list = self._get_subtitle_links(torrent)
if not sublink_list:
logger.warn(f"{torrent.page_url} 页面未找到字幕下载链接")
return
# 下载所有字幕文件
request = RequestUtils(
cookies=torrent.site_cookie,
ua=torrent.site_ua,
proxies=settings.PROXY if torrent.site_proxy else None,
)
for sublink in sublink_list:
logger.info(f"找到字幕下载链接:{sublink},开始下载...")
# 下载
ret = request.get_res(sublink)
if ret and ret.status_code == 200:
# 保存ZIP
file_name = TorrentHelper.get_url_filename(ret, sublink)
if not file_name:
logger.warn(f"链接不是字幕文件:{sublink}")
continue
if file_name.lower().endswith(".zip"):
# ZIP包
zip_file = settings.TEMP_PATH / file_name
# 保存
zip_file.write_bytes(ret.content)
# 解压路径
zip_path = zip_file.with_name(zip_file.stem)
# 解压文件
shutil.unpack_archive(zip_file, zip_path, format='zip')
# 遍历转移文件
for sub_file in SystemUtils.list_files(zip_path, settings.RMT_SUBEXT):
target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)
if storageChain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storageChain.upload_file(working_dir_item, sub_file)
# 删除临时文件
try:
shutil.rmtree(zip_path)
zip_file.unlink()
except Exception as err:
logger.error(f"删除临时文件失败:{str(err)}")
else:
logger.error(f"下载字幕文件失败:{sublink}")
continue
if sublink_list:
logger.info(f"{torrent.page_url} 页面字幕下载完成")
sub_file = settings.TEMP_PATH / file_name
# 保存
sub_file.write_bytes(ret.content)
target_sub_file = Path(working_dir_item.path) / Path(sub_file.name)
if storageChain.get_file_item(storage, target_sub_file):
logger.info(f"字幕文件已存在:{target_sub_file}")
continue
logger.info(f"转移字幕 {sub_file}{target_sub_file} ...")
storageChain.upload_file(working_dir_item, sub_file)
else:
logger.warn(f"{torrent.page_url} 页面未找到字幕下载链接")
elif res is not None:
logger.warn(f"连接 {torrent.page_url} 失败,状态码:{res.status_code}")
else:
logger.warn(f"无法打开链接:{torrent.page_url}")
logger.error(f"下载字幕文件失败:{sublink}")
continue
logger.info(f"{torrent.page_url} 页面字幕下载完成")

View File

@@ -78,7 +78,7 @@ class TheMovieDbModule(_ModuleBase):
"""
测试模块连接性
"""
ret = RequestUtils(proxies=settings.PROXY).get_res(
ret = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=settings.PROXY).get_res(
f"https://{settings.TMDB_API_DOMAIN}/3/movie/550?api_key={settings.TMDB_API_KEY}")
if ret and ret.status_code == 200:
return True, ""
@@ -867,19 +867,19 @@ class TheMovieDbModule(_ModuleBase):
backdrops = images.get("backdrops")
if backdrops:
backdrops = sorted(backdrops, key=lambda x: x.get("vote_average"), reverse=True)
mediainfo.backdrop_path = backdrops[0].get("file_path")
mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(backdrops[0].get("file_path"))
# 标志
if not mediainfo.logo_path:
logos = images.get("logos")
if logos:
logos = sorted(logos, key=lambda x: x.get("vote_average"), reverse=True)
mediainfo.logo_path = logos[0].get("file_path")
mediainfo.logo_path = settings.TMDB_IMAGE_URL(logos[0].get("file_path"))
# 海报
if not mediainfo.poster_path:
posters = images.get("posters")
if posters:
posters = sorted(posters, key=lambda x: x.get("vote_average"), reverse=True)
mediainfo.poster_path = posters[0].get("file_path")
mediainfo.poster_path = settings.TMDB_IMAGE_URL(posters[0].get("file_path"))
return mediainfo
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
@@ -957,7 +957,7 @@ class TheMovieDbModule(_ModuleBase):
image_path = seasoninfo.get(image_type.value)
if image_path:
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/{image_prefix}{image_path}"
return settings.TMDB_IMAGE_URL(image_path, image_prefix)
return None
def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:

View File

@@ -85,10 +85,10 @@ class TmdbScraper:
seasoninfo = self.original_tmdb(mediainfo).get_tv_season_detail(mediainfo.tmdb_id, season)
if seasoninfo:
episodeinfo = self.__get_episode_detail(seasoninfo, episode)
if episodeinfo and episodeinfo.get("still_path"):
if still_path := episodeinfo.get("still_path"):
# TMDB集still图片
still_name = f"{episode}"
still_url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episodeinfo.get('still_path')}"
still_url = settings.TMDB_IMAGE_URL(still_path)
images[still_name] = still_url
else:
# 季的图片
@@ -115,7 +115,7 @@ class TmdbScraper:
if _mediainfo:
for attr_name, attr_value in _mediainfo.items():
if attr_name.endswith("_path") and attr_value is not None:
image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{attr_value}"
image_url = settings.TMDB_IMAGE_URL(attr_value)
image_name = attr_name.replace("_path", "") + Path(image_url).suffix
images[image_name] = image_url
return images
@@ -127,11 +127,11 @@ class TmdbScraper:
"""
# TMDB季poster图片
sea_seq = str(season).rjust(2, '0')
if seasoninfo.get("poster_path"):
if poster_path := seasoninfo.get("poster_path"):
# 后缀
ext = Path(seasoninfo.get('poster_path')).suffix
ext = Path(poster_path).suffix
# URL
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
url = settings.TMDB_IMAGE_URL(poster_path)
# S0海报格式不同
if season == 0:
image_name = f"season-specials-poster{ext}"
@@ -190,8 +190,8 @@ class TmdbScraper:
DomUtils.add_node(doc, xactor, "type", "Actor")
DomUtils.add_node(doc, xactor, "role", actor.get("character") or actor.get("role") or "")
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
DomUtils.add_node(doc, xactor, "thumb",
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{actor.get('profile_path')}")
if profile_path := actor.get('profile_path'):
DomUtils.add_node(doc, xactor, "thumb", settings.TMDB_IMAGE_URL(profile_path))
DomUtils.add_node(doc, xactor, "profile",
f"https://www.themoviedb.org/person/{actor.get('id')}")
# 风格
@@ -297,7 +297,8 @@ class TmdbScraper:
uniqueid.setAttribute("type", "tmdb")
uniqueid.setAttribute("default", "true")
# tmdbid
DomUtils.add_node(doc, root, "tmdbid", str(tmdbid))
# 应与uniqueid一致 使用剧集id 否则jellyfin/emby会将此id覆盖上面的uniqueid
DomUtils.add_node(doc, root, "tmdbid", str(episodeinfo.get("id")))
# 标题
DomUtils.add_node(doc, root, "title", episodeinfo.get("name") or "%s" % episode)
# 简介
@@ -330,8 +331,8 @@ class TmdbScraper:
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
DomUtils.add_node(doc, xactor, "type", "Actor")
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
DomUtils.add_node(doc, xactor, "thumb",
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{actor.get('profile_path')}")
if profile_path := actor.get('profile_path'):
DomUtils.add_node(doc, xactor, "thumb", settings.TMDB_IMAGE_URL(profile_path))
DomUtils.add_node(doc, xactor, "profile",
f"https://www.themoviedb.org/person/{actor.get('id')}")
return doc

View File

@@ -50,7 +50,7 @@ class TmdbCache(metaclass=WeakSingleton):
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}"
return f"[{meta.type.value if meta.type else '未知'}][{settings.TMDB_LOCALE}]{meta.tmdbid or meta.name}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""

View File

@@ -826,7 +826,7 @@ class TmdbApi:
# 转换多语种标题
self.__update_tmdbinfo_extra_title(tmdb_info)
# 转换中文标题
if settings.TMDB_LOCALE == "zh":
if self.tmdb.language in ("zh", "zh-CN"):
self.__update_tmdbinfo_cn_title(tmdb_info)
return tmdb_info
@@ -2134,7 +2134,7 @@ class TmdbApi:
# 转换多语种标题
self.__update_tmdbinfo_extra_title(tmdb_info)
# 转换中文标题
if settings.TMDB_LOCALE == "zh":
if self.tmdb.language in ("zh", "zh-CN"):
self.__update_tmdbinfo_cn_title(tmdb_info)
return tmdb_info

View File

@@ -125,12 +125,12 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
return None, None, None, "下载内容为空"
# 读取种子的名称
torrent, content = __get_torrent_info()
torrent_from_file, content = __get_torrent_info()
# 检查是否为磁力链接
is_magnet = isinstance(content, str) and content.startswith("magnet:") or isinstance(content,
bytes) and content.startswith(
b"magnet:")
if not torrent and not is_magnet:
if not torrent_from_file and not is_magnet:
return None, None, None, f"添加种子任务失败:无法读取种子文件"
# 获取下载器
@@ -149,7 +149,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
else:
labels = None
# 添加任务
torrent = server.add_torrent(
added_torrent = server.add_torrent(
content=content,
download_dir=self.normalize_path(download_dir, downloader),
is_paused=is_paused,
@@ -159,7 +159,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
# TR 始终使用原始种子布局, 返回"Original"
torrent_layout = "Original"
if not torrent:
if not added_torrent:
# 查询所有下载器的种子
torrents, error = server.get_torrents()
if error:
@@ -168,7 +168,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
try:
for torrent in torrents:
# 名称与大小相等则认为是同一个种子
if torrent.name == torrent.name and torrent.total_size == torrent.total_size:
if torrent.name == getattr(torrent_from_file, 'name', '') and torrent.total_size == getattr(torrent_from_file, 'total_size', 0):
torrent_hash = torrent.hashString
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.name}")
# 给种子打上标签
@@ -189,7 +189,7 @@ class TransmissionModule(_ModuleBase, _DownloaderBase[Transmission]):
del torrents
return None, None, None, f"添加种子任务失败:{content}"
else:
torrent_hash = torrent.hashString
torrent_hash = added_torrent.hashString
if is_paused:
# 选择文件
torrent_files = server.get_files(torrent_hash)

View File

@@ -80,7 +80,7 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
# 快照文件缓存
self._snapshot_cache = FileCache(base=settings.CACHE_PATH / "snapshots")
# 监控的文件扩展名
self.all_exts = settings.RMT_MEDIAEXT
self.all_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
# 启动目录监控和文件整理
self.init()
@@ -695,11 +695,13 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
# 全程加锁
with lock:
is_bluray_folder = False
# 蓝光原盘文件处理
if __is_bluray_sub(event_path):
event_path = __get_bluray_dir(event_path)
if not event_path:
return
is_bluray_folder = True
# TTL缓存控重
if self._cache.get(str(event_path)):
@@ -708,13 +710,20 @@ class Monitor(ConfigReloadMixin, metaclass=SingletonClass):
self._cache[str(event_path)] = True
try:
logger.info(f"开始整理文件: {event_path}")
if is_bluray_folder:
logger.info(f"开始整理蓝光原盘: {event_path}")
else:
logger.info(f"开始整理文件: {event_path}")
# 开始整理
TransferChain().do_transfer(
fileitem=FileItem(
storage=storage,
path=event_path.as_posix(),
type="file",
path=(
event_path.as_posix()
if not is_bluray_folder
else event_path.as_posix() + "/"
),
type="file" if not is_bluray_folder else "dir",
name=event_path.name,
basename=event_path.stem,
extension=event_path.suffix[1:],

View File

@@ -221,6 +221,22 @@ class ChannelCapabilityManager:
max_button_text_length=25,
fallback_enabled=True
),
MessageChannel.Discord: ChannelCapabilities(
channel=MessageChannel.Discord,
capabilities={
ChannelCapability.INLINE_BUTTONS,
ChannelCapability.MESSAGE_EDITING,
ChannelCapability.MESSAGE_DELETION,
ChannelCapability.CALLBACK_QUERIES,
ChannelCapability.RICH_TEXT,
ChannelCapability.IMAGES,
ChannelCapability.LINKS
},
max_buttons_per_row=5,
max_button_rows=5,
max_button_text_length=80,
fallback_enabled=True
),
MessageChannel.SynologyChat: ChannelCapabilities(
channel=MessageChannel.SynologyChat,
capabilities={

View File

@@ -21,7 +21,7 @@ class Token(BaseModel):
# 详细权限
permissions: Optional[dict] = Field(default_factory=dict)
# 是否显示配置向导
widzard: Optional[bool] = None
wizard: Optional[bool] = None
class TokenPayload(BaseModel):

View File

@@ -38,8 +38,10 @@ class EventType(Enum):
SiteUpdated = "site.updated"
# 站点已刷新
SiteRefreshed = "site.refreshed"
# 转移完成
# 整理完成
TransferComplete = "transfer.complete"
# 整理失败
TransferFailed = "transfer.failed"
# 下载已添加
DownloadAdded = "download.added"
# 删除历史记录
@@ -265,6 +267,7 @@ class MessageChannel(Enum):
Wechat = "微信"
Telegram = "Telegram"
Slack = "Slack"
Discord = "Discord"
SynologyChat = "SynologyChat"
VoceChat = "VoceChat"
Web = "Web"

View File

@@ -1,20 +1,17 @@
"""
AI智能体初始化器
"""
import asyncio
import threading
from typing import Optional
from app.agent import agent_manager
from app.core.config import settings
from app.log import logger
class AgentInitializer:
"""AI智能体初始化器"""
"""
AI智能体初始化器
"""
def __init__(self):
self.agent_manager = None
self._initialized = False
async def initialize(self) -> bool:
@@ -26,9 +23,6 @@ class AgentInitializer:
logger.info("AI智能体功能未启用")
return True
from app.agent import agent_manager
self.agent_manager = agent_manager
await agent_manager.initialize()
self._initialized = True
logger.info("AI智能体管理器初始化成功")
@@ -43,10 +37,10 @@ class AgentInitializer:
清理AI智能体管理器
"""
try:
if not self._initialized or not self.agent_manager:
if not self._initialized:
return
await self.agent_manager.close()
await agent_manager.close()
self._initialized = False
logger.info("AI智能体管理器已关闭")
@@ -78,8 +72,8 @@ def init_agent():
else:
logger.error("AI智能体管理器初始化失败")
return success
except Exception as e:
logger.error(f"AI智能体管理器初始化失败: {e}")
except Exception as err:
logger.error(f"AI智能体管理器初始化失败: {err}")
return False
finally:
loop.close()

View File

@@ -10,6 +10,7 @@ import requests
import urllib3
from requests import Response, Session
from urllib3.exceptions import InsecureRequestWarning
from urllib.parse import unquote, quote
from app.core.config import settings
from app.log import logger
@@ -17,6 +18,25 @@ from app.log import logger
urllib3.disable_warnings(InsecureRequestWarning)
def _url_decode_if_latin(original: str) -> str:
"""
解码URL编码的字符串只解码文本二进程数据保持不变
:param original: URL编码字符串
:return: 解码后的字符串或原始二进制数据
"""
try:
# 先解码
decoded = unquote(original, encoding='latin-1')
# 再完整编码
fully_encoded = quote(decoded, safe='')
# 验证
decoded_again = unquote(fully_encoded, encoding='latin-1')
if decoded_again == decoded:
return decoded
except Exception as e:
logger.error(f"latin-1解码URL编码失败{e}")
return original
def cookie_parse(cookies_str: str, array: bool = False) -> Union[list, dict]:
"""
解析cookie转化为字典或者数组
@@ -26,12 +46,14 @@ def cookie_parse(cookies_str: str, array: bool = False) -> Union[list, dict]:
"""
if not cookies_str:
return {}
cookie_dict = {}
cookies = cookies_str.split(";")
for cookie in cookies:
cstr = cookie.split("=")
cstr = cookie.split("=", 1) # 只分割第一个=因为value可能包含=
if len(cstr) > 1:
cookie_dict[cstr[0].strip()] = cstr[1].strip()
# URL解码Cookie值但保留Cookie名不解码
cookie_dict[cstr[0].strip()] = _url_decode_if_latin(cstr[1].strip())
if array:
return [{"name": k, "value": v} for k, v in cookie_dict.items()]
return cookie_dict
@@ -654,7 +676,8 @@ class AsyncRequestUtils:
proxy=self._proxies,
timeout=self._timeout,
verify=False,
follow_redirects=True
follow_redirects=True,
cookies=self._cookies # 在创建客户端时传入Cookie
) as client:
return await self._make_request(client, method, url, raise_exception, **kwargs)
else:
@@ -666,7 +689,8 @@ class AsyncRequestUtils:
执行实际的异步请求
"""
kwargs.setdefault("headers", self._headers)
kwargs.setdefault("cookies", self._cookies)
# Cookie已经在AsyncClient创建时设置不要在request时再设置否则会被覆盖
# kwargs.setdefault("cookies", self._cookies)
try:
return await client.request(method, url, **kwargs)

View File

@@ -242,6 +242,27 @@ class StringUtils:
else:
return size + "B"
@staticmethod
def format_size(size_bytes: int) -> str:
"""
将字节转换为人类可读格式
"""
if not size_bytes or size_bytes == 0:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB", "PB"]
size = float(size_bytes)
unit_index = 0
while size >= 1024 and unit_index < len(units) - 1:
size /= 1024
unit_index += 1
# 保留两位小数
if unit_index == 0:
return f"{int(size)} {units[unit_index]}"
return f"{size:.2f} {units[unit_index]}"
@staticmethod
def url_equal(url1: str, url2: str) -> bool:
"""

View File

@@ -479,6 +479,8 @@ class SystemUtils:
def is_bluray_dir(dir_path: Path) -> bool:
"""
判断是否为蓝光原盘目录
(该方法已弃用,改用`StorageChain().is_bluray_folder)`
"""
if not dir_path.is_dir():
return False

View File

@@ -65,7 +65,8 @@ class ScanFileAction(BaseAction):
for file in files:
if global_vars.is_workflow_stopped(workflow_id):
break
if not file.extension or f".{file.extension.lower()}" not in settings.RMT_MEDIAEXT:
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
if not file.extension or f".{file.extension.lower()}" not in media_exts:
continue
# 添加文件到队列,而不是目录
self._fileitems.append(file)

View File

@@ -0,0 +1,41 @@
"""2.2.2
Revision ID: 41ef1dd7467c
Revises: a946dae52526
Create Date: 2026-01-13 13:02:41.614029
"""
from app.db import ScopedSession
from app.db.models.systemconfig import SystemConfig
from app.log import logger
# revision identifiers, used by Alembic.
revision = "41ef1dd7467c"
down_revision = "a946dae52526"
branch_labels = None
depends_on = None
def upgrade() -> None:
# systemconfig表 去重
with ScopedSession() as db:
try:
seen_keys = set()
# 按ID降序查询以便保留最新的配置
for item in db.query(SystemConfig).order_by(SystemConfig.id.desc()).all():
if item.key in seen_keys:
logger.warn(
f"已删除重复的SystemConfig项{item.key} 值:{item.value}"
)
db.delete(item)
else:
seen_keys.add(item.key)
db.commit()
except Exception as e:
logger.error(e)
db.rollback()
def downgrade() -> None:
pass

View File

@@ -0,0 +1,30 @@
"""2.2.3
添加 downloadhistory.custom_words 字段,用于整理时应用订阅识别词
Revision ID: 58edfac72c32
Revises: 41ef1dd7467c
Create Date: 2026-01-19
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "58edfac72c32"
down_revision = "41ef1dd7467c"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# 检查并添加 downloadhistory.custom_words
dh_columns = inspector.get_columns('downloadhistory')
if not any(c['name'] == 'custom_words' for c in dh_columns):
op.add_column('downloadhistory', sa.Column('custom_words', sa.String, nullable=True))
def downgrade() -> None:
# 降级时删除字段
op.drop_column('downloadhistory', 'custom_words')

View File

@@ -94,6 +94,7 @@ COPY --from=prepare_venv --chmod=777 ${VENV_PATH} ${VENV_PATH}
# playwright 环境
RUN playwright install-deps chromium \
&& playwright install-deps firefox \
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf \

View File

@@ -231,9 +231,9 @@ chown moviepilot:moviepilot /etc/hosts /tmp
# 下载浏览器内核
if [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$HTTPS_PROXY" =~ ^https?:// ]] || [[ "$PROXY_HOST" =~ ^https?:// ]]; then
HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}" gosu moviepilot:moviepilot playwright install chromium
HTTPS_PROXY="${HTTPS_PROXY:-${https_proxy:-$PROXY_HOST}}" gosu moviepilot:moviepilot playwright install ${PLAYWRIGHT_BROWSER_TYPE:-chromium}
else
gosu moviepilot:moviepilot playwright install chromium
gosu moviepilot:moviepilot playwright install ${PLAYWRIGHT_BROWSER_TYPE:-chromium}
fi
# 证书管理

View File

@@ -1,9 +1,77 @@
# MoviePilot 工具API文档
# MoviePilot MCP (Model Context Protocol) API 文档
MoviePilot的智能体工具已通过HTTP API暴露可以通过RESTful API调用所有工具
MoviePilot 实现了标准的 **Model Context Protocol (MCP)**,允许 AI 智能体(如 Claude, GPT 等)直接调用 MoviePilot 的功能进行媒体管理、搜索、订阅和下载
## API端点
## 1. 基础信息
* **基础路径**: `/api/v1/mcp`
* **协议版本**: `2025-11-25, 2025-06-18, 2024-11-05`
* **传输协议**: HTTP (JSON-RPC 2.0)
* **认证方式**:
* Header: `X-API-KEY: <你的API_KEY>`
* Query: `?apikey=<你的API_KEY>`
## 2. 标准 MCP 协议 (JSON-RPC 2.0)
### 端点
**POST** `/api/v1/mcp`
### 支持的方法
- `initialize`: 初始化会话,协商协议版本和能力。
- `notifications/initialized`: 客户端确认初始化完成。
- `tools/list`: 获取可用工具列表。
- `tools/call`: 调用特定工具。
- `ping`: 连接存活检测。
---
## 4. 客户端配置示例
### Claude Desktop (Anthropic)
在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
使用请求头方式:
```json
{
"mcpServers": {
"moviepilot": {
"url": "http://localhost:3001/api/v1/mcp",
"headers": {
"X-API-KEY": "your_api_key_here"
}
}
}
}
```
或使用查询参数方式:
```json
{
"mcpServers": {
"moviepilot": {
"url": "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here"
}
}
}
```
## 5. 错误码说明
| 错误码 | 消息 | 说明 |
| :--- | :--- | :--- |
| -32700 | Parse error | JSON 格式错误 |
| -32600 | Invalid Request | 无效的 JSON-RPC 请求 |
| -32601 | Method not found | 方法不存在 |
| -32602 | Invalid params | 参数验证失败 |
| -32002 | Session not found | 会话不存在或已过期 |
| -32003 | Not initialized | 会话未完成初始化流程 |
| -32603 | Internal error | 服务器内部错误 |
## 6. RESTful API
所有工具相关的API端点都在 `/api/v1/mcp` 路径下(保持向后兼容)。
### 1. 列出所有工具
@@ -137,142 +205,3 @@ MoviePilot的智能体工具已通过HTTP API暴露可以通过RESTful API调
"required": ["title", "year", "media_type"]
}
```
## MCP客户端配置
MoviePilot的MCP工具可以通过HTTP协议在支持MCP的客户端中使用。以下是常见MCP客户端的配置方法
### Claude Desktop (Anthropic)
在Claude Desktop的配置文件中添加MoviePilot的MCP服务器配置
**macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
**Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
```json
{
"mcpServers": {
"moviepilot": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-http",
"http://localhost:3001/api/v1/mcp"
],
"env": {
"X-API-KEY": "your_api_key_here"
}
}
}
}
```
**注意**: 如果MCP HTTP服务器不支持环境变量传递API Key可以使用查询参数方式
```json
{
"mcpServers": {
"moviepilot": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-http",
"http://localhost:3001/api/v1/mcp?apikey=your_api_key_here"
]
}
}
}
```
### 其他支持MCP的聊天客户端
对于其他支持MCP协议的聊天客户端如其他AI聊天助手、对话机器人等通常可以通过配置文件或设置界面添加HTTP协议的MCP服务器。配置格式可能因客户端而异但通常需要以下信息
**配置参数**
1. **服务器类型**: HTTP
2. **服务器地址**: `http://your-moviepilot-host:3001/api/v1/mcp`
3. **认证方式**:
- 在HTTP请求头中添加 `X-API-KEY: <your_api_key>`
- 或在URL查询参数中添加 `apikey=<your_api_key>`
**示例配置**(通用格式):
使用请求头方式:
```json
{
"mcpServers": {
"moviepilot": {
"url": "http://localhost:3001/api/v1/mcp",
"headers": {
"X-API-KEY": "your_api_key_here"
}
}
}
}
```
或使用查询参数方式:
```json
{
"mcpServers": {
"moviepilot": {
"url": "http://localhost:3001/api/v1/mcp?apikey=your_api_key_here"
}
}
}
```
**支持的端点**:
- `GET /tools` - 列出所有工具
- `POST /tools/call` - 调用工具
- `GET /tools/{tool_name}` - 获取工具详情
- `GET /tools/{tool_name}/schema` - 获取工具参数Schema
配置完成后您就可以在聊天对话中使用MoviePilot的各种工具例如
- 添加媒体订阅
- 查询下载历史
- 搜索媒体资源
- 管理媒体服务器
- 等等...
### 获取API Key
API Key可以在MoviePilot的系统设置中生成和查看。请妥善保管您的API Key不要泄露给他人。
## 认证
所有MCP API端点都需要认证。**仅支持API Key认证方式**
- **请求头方式**: 在请求头中添加 `X-API-KEY: <api_key>`
- **查询参数方式**: 在URL查询参数中添加 `apikey=<api_key>`
**获取API Key**: 在MoviePilot系统设置中生成和查看API Key。请妥善保管您的API Key不要泄露给他人。
## 错误处理
API会返回标准的HTTP状态码
- `200 OK`: 请求成功
- `400 Bad Request`: 请求参数错误
- `401 Unauthorized`: 未认证或API Key无效
- `404 Not Found`: 工具不存在
- `500 Internal Server Error`: 服务器内部错误
错误响应格式:
```json
{
"detail": "错误描述信息"
}
```
## 架构说明
工具API通过FastAPI端点暴露使用HTTP协议与客户端通信。所有工具共享相同的实现确保功能一致性。
## 注意事项
1. **用户上下文**: API调用会使用当前认证用户的ID作为工具执行的用户上下文
2. **会话隔离**: 每个API请求使用独立的会话ID
3. **参数验证**: 工具参数会根据JSON Schema进行验证
4. **错误日志**: 所有工具调用错误都会记录到MoviePilot日志系统

View File

@@ -43,6 +43,7 @@ cf_clearance~=0.31.0
torrentool~=1.2.0
slack-bolt~=1.23.0
slack-sdk~=3.35.0
discord.py==2.6.4
chardet~=5.2.0
starlette~=0.46.2
PyVirtualDisplay~=3.0
@@ -61,6 +62,7 @@ cachetools~=6.1.0
fast-bencode~=1.1.7
pystray~=0.19.5
pyotp~=2.9.0
webauthn~=2.7.0
Pinyin2Hanzi~=0.1.1
pywebpush~=2.0.3
aiopathlib~=0.6.0
@@ -88,4 +90,4 @@ langchain-google-genai~=2.0.10
langchain-deepseek~=0.1.4
langchain-experimental~=0.3.4
openai~=1.108.2
google-generativeai~=0.8.5
google-generativeai~=0.8.5

View File

@@ -1,161 +1,100 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
# 文件列表结构 list[tuple(名称, 子文件列表 或 文件大小)]
bluray_files = [
{
"name": "FOLDER",
"children": [
{
"name": "Digimon",
"children": [
{
"name": "Digimon (2055)",
"children": [
{
"name": "BDMV",
"children": [
{
"name": "STREAM",
"children": [
{
"name": "00000.m2ts",
"size": 104857600,
},
{
"name": "00001.m2ts",
"size": 104857600,
},
(
"FOLDER",
[
(
"Digimon",
[
(
"Digimon BluRay (2055)",
[
(
"BDMV",
[
(
"STREAM",
[
("00000.m2ts", 104857600),
("00001.m2ts", 104857600),
],
},
),
],
},
{
"name": "CERTIFICATE",
"children": [],
},
),
("CERTIFICATE", None),
],
},
{
"name": "Digimon (2099)",
"children": [
{
"name": "BDMV",
"children": [
{
"name": "STREAM",
"children": [
{
"name": "00000.m2ts",
"size": 104857600,
},
{
"name": "00001.m2ts",
"size": 104857600,
},
{
"name": "00002.m2ts.!qB",
"size": 104857600,
},
),
(
"Digimon BluRay (2099)",
[
(
"BDMV",
[
(
"STREAM",
[
("00000.m2ts", 104857600),
("00001.m2ts", 104857600),
("00002.m2ts.!qB", 104857600),
],
},
),
],
},
{
"name": "CERTIFICATE",
"children": [],
},
),
("CERTIFICATE", None),
],
},
{
"name": "Digimon (2199)",
"children": [
{
"name": "Digimon.2199.mp4",
"size": 104857600,
},
],
},
),
("Digimon (2199)", [("Digimon.2199.mp4", 104857600)]),
],
},
{
"name": "Pokemon (2016)",
"children": [
{
"name": "BDMV",
"children": [
{
"name": "STREAM",
"children": [
{
"name": "00000.m2ts",
"size": 104857600,
},
{
"name": "00001.m2ts",
"size": 104857600,
},
),
(
"Pokemon BluRay (2016)",
[
(
"BDMV",
[
(
"STREAM",
[
("00000.m2ts", 104857600),
("00001.m2ts", 104857600),
],
},
)
],
},
{
"name": "CERTIFICATE",
"children": [],
},
),
("CERTIFICATE", None),
],
},
{
"name": "Pokemon (2021)",
"children": [
{
"name": "BDMV",
"children": [
{
"name": "STREAM",
"children": [
{
"name": "00000.m2ts",
"size": 104857600,
},
{
"name": "00001.m2ts",
"size": 104857600,
},
),
(
"Pokemon BluRay (2021)",
[
(
"BDMV",
[
(
"STREAM",
[
("00000.m2ts", 104857600),
("00001.m2ts", 104857600),
],
},
)
],
},
{
"name": "CERTIFICATE",
"children": [],
},
),
("CERTIFICATE", None),
],
},
{
"name": "Pokemon (2028)",
"children": [
{
"name": "Pokemon.2028.mkv",
"size": 104857600,
},
{
"name": "Pokemon.2028.hdr.mkv.!qB",
"size": 104857600,
},
),
(
"Pokemon (2028)",
[
("Pokemon.2028.mkv", 104857600),
("Pokemon.2028.hdr.mkv.!qB", 104857600),
],
},
{
"name": "Pokemon.2029.mp4",
"size": 104857600,
},
{
"name": "Pokemon (2030)",
"children": [
{
"name": "S",
"size": 104857600,
},
],
},
),
("Pokemon.2029.mp4", 104857600),
("Pokemon.2039.mp4", 104857600),
("Pokemon (2030)", [("S", 104857600)]),
("Pokemon (2031)", [("Pokemon (2031).mp4", 104857600)]),
],
},
)
]

View File

@@ -1117,4 +1117,19 @@ meta_cases = [{
"audio_codec": "",
"tmdbid": 19995
}
}, {
"path": "/movies/DouBan_IMDB.TOP250.Movies.Mixed.Collection.20240501.FRDS/为奴十二年.12.Years.a.Slave.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS/12.Years.a.Slave.2013.BluRay.1080p.x265.10bit.2Audio.MNHD-FRDS.mkv",
"target": {
"type": "未知",
"cn_name": "",
"en_name": "12 Years A Slave",
"year": "2013",
"part": "",
"season": "",
"episode": "",
"restype": "BluRay",
"pix": "1080p",
"video_codec": "x265 10bit",
"audio_codec": "2Audio"
}
}]

View File

@@ -1,5 +1,6 @@
import unittest
from tests.test_bluray import BluRayTest
from tests.test_metainfo import MetaInfoTest
from tests.test_object import ObjectUtilsTest
@@ -12,6 +13,15 @@ if __name__ == '__main__':
suite.addTest(MetaInfoTest('test_emby_format_ids'))
suite.addTest(ObjectUtilsTest('test_check_method'))
# 测试自定义识别词功能
suite.addTest(MetaInfoTest('test_metainfopath_with_custom_words'))
suite.addTest(MetaInfoTest('test_metainfopath_without_custom_words'))
suite.addTest(MetaInfoTest('test_metainfopath_with_empty_custom_words'))
suite.addTest(MetaInfoTest('test_custom_words_apply_words_recording'))
# 测试蓝光目录识别
suite.addTest(BluRayTest())
# 运行测试
runner = unittest.TextTestRunner()
runner.run(suite)

227
tests/test_bluray.py Normal file
View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-
from pathlib import Path
from typing import Optional
from unittest import TestCase
from unittest.mock import patch
from app import schemas
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.context import MediaInfo
from app.core.event import Event
from app.core.metainfo import MetaInfoPath
from app.db.models.transferhistory import TransferHistory
from app.log import logger
from app.schemas.types import EventType
from tests.cases.files import bluray_files
class BluRayTest(TestCase):
def __init__(self, methodName="test"):
super().__init__(methodName)
self.__history = []
self.__root = schemas.FileItem(
path="/", name="", type="dir", extension="", size=0
)
self.__all = {self.__root.path: self.__root}
def __build_child(parent: schemas.FileItem, files: list[tuple[str, list | int]]):
parent.children = []
for name, children in files:
sep = "" if parent.path.endswith("/") else "/"
file_item = schemas.FileItem(
path=f"{parent.path}{sep}{name}",
name=name,
extension=Path(name).suffix[1:],
basename=Path(name).stem,
type="file" if isinstance(children, int) else "dir",
size=children if isinstance(children, int) else 0,
)
parent.children.append(file_item)
self.__all[file_item.path] = file_item
if isinstance(children, list):
__build_child(file_item, children)
__build_child(self.__root, bluray_files)
def _test_do_transfer(self):
def __test_do_transfer(path: str):
self.__history.clear()
TransferChain().do_transfer(
force=False,
background=False,
fileitem=StorageChain().get_file_item(None, Path(path)),
)
return self.__history
self.assertEqual(
[
"/FOLDER/Digimon/Digimon BluRay (2055)",
"/FOLDER/Digimon/Digimon BluRay (2099)",
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
],
__test_do_transfer("/FOLDER/Digimon"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon BluRay (2055)",
],
__test_do_transfer("/FOLDER/Digimon/Digimon BluRay (2055)"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon BluRay (2055)",
],
__test_do_transfer("/FOLDER/Digimon/Digimon BluRay (2055)/BDMV"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon BluRay (2055)",
],
__test_do_transfer("/FOLDER/Digimon/Digimon BluRay (2055)/BDMV/STREAM"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon BluRay (2055)",
],
__test_do_transfer(
"/FOLDER/Digimon/Digimon BluRay (2055)/BDMV/STREAM/00001.m2ts"
),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
],
__test_do_transfer("/FOLDER/Digimon/Digimon (2199)"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
],
__test_do_transfer("/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4"),
)
self.assertEqual(
[
"/FOLDER/Pokemon.2029.mp4",
],
__test_do_transfer("/FOLDER/Pokemon.2029.mp4"),
)
self.assertEqual(
[
"/FOLDER/Digimon/Digimon BluRay (2055)",
"/FOLDER/Digimon/Digimon BluRay (2099)",
"/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4",
"/FOLDER/Pokemon BluRay (2016)",
"/FOLDER/Pokemon BluRay (2021)",
"/FOLDER/Pokemon (2028)/Pokemon.2028.mkv",
"/FOLDER/Pokemon.2029.mp4",
"/FOLDER/Pokemon.2039.mp4",
"/FOLDER/Pokemon (2031)/Pokemon (2031).mp4",
],
__test_do_transfer("/"),
)
def _test_scrape_metadata(self, mock_metadata_nfo):
def __test_scrape_metadata(path: str, excepted_nfo_count: int = 1):
"""
分别测试手动和自动刮削
"""
fileitem = StorageChain().get_file_item(None, Path(path))
meta = MetaInfoPath(Path(fileitem.path))
mediainfo = MediaInfo(tmdb_info={"id": 1, "title": "Test"})
# 测试手动刮削
logger.debug(f"测试手动刮削 {path}")
mock_metadata_nfo.call_count = 0
MediaChain().scrape_metadata(
fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True
)
# 确保调用了指定次数的metadata_nfo
self.assertEqual(mock_metadata_nfo.call_count, excepted_nfo_count)
# 测试自动刮削
logger.debug(f"测试自动刮削 {path}")
mock_metadata_nfo.call_count = 0
MediaChain().scrape_metadata_event(
Event(
event_type=EventType.MetadataScrape,
event_data={
"meta": meta,
"mediainfo": mediainfo,
"fileitem": fileitem,
"file_list": [fileitem.path],
"overwrite": False,
},
)
)
# 调用了指定次数的metadata_nfo
self.assertEqual(mock_metadata_nfo.call_count, excepted_nfo_count)
# 刮削原盘目录
__test_scrape_metadata("/FOLDER/Digimon/Digimon BluRay (2099)")
# 刮削电影文件
__test_scrape_metadata("/FOLDER/Digimon/Digimon (2199)/Digimon.2199.mp4")
# 刮削电影目录
__test_scrape_metadata("/FOLDER", excepted_nfo_count=2)
@patch("app.chain.ChainBase.metadata_img", return_value=None) # 避免获取图片
@patch("app.chain.ChainBase.__init__", return_value=None) # 避免不必要的模块初始化
@patch("app.db.transferhistory_oper.TransferHistoryOper.get_by_src")
@patch("app.chain.storage.StorageChain.list_files")
@patch("app.chain.storage.StorageChain.get_parent_item")
@patch("app.chain.storage.StorageChain.get_file_item")
def test(
self,
mock_get_file_item,
mock_get_parent_item,
mock_list_files,
mock_get_by_src,
*_,
):
def get_file_item(storage: str, path: Path):
path_posix = path.as_posix()
return self.__all.get(path_posix)
def get_parent_item(fileitem: schemas.FileItem):
return get_file_item(None, Path(fileitem.path).parent)
def list_files(fileitem: schemas.FileItem, recursion: bool = False):
if fileitem.type != "dir":
return None
if recursion:
result = []
file_path = f"{fileitem.path}/"
for path, item in self.__all.items():
if path.startswith(file_path):
result.append(item)
return result
else:
return fileitem.children
def get_by_src(src: str, storage: Optional[str] = None):
self.__history.append(src)
result = TransferHistory()
result.status = True
return result
mock_get_file_item.side_effect = get_file_item
mock_get_parent_item.side_effect = get_parent_item
mock_list_files.side_effect = list_files
mock_get_by_src.side_effect = get_by_src
self._test_do_transfer()
with patch(
"app.chain.media.MediaChain.metadata_nfo", return_value=None
) as mock:
self._test_scrape_metadata(mock_metadata_nfo=mock)

View File

@@ -61,3 +61,38 @@ class MetaInfoTest(TestCase):
meta = MetaInfoPath(Path(path_str))
self.assertEqual(meta.tmdbid, expected_tmdbid,
f"路径 {path_str} 期望的tmdbid为 {expected_tmdbid},实际识别为 {meta.tmdbid}")
def test_metainfopath_with_custom_words(self):
"""测试 MetaInfoPath 使用自定义识别词"""
# 测试替换词:将"测试替换"替换为空
custom_words = ["测试替换 => "]
path = Path("/movies/电影测试替换名称 (2024)/movie.mkv")
meta = MetaInfoPath(path, custom_words=custom_words)
# 验证替换生效cn_name 不应包含"测试替换"
if meta.cn_name:
self.assertNotIn("测试替换", meta.cn_name)
def test_metainfopath_without_custom_words(self):
"""测试 MetaInfoPath 不传入自定义识别词"""
path = Path("/movies/Normal Movie (2024)/movie.mkv")
meta = MetaInfoPath(path)
# 验证正常识别,不报错
self.assertIsNotNone(meta)
def test_metainfopath_with_empty_custom_words(self):
"""测试 MetaInfoPath 传入空的自定义识别词"""
path = Path("/movies/Test Movie (2024)/movie.mkv")
meta = MetaInfoPath(path, custom_words=[])
# 验证不报错,正常识别
self.assertIsNotNone(meta)
def test_custom_words_apply_words_recording(self):
"""测试 apply_words 记录功能"""
custom_words = ["替换词 => 新词"]
title = "电影替换词.2024.mkv"
meta = MetaInfo(title=title, custom_words=custom_words)
# 验证 apply_words 属性存在
self.assertTrue(hasattr(meta, 'apply_words'))
# 如果替换词被应用,应该记录在 apply_words 中
if meta.apply_words:
self.assertIn("替换词 => 新词", meta.apply_words)

View File

@@ -1,2 +1,2 @@
APP_VERSION = 'v2.8.8'
FRONTEND_VERSION = 'v2.8.8'
APP_VERSION = 'v2.9.5-1'
FRONTEND_VERSION = 'v2.9.5'