Compare commits

...

193 Commits

Author SHA1 Message Date
jxxghp
e4a67ea052 - 修复了健康检查themoviedb、thetvdb时未使用内置代理的问题
- 修复了VoceChat部分场景下消息发送失败的问题
- 修复了VoceChat响应干扰了微信回调的问题
- 提升了VoceChat的安全性,机器人Webhook需要参考说明重新设置

注意:VoceChat机器人Webhook回调地址相对路径调整为:`/api/v1/message/?token=moviepilot`,其中`moviepilot`为环境变量中设置的`API_TOKEN`
2024-03-07 18:22:17 +08:00
jxxghp
a4df2f5213 fix wechat bug 2024-03-07 18:15:04 +08:00
jxxghp
4f89780a0f Merge remote-tracking branch 'origin/main' 2024-03-07 18:01:57 +08:00
jxxghp
26d6201b30 fix wechat bug 2024-03-07 18:01:50 +08:00
jxxghp
c9a9ff2692 Merge pull request #1613 from WangEdward/main 2024-03-07 17:48:31 +08:00
Edward
0be49953b4 fix: change vote to float 2024-03-07 09:45:14 +00:00
jxxghp
0de952f090 fix 2024-03-07 17:15:04 +08:00
jxxghp
2b570bf48f fix:提升VoceChat安全性 2024-03-07 17:07:28 +08:00
jxxghp
9476017af5 Merge remote-tracking branch 'origin/main' 2024-03-07 12:43:05 +08:00
jxxghp
54f808485e fix #1608 2024-03-07 12:42:59 +08:00
jxxghp
fa5c82899b Merge pull request #1605 from HankunYu/main
Update 中文字幕过滤
2024-03-07 12:33:06 +08:00
HankunYu
4a57071809 Update 中字过滤规则
添加匹配小写
2024-03-07 02:48:29 +00:00
HankunYu
4631db9a45 Update 中字过滤规则
去除重复 简体, 严格CHT以及CHS匹配规则
2024-03-07 02:43:59 +00:00
jxxghp
0f09da55b0 Merge pull request #1606 from thsrite/main 2024-03-07 09:22:21 +08:00
thsrite
b14b41c2c1 fix 系统健康检查tmdb、tvdb走代理 2024-03-07 09:20:20 +08:00
jxxghp
cf05ae20c5 v1.7.0
- 捷径中增加了系统健康检查功能,可快速检测各模块连接状态、目录是否跨盘等
- 网络测试增加了`fanart`和`thetvdb`测试项
- 新增`短剧自动分类`插件,可根据视频文件时长将短剧整理到独立分类目录
- 通知消息新增支持`VoceChat`,可实现消息交互以及群组通知功能,需自行搭建服务端
- 修复了订阅集数修改后会被覆盖的问题,订阅默认规则增加了做种数设定

VoceChat搭建及配置参考:https://doc.voce.chat/zh-cn/bot/bot-and-webhook ,Webhook回调地址相对路径为:/api/v1/message/
2024-03-07 08:10:52 +08:00
HankunYu
897758d829 Merge remote-tracking branch 'upstream/main' 2024-03-06 19:54:39 +00:00
jxxghp
85a77a66dd fix 2024-03-06 22:26:36 +08:00
HankunYu
c450dfc0fa Update 中文字幕过滤
添加对于动画番剧中文字幕识别的支持
2024-03-06 14:09:19 +00:00
jxxghp
3d782a7475 feat:目录检测 2024-03-06 21:42:21 +08:00
jxxghp
4734851213 Merge pull request #1587 from WangEdward/main 2024-03-06 20:11:10 +08:00
jxxghp
9c8635002d Merge pull request #1603 from thsrite/main 2024-03-06 19:50:33 +08:00
thsrite
4cd3cb2b60 fix 2024-03-06 19:46:09 +08:00
thsrite
fa890ca29c fix 手动修改过订阅总集数后,不再随tmdb变化 2024-03-06 19:33:31 +08:00
jxxghp
bbf1ec4c50 fix bug 2024-03-06 17:19:32 +08:00
jxxghp
523d458489 fix bug 2024-03-06 17:02:07 +08:00
jxxghp
45ec668875 add log 2024-03-06 16:47:04 +08:00
jxxghp
60122644b8 fix 2024-03-06 16:20:39 +08:00
jxxghp
07a77e0001 fix 2024-03-06 16:04:04 +08:00
jxxghp
d112f49a69 feat:支持VoceChat 2024-03-06 15:54:40 +08:00
jxxghp
8cb061ff75 feat:模块健康检查 2024-03-06 13:23:51 +08:00
jxxghp
01e08c8e69 Merge pull request #1595 from richard-guan-dev/main
fix: Add a validation check to see if "assisted verification users" are active when logging in.
2024-03-06 10:44:44 +08:00
Richard Guan
3549b38ee8 Add validation for whether the assistive user is activated. 2024-03-06 10:38:20 +08:00
jxxghp
f5fb888c85 fix #1594 2024-03-06 08:19:08 +08:00
Edward
8bcb6a7cb6 chore: merge nested if 2024-03-05 09:35:34 +00:00
Edward
ac81dd943c feat: add min_seeders in filter_rule 2024-03-05 09:25:23 +00:00
jxxghp
663d282b5e fix category.yaml 2024-03-05 15:54:13 +08:00
jxxghp
c7b389dd9b v1.6.9
- 索引站点支持`青蛙`
- 优化了种子索引匹配逻辑,减少`类型不匹配`的错误
- 修复了订阅默认过滤规则不生效的问题
2024-03-04 20:29:15 +08:00
jxxghp
bad37a1846 Merge pull request #1572 from WangEdward/main 2024-03-01 23:01:54 +08:00
jxxghp
9c09981583 Merge pull request #1571 from sohunjug/main 2024-03-01 22:47:46 +08:00
Edward
2d8e66cbe2 fix: site scope error 2024-03-01 14:40:50 +00:00
sohunjug
db28986d22 fix: softlink exists 2024-03-01 22:14:07 +08:00
Edward
727bed46b7 fix: empty return from get_subscribed_sites 2024-03-01 14:06:58 +00:00
jxxghp
8e0df90177 Merge pull request #1570 from DDS-Derek/main 2024-03-01 21:55:32 +08:00
DDSRem
34bbb86c16 fix: plugin directory backup failed 2024-03-01 21:40:46 +08:00
jxxghp
0403f1f48c fix logging 2024-03-01 13:10:19 +08:00
jxxghp
1db452e268 Merge pull request #1568 from cikezhu/main
fix time_difference()
2024-02-29 21:49:20 +08:00
叮叮当
81ca11650d fix time_difference() 2024-02-29 21:39:32 +08:00
jxxghp
2e4671fdbc Merge remote-tracking branch 'origin/main' 2024-02-29 17:28:42 +08:00
jxxghp
da80ad33d9 fix bug 2024-02-29 17:28:36 +08:00
jxxghp
a6f28569ab Merge pull request #1567 from sundxfansky/main 2024-02-29 17:20:45 +08:00
jxxghp
5dd36e95e0 feat:使用站点种子归类优化识别匹配
fix:优先级规则复杂时,过滤时间很长,调整到最后
fix #1432
2024-02-29 17:18:01 +08:00
sundxfansky
1eaeea62db rm line 2024-02-29 16:58:18 +08:00
sundxfansky
4282c5dfc2 fix update failed 2024-02-29 16:56:33 +08:00
jxxghp
2e661f8759 Merge pull request #1566 from sundxfansky/main 2024-02-29 06:48:15 +08:00
tonser
31ca41828e 修复tr 选中种子下载失败 2024-02-29 02:09:53 +08:00
jxxghp
c9ebe76eb1 - 修复v1.6.8订阅失效问题 2024-02-28 23:19:10 +08:00
jxxghp
71ac12ab7a 更新 scheduler.py 2024-02-28 23:03:31 +08:00
jxxghp
81c0e15a1c 更新 version.py 2024-02-28 22:19:12 +08:00
jxxghp
2bde4923f9 更新 subscribe.py 2024-02-28 21:57:35 +08:00
jxxghp
22fb6305cf 更新 subscribe.py 2024-02-28 21:50:32 +08:00
jxxghp
4bb5772e10 fix #1545
fix #1537
2024-02-28 15:03:55 +08:00
jxxghp
549658e871 fix #1553
fix #1496
2024-02-28 14:18:23 +08:00
jxxghp
80f47594f4 v1.6.8
- `🌈岛`支持多域名及HR检测,支持`麒麟`的短剧搜索
- 修复了做种数大于1000时识别为0的问题
- 官方插件库的所有插件均可在`设定-服务`中查看和启动后台任务(需要更新到最新插件版本,第三方插件需要开发者适配)
- 新增`共享识别词`插件(感谢@honuee),搭建了识别词共享服务,欢迎大家共同维护使用

1. 插件开发说明:https://github.com/jxxghp/MoviePilot-Plugins/blob/main/README.md
2. 识别词共享地址:
- https://movie-pilot.org/etherpad/p/MoviePilot_TV_Words
- https://movie-pilot.org/etherpad/p/MoviePilot_Anime_Words
2024-02-26 19:33:00 +08:00
jxxghp
2614eeadb0 fix #1544 2024-02-26 10:50:33 +08:00
jxxghp
a0af827319 Merge pull request #1541 from WangEdward/main 2024-02-25 23:48:08 +08:00
jxxghp
0233853794 fix bug 2024-02-25 17:16:46 +08:00
jxxghp
6b24ccdc35 fix bug 2024-02-25 16:43:21 +08:00
jxxghp
7d76ee2e65 fix warning 2024-02-25 09:11:47 +08:00
jxxghp
1dd9228d01 fix warning 2024-02-25 09:03:43 +08:00
Edward
a5b4221a00 fix: db migration for search_imdbid 2024-02-24 09:36:04 +00:00
jxxghp
37ba75b53c Merge pull request #1535 from cikezhu/main 2024-02-24 06:46:43 +08:00
WangEdward
b8553e2b86 feat: add search_imdbid in subscribe api 2024-02-24 00:14:44 +08:00
WangEdward
d28f3ed74b feat: add search_imdbid for subscribe 2024-02-23 23:55:32 +08:00
叮叮当
185c78b05c 定时作业添加提供者条目 2024-02-23 22:25:41 +08:00
叮叮当
f23cab861a fix 获取其他待执行任务>status 2024-02-23 21:39:58 +08:00
叮叮当
bbddec763a 插件直接采用程序的定时任务模块, 可显示在前端页面 2024-02-23 18:13:27 +08:00
jxxghp
06c3985aa4 v1.6.7
- 加快了插件页面的展现速度
- 插件的日志独立保存和查看
- 站点索引新增支持`萝莉`,支持`AGSVPT`的短剧搜索,修复了`象站`的索引问题
2024-02-23 10:59:20 +08:00
jxxghp
9503a603e6 fix 2024-02-23 08:23:56 +08:00
jxxghp
6e9ab24d95 Merge pull request #1531 from cikezhu/main 2024-02-23 07:59:40 +08:00
叮叮当
7524379af6 增加条件减少循环次数 2024-02-23 01:15:11 +08:00
叮叮当
eebf3dec68 fix 日志恢复成文件名方式/插件调用内置模块的日志将会显示在插件日志 2024-02-22 23:54:25 +08:00
jxxghp
a89dd636a4 fix #1528 2024-02-22 17:59:44 +08:00
jxxghp
7fb025bff4 Merge pull request #1529 from honue/main 2024-02-22 17:34:55 +08:00
honue
c44c0f6321 自定义识别词#注释 认为为注释 2024-02-22 17:22:40 +08:00
jxxghp
585bcb924f Merge pull request #1526 from WangEdward/main 2024-02-22 15:49:50 +08:00
jxxghp
0ce3c3d90f Merge pull request #1522 from honue/main 2024-02-22 15:48:32 +08:00
Edward
9cb69f4879 fix: discuz is_logged_in 2024-02-22 15:34:48 +08:00
honue
c5b13f2fee fix #1502 2024-02-22 14:06:56 +08:00
jxxghp
235af9e558 fix bug 2024-02-22 13:31:49 +08:00
jxxghp
cb274d1587 feat:插件日志文件独立 2024-02-22 13:23:23 +08:00
jxxghp
63643e6d26 feat:插件API支持类型过滤 2024-02-21 16:11:15 +08:00
jxxghp
0726600936 feat:插件API支持类型过滤 2024-02-21 16:05:24 +08:00
jxxghp
6151bd64dd Merge remote-tracking branch 'origin/main' 2024-02-21 16:00:00 +08:00
jxxghp
32dc0f69f9 feat:插件API支持类型过滤 2024-02-21 15:59:53 +08:00
jxxghp
5b563cf173 Merge pull request #1515 from WangEdward/main 2024-02-21 15:18:38 +08:00
Edward
3dbb534883 fix: 2fa Nonetype 2024-02-21 05:42:35 +00:00
jxxghp
7304fad460 Merge remote-tracking branch 'origin/main' 2024-02-21 13:36:32 +08:00
jxxghp
9f829c2129 fix #1514 2024-02-21 13:31:19 +08:00
jxxghp
32e71beca8 Merge pull request #1507 from donniex1986/patch-1 2024-02-20 19:09:29 +08:00
donniex1986
3c1c04f356 Update README.md 2024-02-20 18:21:25 +08:00
jxxghp
c473594663 v1.6.6
- IYUU Api域名地址更改为`api.bolahg.cn`
- 消息交互支持洗版订阅和强制重新下载
- 历史记录删除源文件时自动删除下载器中的下载任务
- 文件管理支持英文标题占位符`en_title`
- 更新了多个官方插件,其中目录监控插件修复了蓝光原盘目录整理重复通知的问题

【消息交互示例】
- 订阅 XXX:添加订阅
- 洗版 XXX:添加洗版订阅
- 搜索/下载 XXX:不检查本地是否存在,重新搜索下载
2024-02-20 16:40:28 +08:00
jxxghp
a8ce9648e2 fix bug 2024-02-20 16:18:29 +08:00
jxxghp
760285b085 Merge pull request #1503 from DDS-Derek/main
feat: startup and update script optimization
2024-02-20 15:09:18 +08:00
jxxghp
ccdad3e8dc fix #1502 2024-02-20 15:07:55 +08:00
jxxghp
f33e9bee21 Merge pull request #1502 from honue/main
当插件状态未启用时,设置事件注册状态不可用
2024-02-20 14:58:00 +08:00
jxxghp
4183dca80f fix bug 2024-02-20 14:41:25 +08:00
DDSRem
6f6fd6a42e fix: bug 2024-02-20 14:38:36 +08:00
DDSRem
13bb31fd93 fix: bug 2024-02-20 14:35:19 +08:00
DDSRem
5bac94cbc5 fix: bug 2024-02-20 14:34:29 +08:00
DDSRem
daa8d80ec9 feat: startup and update script optimization
1. 修复Shellcheck指出的问题,增强脚本稳定性。
2. 对于更新部分:采取先更新主程序和前端,插件和资源包再更新的原则;主要解决如果插件或资源包没有下载成功,主程序就无法更新成功的问题,但是其实资源包和插件是不影响主程序更新的。

Co-Authored-By: DDSDerek <108336573+DDSDerek@users.noreply.github.com>
Co-Authored-By: Summer⛱ <57806936+honue@users.noreply.github.com>
2024-02-20 14:26:59 +08:00
honue
b095f01b09 当插件状态未启用时,设置事件注册状态不可用 2024-02-20 13:27:02 +08:00
jxxghp
f43efab831 Merge pull request #1501 from honue/main 2024-02-20 11:44:29 +08:00
honue
946b7905b3 fix 总集数减小时,不能正确更新元数据 2024-02-20 11:34:40 +08:00
jxxghp
544625a9a3 fix bug 2024-02-19 18:03:35 +08:00
jxxghp
d7c6c27679 feat:消息交互支持洗版订阅及全量重新下载
fix #1202
2024-02-19 17:47:20 +08:00
jxxghp
70adbfe6b5 fix #1334 2024-02-19 16:20:03 +08:00
jxxghp
d8f9ab93e5 feat:源文件删除时删除下载任务 fix #1391 2024-02-19 16:07:46 +08:00
jxxghp
e06d07937e fix 2024-02-19 15:55:57 +08:00
jxxghp
f94d248383 Merge pull request #1492 from WangEdward/main
feat: english title from tmdb
2024-02-18 16:31:13 +08:00
Edward
c139aeebf5 feat: title search include en_title 2024-02-18 07:41:49 +00:00
Edward
89a8625817 feat: english title from tmdb 2024-02-18 07:40:59 +00:00
jxxghp
59acda5dec fix #1380
fix #1373
fix #1404
2024-02-18 11:26:05 +08:00
jxxghp
57d9e4a370 fix #1347 2024-02-18 11:20:04 +08:00
jxxghp
8b6a2a3d99 fix #1382 2024-02-18 10:53:13 +08:00
jxxghp
3e10642bdd v1.6.5
- 认证站点新增支持`麒麟`
- 修复了一个设定保存后无法启动的问题
2024-02-18 09:50:07 +08:00
jxxghp
c8e63b6ae0 Merge pull request #1489 from WangEdward/main 2024-02-17 19:47:11 +08:00
Edward
03c92ad41c fix: search_area 错误删除
resolve #1051
2024-02-17 11:33:59 +00:00
jxxghp
690b454bb1 fix api 2024-02-17 13:24:41 +08:00
jxxghp
2e6c1bef63 Merge pull request #1482 from WangEdward/main 2024-02-17 00:45:54 +08:00
jxxghp
0fd428f809 Merge pull request #1485 from cikezhu/main 2024-02-17 00:45:20 +08:00
叮叮当
6083a8a859 fix 更新站点图标 2024-02-16 20:35:03 +08:00
Edward
bb7d262ea3 feat: m-team 2fa 2024-02-15 16:39:14 +00:00
Edward
ca9a37d12a fix: 2fa 变量名冲突 2024-02-15 16:19:20 +00:00
jxxghp
595ca631f4 Merge pull request #1480 from WangEdward/main 2024-02-15 22:02:19 +08:00
jxxghp
cbffddc57f 更新 wechat.py 2024-02-15 21:51:57 +08:00
jxxghp
a5f5d41104 更新 transmission.py 2024-02-15 21:51:23 +08:00
jxxghp
56f07b3dd6 更新 telegram.py 2024-02-15 21:50:57 +08:00
jxxghp
fba10fe6a0 更新 synologychat.py 2024-02-15 21:50:16 +08:00
jxxghp
5639e0b7d0 更新 qbittorrent.py 2024-02-15 21:49:34 +08:00
jxxghp
a6ad58ca33 更新 plex.py 2024-02-15 21:49:03 +08:00
jxxghp
00447f2475 更新 emby.py 2024-02-15 21:48:11 +08:00
jxxghp
9d14fc47fe 更新 jellyfin.py 2024-02-15 21:47:52 +08:00
jxxghp
70c459f810 更新 emby.py 2024-02-15 21:47:01 +08:00
Edward
a0af2f4b68 fix: tmdb 同名返回已订阅 2024-02-15 13:45:29 +00:00
jxxghp
603eefb22f v1.6.4 2024-02-15 21:30:49 +08:00
jxxghp
34625ee384 feat:调整设置项内容结构 2024-02-15 20:40:01 +08:00
jxxghp
ca78fb7c22 fix api 2024-02-15 19:54:16 +08:00
jxxghp
3c710dd266 fix api 2024-02-15 19:46:30 +08:00
jxxghp
514e7add4b fix api 2024-02-15 18:57:24 +08:00
jxxghp
bdbf1e9084 fix api 2024-02-15 16:56:48 +08:00
jxxghp
6149cef1d3 fix api 2024-02-15 15:03:37 +08:00
jxxghp
b8fac86c6e feat:错误变量类型兼容 2024-02-15 13:28:52 +08:00
jxxghp
9f450dd8be fix settings api 2024-02-15 08:39:55 +08:00
jxxghp
24c2d3f8ca fix twofa 2024-02-14 21:11:35 +08:00
jxxghp
4248b8fa4e fix:多域名站点CookieCloud同步重复Bug 2024-02-14 21:10:08 +08:00
jxxghp
deaa2e5644 Merge pull request #1478 from WangEdward/main 2024-02-14 18:53:10 +08:00
Edward
dc43aabe2a fix 2fa helper 2024-02-14 08:51:20 +00:00
Edward
02981d38c0 chore 重命名 2fa 参数名 2024-02-14 08:47:55 +00:00
Edward
85fd9b3c09 feat 为 update_cookie 增加 2fa 支持 2024-02-14 08:47:02 +00:00
Edward
39ad54f3d9 feat 新增 2fa helper 2024-02-14 05:30:41 +00:00
jxxghp
aa9a2c46aa merge cookiecloud chain 2024-02-13 10:36:05 +08:00
jxxghp
c43a1411c9 fix 手动维护站点时缓存站点图标 2024-02-13 10:18:27 +08:00
jxxghp
928aaf0c19 Merge pull request #1474 from WangEdward/main 2024-02-12 15:19:03 +08:00
Edward
ea8a4a3ec4 fix: 支持 Radarr 的 X-Api-Key 请求头 2024-02-12 04:43:21 +00:00
jxxghp
c4dc468479 fix 增加插件库缓存 2024-02-11 22:02:03 +08:00
jxxghp
87ddfbca90 Merge remote-tracking branch 'origin/main' 2024-02-11 21:35:19 +08:00
jxxghp
164ce8f7c4 fix #984 2024-02-11 21:35:11 +08:00
jxxghp
c2fd6e3342 合并拉取请求 #1471
fix 后端程序目录不正确/其他目录被映射时mv会失败
2024-02-11 21:07:01 +08:00
jxxghp
16b79754c3 v1.6.3
- 文件管理支持手动削刮媒体文件
- 集成apexcharts,插件支持绘制图表
- 站点数据统计插件增加今日流量饼图
- 文件重命名兼容特殊字符
- 修复了资源包下载失败时无法启动的问题
2024-02-11 08:40:15 +08:00
叮叮当
9cfb1f789f fix 后端程序目录不正确/其他目录被映射时mv会失败 2024-02-11 02:18:35 +08:00
jxxghp
e3faa388cf fix 连不上Github可能导致无法启动的问题 2024-02-10 20:38:34 +08:00
jxxghp
b75ec92368 fix #1422 2024-02-10 20:35:07 +08:00
jxxghp
f91763ef7c add scrape api 2024-02-10 19:30:41 +08:00
jxxghp
edf8b03d3b Merge pull request #1464 from cikezhu/main
让自定义站点可自行设置: 搜索结果条数/请求超时
2024-02-10 11:30:25 +08:00
jxxghp
ea48eb5c56 fix update 2024-02-10 11:07:42 +08:00
jxxghp
282f723d34 fix plugin api 2024-02-10 10:58:43 +08:00
叮叮当
dde3b76573 让自定义站点可自行设置: 搜索结果条数/请求超时 2024-02-09 22:45:58 +08:00
jxxghp
f571711386 v1.6.2
- 支持更灵活的密码设置
- 支持在新窗口中打开实时日志
- 新增实时硬链接、二级分类策略、下载任务分类与标签、清理硬链接等插件
- 修复了ChineseSubFinder插件无法下载电影字幕的问题
- 前端集成了ace-builds,支持基于路径的反向代理
2024-02-09 11:23:24 +08:00
jxxghp
e8e8d36a13 fix logger 2024-02-09 09:43:35 +08:00
jxxghp
782a9a4759 fix logger 2024-02-09 09:42:49 +08:00
jxxghp
d0184bd34c fix logger 2024-02-09 09:35:05 +08:00
jxxghp
e4c0643c39 fix bug 2024-02-08 20:50:41 +08:00
jxxghp
305c08c7dd fix category 2024-02-08 14:42:38 +08:00
jxxghp
9521a3ef09 Merge remote-tracking branch 'origin/main' 2024-02-08 08:35:25 +08:00
jxxghp
b4c6a206af fix password 2024-02-08 08:35:18 +08:00
jxxghp
fa7eeec345 Merge pull request #1460 from cikezhu/main 2024-02-08 07:15:34 +08:00
叮叮当
7350216fc4 新窗口打开全部日志 2024-02-08 00:09:20 +08:00
jxxghp
36122dda31 Merge pull request #1454 from WangEdward/main 2024-02-07 21:11:58 +08:00
Edward
5851673b43 fix: 重新整理成功移动 2024-02-06 21:07:57 +08:00
Edward
0d81105a0b fix: 历史记录中重新整理成功记录时的问题 2024-02-06 18:05:45 +08:00
jxxghp
b934b0975b Merge pull request #1437 from falling/main 2024-02-01 13:55:37 +08:00
falling
035b4b0608 正在下载的任务状态更新 2024-02-01 12:03:09 +08:00
97 changed files with 2483 additions and 1222 deletions

164
README.md
View File

@@ -59,7 +59,9 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
## 配置
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**
大部分配置可启动后通过WEB管理界面进行配置但仍有部分配置需要通过环境变量/配置文件进行配置。
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件或通过WEB界面配置 > 默认值。
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
@@ -72,153 +74,62 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
- **PROXY_HOST** 网络代理访问themoviedb或者重启更新需要使用代理访问格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
- **MOVIEPILOT_AUTO_UPDATE** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker
- **❗AUTH_SITE** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数,认证资源`v1.1.1`支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数
认证资源`v1.1.4`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
| iyuu | `IYUU_SIGN`IYUU登录令牌 |
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
| audiences | `AUDIENCES_UID`用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
| hddolby | `HDDOLBY_ID`用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
| zmpt | `ZMPT_UID`用户ID<br/>`ZMPT_PASSKEY`:密钥 |
| freefarm | `FREEFARM_UID`用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
| hdfans | `HDFANS_UID`用户ID<br/>`HDFANS_PASSKEY`:密钥 |
| wintersakura | `WINTERSAKURA_UID`用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
| leaves | `LEAVES_UID`用户ID<br/>`LEAVES_PASSKEY`:密钥 |
| ptba | `PTBA_UID`用户ID<br/>`PTBA_PASSKEY`:密钥 |
| icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 |
| ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 |
| xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
| ptvicomo | `PTVICOMO_UID`用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
| agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
| iyuu | `IYUU_SIGN`IYUU登录令牌 |
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
| audiences | `AUDIENCES_UID`用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
| hddolby | `HDDOLBY_ID`用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
| zmpt | `ZMPT_UID`用户ID<br/>`ZMPT_PASSKEY`:密钥 |
| freefarm | `FREEFARM_UID`用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
| hdfans | `HDFANS_UID`用户ID<br/>`HDFANS_PASSKEY`:密钥 |
| wintersakura | `WINTERSAKURA_UID`用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
| leaves | `LEAVES_UID`用户ID<br/>`LEAVES_PASSKEY`:密钥 |
| ptba | `PTBA_UID`用户ID<br/>`PTBA_PASSKEY`:密钥 |
| icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 |
| ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 |
| xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
| ptvicomo | `PTVICOMO_UID`用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
| agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
| hdkyl | `HDKYL_UID`用户ID<br/>`HDKYL_PASSKEY`:密钥 |
### 2. **app.env配置文件**
### 2. **环境变量 / 配置文件**
下载 [app.env 模板](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env),修改后放配置文件目录app.env 的所有配置项也可以通过环境变量进行配置
配置文件名:`app.env`放配置文件目录。
- **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面**注意:1、初始密码为自动生成需要在首次运行时的后台日志中查看成功登录后可以设定中修改2、启动一次后再次修改该值不会生效,除非删除数据库文件!**
- **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
- **❗API_TOKEN** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
- **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
- **GITHUB_TOKEN** Github token提高自动更新、插件安装等请求Github Api的限流阈值格式ghp_****
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker镜像
---
- **TMDB_API_DOMAIN** TMDB API地址默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可
- **TMDB_IMAGE_DOMAIN** TMDB图片地址默认`image.tmdb.org`可配置为其它中转代理以加速TMDB图片显示`static-mdb.v.geilijiasu.com`
- **WALLPAPER** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
- **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
- **FANART_ENABLE** Fanart开关`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
---
- **SCRAP_METADATA** 刮削入库的媒体文件,`true`/`false`,默认`true`
- **SCRAP_SOURCE** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
- **SCRAP_FOLLOW_TMDB** 新增已入库媒体是否跟随TMDB信息变化`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
---
- **❗LIBRARY_PATH** 媒体库目录,多个目录使用`,`分隔
- **LIBRARY_MOVIE_NAME** 电影媒体库目录名称(不是完整路径),默认`电影`
- **LIBRARY_TV_NAME** 电视剧媒体库目录称(不是完整路径),默认`电视剧`
- **LIBRARY_ANIME_NAME** 动漫媒体库目录称(不是完整路径),默认`电视剧/动漫`
- **LIBRARY_CATEGORY** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
- **❗TRANSFER_TYPE** 整理转移方式,支持`link`/`copy`/`move`/`softlink`/`rclone_copy`/`rclone_move` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响rclone需要自行映射rclone配置目录到容器中或在容器内完成rclone配置节点名称必须为`MP`**
- **OVERWRITE_MODE** 转移覆盖模式,默认为`size`,支持`nerver`/`size`/`always`/`latest`,分别表示`不覆盖同名文件`/`同名文件根据文件大小覆盖(大覆盖小)`/`总是覆盖同名文件`/`仅保留最新版本,删除旧版本文件(包括非同名文件)`
---
- **❗COOKIECLOUD_HOST** CookieCloud服务器地址格式`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
- **❗COOKIECLOUD_KEY** CookieCloud用户KEY
- **❗COOKIECLOUD_PASSWORD** CookieCloud端对端加密密码
- **❗COOKIECLOUD_INTERVAL** CookieCloud同步间隔分钟
- **❗USER_AGENT** CookieCloud保存Cookie对应的浏览器UA建议配置设置后可增加连接站点的成功率同步站点后可以在管理界面中修改
---
- **SUBSCRIBE_MODE** 订阅模式,`rss`/`spider`,默认`spider``rss`模式通过定时刷新RSS来匹配订阅RSS地址会自动获取也可手动维护对站点压力小同时可设置订阅刷新周期24小时运行但订阅和下载通知不能过滤和显示免费推荐使用rss模式。
- **SUBSCRIBE_RSS_INTERVAL** RSS订阅模式刷新时间间隔分钟默认`30`分钟不能小于5分钟。
- **SUBSCRIBE_SEARCH** 订阅搜索,`true`/`false`,默认`false`开启后会每隔24小时对所有订阅进行全量搜索以补齐缺失剧集一般情况下正常订阅即可订阅搜索只做为兜底会增加站点压力不建议开启
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID消息通知渠道的用户ID多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
---
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
---
- **❗MESSAGER** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
- `wechat`设置项:
- **WECHAT_CORPID** WeChat企业ID
- **WECHAT_APP_SECRET** WeChat应用Secret
- **WECHAT_APP_ID** WeChat应用ID
- **WECHAT_TOKEN** WeChat消息回调的Token
- **WECHAT_ENCODING_AESKEY** WeChat消息回调的EncodingAESKey
- **WECHAT_ADMINS** WeChat管理员列表多个管理员用英文逗号分隔可选
- **WECHAT_PROXY** WeChat代理服务器后面不要加/
- `telegram`设置项:
- **TELEGRAM_TOKEN** Telegram Bot Token
- **TELEGRAM_CHAT_ID** Telegram Chat ID
- **TELEGRAM_USERS** Telegram 用户ID多个使用,分隔只有用户ID在列表中才可以使用Bot如未设置则均可以使用Bot
- **TELEGRAM_ADMINS** Telegram 管理员ID多个使用,分隔只有管理员才可以操作Bot菜单如未设置则均可以操作菜单可选
- `slack`设置项:
- **SLACK_OAUTH_TOKEN** Slack Bot User OAuth Token
- **SLACK_APP_TOKEN** Slack App-Level Token
- **SLACK_CHANNEL** Slack 频道名称,默认`全体`(可选)
- `synologychat`设置项:
- **SYNOLOGYCHAT_WEBHOOK** 在Synology Chat中创建机器人获取机器人`传入URL`
- **SYNOLOGYCHAT_TOKEN** SynologyChat机器人`令牌`
---
- **❗DOWNLOAD_PATH** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
- **DOWNLOAD_MOVIE_PATH** 电影下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
- **DOWNLOAD_TV_PATH** 电视剧下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
- **DOWNLOAD_ANIME_PATH** 动漫下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
- **DOWNLOAD_CATEGORY** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在下载目录下建立二级目录分类
- **DOWNLOAD_SUBTITLE** 下载站点字幕,`true`/`false`,默认`true`
---
- **❗DOWNLOADER** 下载器,支持`qbittorrent`/`transmission`QB版本号要求>= 4.3.9TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
- `qbittorrent`设置项:
- **QB_HOST** qbittorrent地址格式`ip:port`https需要添加`https://`前缀
- **QB_USER** qbittorrent用户名
- **QB_PASSWORD** qbittorrent密码
- **QB_CATEGORY** qbittorrent分类自动管理`true`/`false`,默认`false`,开启后会将下载二级分类传递到下载器,由下载器管理下载目录,需要同步开启`DOWNLOAD_CATEGORY`
- **QB_SEQUENTIAL** qbittorrent按顺序下载`true`/`false`,默认`true`
- **QB_FORCE_RESUME** qbittorrent忽略队列限制强制继续`true`/`false`,默认 `false`
- `transmission`设置项:
- **TR_HOST** transmission地址格式`ip:port`https需要添加`https://`前缀
- **TR_USER** transmission用户名
- **TR_PASSWORD** transmission密码
- **DOWNLOADER_MONITOR** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
- **TORRENT_TAG** 下载器种子标签,默认为`MOVIEPILOT`设置后只有MoviePilot添加的下载才会处理留空所有下载器中的任务均会处理
---
- **❗MEDIASERVER** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
- `emby`设置项:
- **EMBY_HOST** Emby服务器地址格式`ip:port`https需要添加`https://`前缀
- **EMBY_PLAY_HOST** EMBY外网地址格式`http(s)://DOMAIN:PORT`,未设置时使用`EMBY_HOST`
- **EMBY_API_KEY** Emby Api Key在`设置->高级->API密钥`处生成
- `jellyfin`设置项:
- **JELLYFIN_HOST** Jellyfin服务器地址格式`ip:port`https需要添加`https://`前缀
- **JELLYFIN_PLAY_HOST** Jellyfin外网地址格式`http(s)://DOMAIN:PORT`,未设置时使用`JELLYFIN_HOST`
- **JELLYFIN_API_KEY** Jellyfin Api Key在`设置->高级->API密钥`处生成
- `plex`设置项:
- **PLEX_HOST** Plex服务器地址格式`ip:port`https需要添加`https://`前缀
- **PLEX_PLAY_HOST** Plex外网地址格式`http(s)://DOMAIN:PORT`,未设置时使用`PLEX_HOST`
- **PLEX_TOKEN** Plex网页Url中的`X-Plex-Token`通过浏览器F12->网络从请求URL中获取
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割
---
- **MOVIE_RENAME_FORMAT** 电影重命名格式基于jinjia2语法
`MOVIE_RENAME_FORMAT`支持的配置项:
> `title` TMDB/豆瓣中的标题
> `en_title` TMDB中的英文标题 (暂不支持豆瓣)
> `original_title` TMDB/豆瓣中的原语种标题
> `name` 从文件名中识别的名称(同时存在中英文时,优先使用中文)
> `en_name`:从文件名中识别的英文名称(可能为空)
@@ -263,7 +174,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
### 3. **优先级规则**
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等
- 仅支持使用内置规则进行排列组合,通过设置多层规则来实现优先级顺序匹配
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
- 不符合过滤规则所有层级规则的资源将不会被选中
@@ -274,13 +185,14 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
## 使用
- 通过CookieCloud同步快速同步站点不需要使用的站点可在WEB管理界面中禁用无法同步的站点可手动新增。
- 通过WEB进行管理将WEB添加到手机桌面获得类App使用效果管理界面端口`3000`后台API端口`3001`
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单微信菜单条数有限制部分菜单不显示微信需要在官方页面设置回调地址SynologyChat需要设置机器人传入地址,地址相对路径为:`/api/v1/message/`。
- 设置媒体服务器Webhook通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot``3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr`API服务端口`可使用Overseerr/Jellyseerr浏览订阅。
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
- 通过设置的超级管理员用户登录管理界面默认用户admin默认端口3000**注意:初始密码为自动生成,需要在首次运行时的后台日志中查看!**
- 通过CookieCloud同步快速添加站点不需要使用的站点可在WEB管理界面中禁用或删除无法同步的站点可手动新增
- 通过打开下载器监控实现下载完成后自动整理入库刮削媒体信息
- 通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`远程管理,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信回调地址SynologyChat传入地址地址相对路径均为:`/api/v1/message/`VoceChat的Webhook地址相对路径为`/api/v1/message/?token=moviepilot`其中moviepilot为设置的`API_TOKEN`。
- 设置媒体服务器Webhook通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`。
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr可使用Overseerr/Jellyseerr浏览订阅。
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
### **注意**
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。

View File

@@ -96,7 +96,8 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
eventmanager.send_event(
EventType.DownloadFileDeleted,
{
"src": history.src
"src": history.src,
"hash": history.download_hash
}
)
# 删除记录

View File

@@ -34,21 +34,24 @@ async def login_access_token(
)
if not user:
# 请求协助认证
logger.warn("登录用户本地不匹配,尝试辅助认证 ...")
logger.warn(f"登录用户 {form_data.username} 本地用户名或密码不匹配,尝试辅助认证 ...")
token = UserChain().user_authenticate(form_data.username, form_data.password)
if not token:
logger.warn(f"用户 {form_data.username} 登录失败!")
raise HTTPException(status_code=401, detail="用户名或密码不正确")
else:
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token}")
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
# 加入用户信息表
user = User.get_by_name(db=db, name=form_data.username)
if not user:
logger.info(f"用户不存在,创建普通用户: {form_data.username}")
logger.info(f"用户不存在,创建用户: {form_data.username}")
user = User(name=form_data.username, is_active=True,
is_superuser=False, hashed_password=get_password_hash(token))
user.create(db)
else:
# 辅助验证用户若未启用,则禁止登录
if not user.is_active:
raise HTTPException(status_code=403, detail="用户未启用")
# 普通用户权限
user.is_superuser = False
elif not user.is_active:

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from typing import List, Any
from fastapi import APIRouter, Depends
@@ -6,7 +7,7 @@ from app import schemas
from app.chain.media import MediaChain
from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.schemas import MediaType
@@ -76,6 +77,28 @@ def search_by_title(title: str,
return []
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
def scrape(path: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
刮削媒体信息
"""
if not path:
return schemas.Response(success=False, message="刮削路径无效")
scrape_path = Path(path)
if not scrape_path.exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 识别
chain = MediaChain()
meta = MetaInfoPath(scrape_path)
mediainfo = chain.recognize_media(meta)
if not media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
# 刮削
chain.scrape_metadata(path=scrape_path, mediainfo=mediainfo, transfer_type=settings.TRANSFER_TYPE)
return schemas.Response(success=True, message="刮削完成")
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
def media_info(mediaid: str, type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -36,13 +36,11 @@ async def user_message(background_tasks: BackgroundTasks, request: Request):
return schemas.Response(success=True)
@router.get("/", summary="微信验证")
def wechat_verify(echostr: str, msg_signature: str,
timestamp: Union[str, int], nonce: str) -> Any:
"""
用户消息响应
微信验证响应
"""
logger.info(f"收到微信验证请求: {echostr}")
try:
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
@@ -60,6 +58,28 @@ def wechat_verify(echostr: str, msg_signature: str,
return PlainTextResponse(sEchoStr)
def vocechat_verify(token: str) -> Any:
"""
VoceChat验证响应
"""
if token == settings.API_TOKEN:
return {"status": "OK"}
return {"status": "ERROR"}
@router.get("/", summary="回调请求验证")
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
"""
微信/VoceChat等验证响应
"""
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
if echostr and msg_signature and timestamp and nonce:
return wechat_verify(echostr, msg_signature, timestamp, nonce)
return vocechat_verify(token)
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -72,7 +92,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
for noti in NotificationType:
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True,
synologychat=True))
synologychat=True, vocechat=True))
else:
for switch in switchs:
return_list.append(NotificationSwitch(**switch))

View File

@@ -7,45 +7,60 @@ from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.plugin import PluginHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey
router = APIRouter()
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> Any:
"""
查询所有插件清单,包括本地插件和在线插件
查询所有插件清单,包括本地插件和在线插件插件状态installed, market, all
"""
plugins = []
# 本地插件
local_plugins = PluginManager().get_local_plugins()
# 已安装插件
installed_plugins = [plugin for plugin in local_plugins if plugin.get("installed")]
# 未安装的本地插件
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.get("installed")]
if state == "installed":
return installed_plugins
# 在线插件
online_plugins = PluginManager().get_online_plugins()
if not online_plugins:
# 没有获取在线插件
if state == "market":
# 返回未安装的本地插件
return not_installed_plugins
return local_plugins
# 插件市场插件清单
market_plugins = []
# 已安装插件IDS
installed_ids = [plugin["id"] for plugin in local_plugins if plugin.get("installed")]
# 已经安装的本地
plugins.extend([plugin for plugin in local_plugins if plugin.get("installed")])
_installed_ids = [plugin["id"] for plugin in installed_plugins]
# 未安装的线上插件或者有更新的插件
for plugin in online_plugins:
if plugin["id"] not in installed_ids:
plugins.append(plugin)
if plugin["id"] not in _installed_ids:
market_plugins.append(plugin)
elif plugin.get("has_update"):
plugin["installed"] = False
plugins.append(plugin)
# 本地插件存在但未安装且本地插件不在online插件中
plugin_ids = [plugin["id"] for plugin in plugins]
for plugin in local_plugins:
if plugin["id"] not in installed_ids \
and plugin["id"] not in plugin_ids:
plugins.append(plugin)
return plugins
market_plugins.append(plugin)
# 未安装的本地插件,且不在线上插件中
_plugin_ids = [plugin["id"] for plugin in market_plugins]
for plugin in not_installed_plugins:
if plugin["id"] not in _plugin_ids:
market_plugins.append(plugin)
# 返回插件清单
if state == "market":
# 返回未安装的插件
return market_plugins
# 返回所有插件
return installed_plugins + market_plugins
@router.get("/installed", summary="已安装插件", response_model=List[str])
def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def installed(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询用户已安装插件清单
"""
@@ -53,10 +68,10 @@ def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
def install_plugin(plugin_id: str,
repo_url: str = "",
force: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def install(plugin_id: str,
repo_url: str = "",
force: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
安装插件
"""
@@ -76,6 +91,8 @@ def install_plugin(plugin_id: str,
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 重载插件管理器
PluginManager().init_config()
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
return schemas.Response(success=True)
@@ -101,7 +118,7 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token))
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据插件ID重置插件配置
"""
@@ -109,6 +126,8 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
PluginManager().delete_plugin_config(plugin_id)
# 重新生效插件
PluginManager().reload_plugin(plugin_id, {})
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
return schemas.Response(success=True)
@@ -130,6 +149,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
PluginManager().save_plugin_config(plugin_id, conf)
# 重新生效插件
PluginManager().reload_plugin(plugin_id, conf)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
return schemas.Response(success=True)
@@ -149,6 +170,8 @@ def uninstall_plugin(plugin_id: str,
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 重载插件管理器
PluginManager().init_config()
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
return schemas.Response(success=True)

View File

@@ -54,6 +54,10 @@ def add_site(
site_in.id = None
site = Site(**site_in.dict())
site.create(db)
# 通知缓存站点图标
EventManager().send_event(EventType.CacheSiteIcon, {
"domain": domain
})
return schemas.Response(success=True)
@@ -71,6 +75,10 @@ def update_site(
if not site:
return schemas.Response(success=False, message="站点不存在")
site.update(db, site_in.dict())
# 通知缓存站点图标
EventManager().send_event(EventType.CacheSiteIcon, {
"domain": site_in.domain
})
return schemas.Response(success=True)
@@ -103,8 +111,8 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
def cookie_cloud_sync(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def reset(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
清空所有站点数据并重新同步CookieCloud站点信息
"""
@@ -126,6 +134,7 @@ def update_cookie(
site_id: int,
username: str,
password: str,
code: str = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -141,7 +150,8 @@ def update_cookie(
# 更新Cookie
state, message = SiteChain().update_cookie(site_info=site_info,
username=username,
password=password)
password=password,
two_step_code=code)
return schemas.Response(success=state, message=message)

View File

@@ -83,10 +83,11 @@ def create_subscribe(
username=current_user.name,
best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path,
search_imdbid=subscribe_in.search_imdbid,
exist_ok=True)
return schemas.Response(success=True if sid else False, message=message, data={
"id": sid
})
return schemas.Response(
success=bool(sid), message=message, data={"id": sid}
)
@router.put("/", summary="更新订阅", response_model=schemas.Response)
@@ -115,6 +116,9 @@ def update_subscribe(
subscribe_dict["lack_episode"] = (subscribe.lack_episode
+ (subscribe_in.total_episode
- (subscribe.total_episode or 0)))
# 是否手动修改过总集数
if subscribe_in.total_episode != subscribe.total_episode:
subscribe_dict["manual_total_episode"] = 1
subscribe.update(db, subscribe_dict)
return schemas.Response(success=True)
@@ -140,12 +144,13 @@ def subscribe_mediaid(
if not doubanid:
return Subscribe()
result = Subscribe.get_by_doubanid(db, doubanid)
if not result and title:
meta = MetaInfo(title)
if season:
meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
# 豆瓣已订阅如果 id 搜索无结果使用标题搜索
# 会造成同名结果也会被返回
if not result and title:
meta = MetaInfo(title)
if season:
meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
if result and result.sites:
result.sites = json.loads(result.sites)

View File

@@ -4,12 +4,14 @@ from datetime import datetime
from typing import Union, Any
import tailer
from dotenv import set_key
from fastapi import APIRouter, HTTPException, Depends, Response
from fastapi.responses import StreamingResponse
from app import schemas
from app.chain.search import SearchChain
from app.core.config import settings
from app.core.module import ModuleManager
from app.core.security import verify_token
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
@@ -46,7 +48,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
查询系统环境变量,包括当前版本号
"""
info = settings.dict(
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
)
info.update({
"VERSION": APP_VERSION,
@@ -57,6 +59,27 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
data=info)
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
def set_env_setting(env: dict,
_: schemas.TokenPayload = Depends(verify_token)):
"""
更新系统环境变量
"""
for k, v in env.items():
if k == "undefined":
continue
if hasattr(settings, k):
if v == "None":
v = None
setattr(settings, k, v)
if v is None:
v = ''
else:
v = str(v)
set_key(settings.CONFIG_PATH / "app.env", k, v)
return schemas.Response(success=True)
@router.get("/progress/{process_type}", summary="实时进度")
def get_progress(process_type: str, token: str):
"""
@@ -85,18 +108,32 @@ def get_setting(key: str,
"""
查询系统设置
"""
if hasattr(settings, key):
value = getattr(settings, key)
else:
value = SystemConfigOper().get(key)
return schemas.Response(success=True, data={
"value": SystemConfigOper().get(key)
"value": value
})
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
def set_setting(key: str, value: Union[list, dict, str, int] = None,
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
_: schemas.TokenPayload = Depends(verify_token)):
"""
更新系统设置
"""
SystemConfigOper().set(key, value)
if hasattr(settings, key):
if value == "None":
value = None
setattr(settings, key, value)
if value is None:
value = ''
else:
value = str(value)
set_key(settings.CONFIG_PATH / "app.env", key, value)
else:
SystemConfigOper().set(key, value)
return schemas.Response(success=True)
@@ -123,9 +160,11 @@ def get_message(token: str):
@router.get("/logging", summary="实时日志")
def get_logging(token: str):
def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
"""
实时获取系统日志返回格式为SSE
实时获取系统日志
length = -1 时, 返回text/plain
否则 返回格式SSE
"""
if not token or not verify_token(token):
raise HTTPException(
@@ -133,45 +172,29 @@ def get_logging(token: str):
detail="认证失败!",
)
log_path = settings.LOG_PATH / logfile
def log_generator():
log_path = settings.LOG_PATH / 'moviepilot.log'
# 读取文件末尾50行不使用tailer模块
with open(log_path, 'r', encoding='utf-8') as f:
for line in f.readlines()[-50:]:
for line in f.readlines()[-max(length, 50):]:
yield 'data: %s\n\n' % line
while True:
for text in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (text or '')
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (t or '')
time.sleep(1)
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/nettest", summary="测试网络连通性")
def nettest(url: str,
proxy: bool,
_: schemas.TokenPayload = Depends(verify_token)):
"""
测试网络连通性
"""
# 记录开始的毫秒数
start_time = datetime.now()
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
result = RequestUtils(proxies=settings.PROXY if proxy else None,
ua=settings.USER_AGENT).get_res(url)
# 计时结束的毫秒数
end_time = datetime.now()
# 计算相关秒数
if result and result.status_code == 200:
return schemas.Response(success=True, data={
"time": round((end_time - start_time).microseconds / 1000)
})
elif result:
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
"time": round((end_time - start_time).microseconds / 1000)
})
# 根据length参数返回不同的响应
if length == -1:
# 返回全部日志作为文本响应
if not log_path.exists():
return Response(content="日志文件不存在!", media_type="text/plain")
with open(log_path, 'r', encoding='utf-8') as file:
text = file.read()
return Response(content=text, media_type="text/plain")
else:
return schemas.Response(success=False, message="网络连接失败!")
# 返回SSE流响应
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
@@ -219,6 +242,53 @@ def ruletest(title: str,
})
@router.get("/nettest", summary="测试网络连通性")
def nettest(url: str,
proxy: bool,
_: schemas.TokenPayload = Depends(verify_token)):
"""
测试网络连通性
"""
# 记录开始的毫秒数
start_time = datetime.now()
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
result = RequestUtils(proxies=settings.PROXY if proxy else None,
ua=settings.USER_AGENT).get_res(url)
# 计时结束的毫秒数
end_time = datetime.now()
# 计算相关秒数
if result and result.status_code == 200:
return schemas.Response(success=True, data={
"time": round((end_time - start_time).microseconds / 1000)
})
elif result:
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
"time": round((end_time - start_time).microseconds / 1000)
})
else:
return schemas.Response(success=False, message="网络连接失败!")
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询已加载的模块ID列表
"""
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
return schemas.Response(success=True, data={
"ids": module_ids
})
@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response)
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
"""
模块可用性测试接口
"""
state, errmsg = ModuleManager().test(moduleid)
return schemas.Response(success=state, message=errmsg)
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
"""
@@ -231,6 +301,16 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
return schemas.Response(success=ret, message=msg)
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
"""
重新加载模块
"""
ModuleManager().stop()
ModuleManager().load_modules()
return schemas.Response(success=True)
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
def execute_command(jobid: str,
_: schemas.TokenPayload = Depends(verify_token)):

View File

@@ -37,7 +37,7 @@ def manual_transfer(path: str = None,
:param type_name: 媒体类型、电影/电视剧
:param tmdbid: tmdbid
:param season: 剧集季号
:param transfer_type: 转移类型move/copy
:param transfer_type: 转移类型move/copy
:param episode_format: 剧集识别格式
:param episode_detail: 剧集识别详细信息
:param episode_part: 剧集识别分集信息
@@ -56,16 +56,20 @@ def manual_transfer(path: str = None,
return schemas.Response(success=False, message=f"历史记录不存在ID{logid}")
# 强制转移
force = True
# 源路径
in_path = Path(history.src)
# 目的路径
if history.dest and str(history.dest) != "None":
# 删除旧的已整理文件
transfer.delete_files(Path(history.dest))
if not target:
target = transfer.get_root_path(path=history.dest,
type_name=history.type,
category=history.category)
if history.status and ("move" in history.mode):
# 重新整理成功的转移,则使用成功的 dest 做 in_path
in_path = Path(history.dest)
else:
# 源路径
in_path = Path(history.src)
# 目的路径
if history.dest and str(history.dest) != "None":
# 删除旧的已整理文件
transfer.delete_files(Path(history.dest))
if not target:
target = transfer.get_root_path(path=history.dest,
type_name=history.type,
category=history.category)
elif path:
in_path = Path(path)
else:

View File

@@ -60,10 +60,10 @@ def update_user(
"""
user_info = user_in.dict()
if user_info.get("password"):
# 正则表达式匹配密码包含大写字母、小写字母、数字
pattern = r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]+$'
# 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项
pattern = r'^(?![a-zA-Z]+$)(?!\d+$)(?![^\da-zA-Z\s]+$).{6,50}$'
if not re.match(pattern, user_info.get("password")):
return schemas.Response(success=False, message="密码需要同时包含大小写和数")
return schemas.Response(success=False, message="密码需要同时包含字母、数字、特殊字符中的至少两项且长度大于6位")
user_info["hashed_password"] = get_password_hash(user_info["password"])
user_info.pop("password")
user = User.get_by_name(db, name=user_info["name"])

View File

@@ -4,7 +4,6 @@ from fastapi import APIRouter, BackgroundTasks, Request, Depends
from app import schemas
from app.chain.webhook import WebhookChain
from app.core.config import settings
from app.core.security import verify_uri_token
router = APIRouter()

View File

@@ -108,19 +108,21 @@ class ChainBase(metaclass=ABCMeta):
break
except Exception as err:
logger.error(
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.print_exc()}")
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
return result
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
tmdbid: int = None,
doubanid: str = None) -> Optional[MediaInfo]:
doubanid: str = None,
cache: bool = True) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
# 识别用名中含指定信息情形
@@ -131,7 +133,7 @@ class ChainBase(metaclass=ABCMeta):
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
return self.run_module("recognize_media", meta=meta, mtype=mtype,
tmdbid=tmdbid, doubanid=doubanid)
tmdbid=tmdbid, doubanid=doubanid, cache=cache)
def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
@@ -340,13 +342,14 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("transfer_completed", hashs=hashs, path=path)
def remove_torrents(self, hashs: Union[str, list]) -> bool:
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
"""
删除下载器种子
:param hashs: 种子Hash
:param delete_file: 是否删除文件
:return: bool
"""
return self.run_module("remove_torrents", hashs=hashs)
return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file)
def start_torrents(self, hashs: Union[list, str]) -> bool:
"""

View File

@@ -1,178 +0,0 @@
import base64
from typing import Tuple, Optional
from urllib.parse import urljoin
from lxml import etree
from app.chain import ChainBase
from app.chain.site import SiteChain
from app.core.config import settings
from app.db.site_oper import SiteOper
from app.db.siteicon_oper import SiteIconOper
from app.helper.cloudflare import under_challenge
from app.helper.cookiecloud import CookieCloudHelper
from app.helper.message import MessageHelper
from app.helper.rss import RssHelper
from app.helper.sites import SitesHelper
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
class CookieCloudChain(ChainBase):
"""
CookieCloud处理链
"""
def __init__(self):
super().__init__()
self.siteoper = SiteOper()
self.siteiconoper = SiteIconOper()
self.siteshelper = SitesHelper()
self.rsshelper = RssHelper()
self.sitechain = SiteChain()
self.message = MessageHelper()
self.cookiecloud = CookieCloudHelper(
server=settings.COOKIECLOUD_HOST,
key=settings.COOKIECLOUD_KEY,
password=settings.COOKIECLOUD_PASSWORD
)
def process(self, manual=False) -> Tuple[bool, str]:
"""
通过CookieCloud同步站点Cookie
"""
logger.info("开始同步CookieCloud站点 ...")
cookies, msg = self.cookiecloud.download()
if not cookies:
logger.error(f"CookieCloud同步失败{msg}")
if manual:
self.message.put(f"CookieCloud同步失败 {msg}")
return False, msg
# 保存Cookie或新增站点
_update_count = 0
_add_count = 0
_fail_count = 0
for domain, cookie in cookies.items():
# 获取站点信息
indexer = self.siteshelper.get_indexer(domain)
site_info = self.siteoper.get_by_domain(domain)
if site_info:
# 检查站点连通性
status, msg = self.sitechain.test(domain)
# 更新站点Cookie
if status:
logger.info(f"站点【{site_info.name}】连通性正常不同步CookieCloud数据")
# 更新站点rss地址
if not site_info.public and not site_info.rss:
# 自动生成rss地址
rss_url, errmsg = self.rsshelper.get_rss_link(
url=site_info.url,
cookie=cookie,
ua=settings.USER_AGENT,
proxy=True if site_info.proxy else False
)
if rss_url:
logger.info(f"更新站点 {domain} RSS地址 ...")
self.siteoper.update_rss(domain=domain, rss=rss_url)
else:
logger.warn(errmsg)
continue
# 更新站点Cookie
logger.info(f"更新站点 {domain} Cookie ...")
self.siteoper.update_cookie(domain=domain, cookies=cookie)
_update_count += 1
elif indexer:
# 新增站点
res = RequestUtils(cookies=cookie,
ua=settings.USER_AGENT
).get_res(url=indexer.get("domain"))
if res and res.status_code in [200, 500, 403]:
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
_fail_count += 1
if under_challenge(res.text):
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护无法登录无法添加站点")
continue
logger.warn(
f"站点 {indexer.get('name')} 登录失败没有该站点账号或Cookie已失效无法添加站点")
continue
elif res is not None:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
continue
else:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
continue
# 获取rss地址
rss_url = None
if not indexer.get("public") and indexer.get("domain"):
# 自动生成rss地址
rss_url, errmsg = self.rsshelper.get_rss_link(url=indexer.get("domain"),
cookie=cookie,
ua=settings.USER_AGENT)
if errmsg:
logger.warn(errmsg)
# 插入数据库
logger.info(f"新增站点 {indexer.get('name')} ...")
self.siteoper.add(name=indexer.get("name"),
url=indexer.get("domain"),
domain=domain,
cookie=cookie,
rss=rss_url,
public=1 if indexer.get("public") else 0)
_add_count += 1
# 保存站点图标
if indexer:
site_icon = self.siteiconoper.get_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:
self.siteiconoper.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')} 图标失败")
# 处理完成
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
if _fail_count > 0:
ret_msg += f"{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.message.put(f"CookieCloud同步成功, {ret_msg}")
logger.info(f"CookieCloud同步成功{ret_msg}")
return True, ret_msg
@staticmethod
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
"""
解析站点favicon,返回base64 fav图标
:param url: 站点地址
:param cookie: Cookie
:param ua: User-Agent
:return:
"""
favicon_url = urljoin(url, "favicon.ico")
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
if res:
html_text = res.text
else:
logger.error(f"获取站点页面失败:{url}")
return favicon_url, None
html = etree.HTML(html_text)
if html:
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
if fav_link:
favicon_url = urljoin(url, fav_link[0])
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
logger.error(f"获取站点图标失败:{favicon_url}")
return favicon_url, None

View File

@@ -9,6 +9,7 @@ from typing import List, Optional, Tuple, Set, Dict, Union
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper
@@ -838,3 +839,16 @@ class DownloadChain(ChainBase):
删除下载任务
"""
return self.remove_torrents(hashs=[hash_str])
@eventmanager.register(EventType.DownloadFileDeleted)
def download_file_deleted(self, event: Event):
"""
下载文件删除时,同步删除下载任务
"""
if not event:
return
hash_str = event.event_data.get("hash")
if not hash_str:
return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
self.remove_torrents(hashs=[hash_str], delete_file=False)

View File

@@ -66,18 +66,18 @@ class MediaServerChain(ChainBase):
"""
同步媒体库所有数据到本地数据库
"""
# 设置的媒体服务器
if not settings.MEDIASERVER:
return
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
mediaservers = settings.MEDIASERVER.split(",")
with lock:
# 汇总统计
total_count = 0
# 清空登记薄
self.dboper.empty()
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
# 设置的媒体服务器
if not settings.MEDIASERVER:
return
mediaservers = settings.MEDIASERVER.split(",")
# 遍历媒体服务器
for mediaserver in mediaservers:
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")

View File

@@ -1,7 +1,7 @@
import copy
import json
import re
from typing import Any, Optional, Dict
from typing import Any, Optional, Dict, Union
from app.chain import ChainBase
from app.chain.download import DownloadChain
@@ -14,7 +14,7 @@ from app.core.event import EventManager
from app.core.meta import MetaBase
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import Notification
from app.schemas import Notification, NotExistMediaInfo
from app.schemas.types import EventType, MessageChannel, MediaType
from app.utils.string import StringUtils
@@ -40,10 +40,64 @@ class MessageChain(ChainBase):
self.downloadchain = DownloadChain()
self.subscribechain = SubscribeChain()
self.searchchain = SearchChain()
self.medtachain = MediaChain()
self.mediachain = MediaChain()
self.eventmanager = EventManager()
self.torrenthelper = TorrentHelper()
def __get_noexits_info(
self,
_meta: MetaBase,
_mediainfo: MediaInfo) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]:
"""
获取缺失的媒体信息
"""
if _mediainfo.type == MediaType.TV:
if not _mediainfo.seasons:
# 补充媒体信息
_mediainfo = self.mediachain.recognize_media(mtype=_mediainfo.type,
tmdbid=_mediainfo.tmdb_id,
doubanid=_mediainfo.douban_id,
cache=False)
if not _mediainfo:
logger.warn(f"{_mediainfo.tmdb_id or _mediainfo.douban_id} 媒体信息识别失败!")
return {}
if not _mediainfo.seasons:
logger.warn(f"媒体信息中没有季集信息,"
f"标题:{_mediainfo.title}"
f"tmdbid{_mediainfo.tmdb_id}doubanid{_mediainfo.douban_id}")
return {}
# KEY
_mediakey = _mediainfo.tmdb_id or _mediainfo.douban_id
_no_exists = {
_mediakey: {}
}
if _meta.begin_season:
# 指定季
episodes = _mediainfo.seasons.get(_meta.begin_season)
if not episodes:
return {}
_no_exists[_mediakey][_meta.begin_season] = NotExistMediaInfo(
season=_meta.begin_season,
episodes=[],
total_episode=len(episodes),
start_episode=episodes[0]
)
else:
# 所有季
for sea, eps in _mediainfo.seasons.items():
if not eps:
continue
_no_exists[_mediakey][sea] = NotExistMediaInfo(
season=sea,
episodes=[],
total_episode=len(eps),
start_episode=eps[0]
)
else:
_no_exists = {}
return _no_exists
def process(self, body: Any, form: Any, args: Any) -> None:
"""
识别消息内容,执行操作
@@ -84,6 +138,7 @@ class MessageChain(ChainBase):
)
elif text.isdigit():
# 用户选择了具体的条目
# 缓存
cache_data: dict = user_cache.get(userid)
# 选择项目
@@ -100,31 +155,44 @@ class MessageChain(ChainBase):
# 缓存列表
cache_list: list = copy.deepcopy(cache_data.get('items'))
# 选择
if cache_type == "Search":
if cache_type in ["Search", "ReSearch"]:
# 当前媒体信息
mediainfo: MediaInfo = cache_list[_choice]
_current_media = mediainfo
# 查询缺失的媒体信息
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
mediainfo=_current_media)
if exist_flag:
if exist_flag and cache_type == "Search":
# 媒体库中已存在
self.post_message(
Notification(channel=channel,
title=f"{_current_media.title_year}"
f"{_current_meta.sea} 媒体库中已存在",
f"{_current_meta.sea} 媒体库中已存在,如需重新下载请发送:搜索 XXX 或 下载 XXX",
userid=userid))
return
elif exist_flag:
# 没有缺失,但要全量重新搜索和下载
no_exists = self.__get_noexits_info(_current_meta, _current_media)
# 发送缺失的媒体信息
if no_exists:
# 发送消息
messages = []
if no_exists and cache_type == "Search":
# 发送缺失消息
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
messages = [
f"{sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode}"
for sea, no_exist in no_exists.get(mediakey).items()]
elif no_exists:
# 发送总集数的消息
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
messages = [
f"{sea} 季总 {no_exist.total_episode}"
for sea, no_exist in no_exists.get(mediakey).items()]
if messages:
self.post_message(Notification(channel=channel,
title=f"{mediainfo.title_year}\n" + "\n".join(messages),
userid=userid))
# 搜索种子,过滤掉不需要的剧集,以便选择
logger.info(f"{mediainfo.title_year} 媒体库中不存在,开始搜索 ...")
logger.info(f"开始搜索 {mediainfo.title_year} ...")
self.post_message(
Notification(channel=channel,
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
@@ -144,13 +212,16 @@ class MessageChain(ChainBase):
# 判断是否设置自动下载
auto_download_user = settings.AUTO_DOWNLOAD_USER
# 匹配到自动下载用户
if auto_download_user and (auto_download_user == "all" or any(userid == user for user in auto_download_user.split(","))):
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载")
if auto_download_user \
and (auto_download_user == "all"
or any(userid == user for user in auto_download_user.split(","))):
logger.info(f"用户 {userid} 在自动下载用户中,开始自动择优下载 ...")
# 自动选择下载
self.__auto_download(channel=channel,
cache_list=contexts,
userid=userid,
username=username)
username=username,
no_exists=no_exists)
else:
# 更新缓存
user_cache[userid] = {
@@ -165,19 +236,24 @@ class MessageChain(ChainBase):
userid=userid,
total=len(contexts))
elif cache_type == "Subscribe":
# 订阅媒体
elif cache_type in ["Subscribe", "ReSubscribe"]:
# 订阅或洗版媒体
mediainfo: MediaInfo = cache_list[_choice]
# 洗版标识
best_version = False
# 查询缺失的媒体信息
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
mediainfo=mediainfo)
if exist_flag:
self.post_message(Notification(
channel=channel,
title=f"{mediainfo.title_year}"
f"{_current_meta.sea} 媒体库中已存在",
userid=userid))
return
if cache_type == "Subscribe":
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
mediainfo=mediainfo)
if exist_flag:
self.post_message(Notification(
channel=channel,
title=f"{mediainfo.title_year}"
f"{_current_meta.sea} 媒体库中已存在,如需洗版请发送:洗版 XXX",
userid=userid))
return
else:
best_version = True
# 添加订阅状态为N
self.subscribechain.add(title=mediainfo.title,
year=mediainfo.year,
@@ -186,10 +262,11 @@ class MessageChain(ChainBase):
season=_current_meta.begin_season,
channel=channel,
userid=userid,
username=username)
username=username,
best_version=best_version)
elif cache_type == "Torrent":
if int(text) == 0:
# 自动选择下载
# 自动选择下载,强制下载模式
self.__auto_download(channel=channel,
cache_list=cache_list,
userid=userid,
@@ -280,6 +357,14 @@ class MessageChain(ChainBase):
# 订阅
content = re.sub(r"订阅[:\s]*", "", text)
action = "Subscribe"
elif text.startswith("洗版"):
# 洗版
content = re.sub(r"洗版[:\s]*", "", text)
action = "ReSubscribe"
elif text.startswith("搜索") or text.startswith("下载"):
# 重新搜索/下载
content = re.sub(r"(搜索|下载)[:\s]*", "", text)
action = "ReSearch"
elif text.startswith("#") \
or re.search(r"^请[问帮你]", text) \
or re.search(r"[?]$", text) \
@@ -290,12 +375,12 @@ class MessageChain(ChainBase):
action = "chat"
else:
# 搜索
content = re.sub(r"(搜索|下载)[:\s]*", "", text)
content = text
action = "Search"
if action in ["Subscribe", "Search"]:
if action != "chat":
# 搜索
meta, medias = self.medtachain.search(content)
meta, medias = self.mediachain.search(content)
# 识别
if not meta.name:
self.post_message(Notification(
@@ -334,20 +419,22 @@ class MessageChain(ChainBase):
# 保存缓存
self.save_cache(user_cache, self._cache_file)
def __auto_download(self, channel, cache_list, userid, username):
def __auto_download(self, channel: MessageChannel, cache_list: list[Context],
userid: Union[str, int], username: str,
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None):
"""
自动择优下载
"""
# 查询缺失的媒体信息
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
mediainfo=_current_media)
if exist_flag:
self.post_message(Notification(
channel=channel,
title=f"{_current_media.title_year}"
f"{_current_meta.sea} 媒体库中已存在",
userid=userid))
return
if no_exists is None:
# 查询缺失的媒体信息
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
meta=_current_meta,
mediainfo=_current_media
)
if exist_flag:
# 媒体库中已存在,查询全量
no_exists = self.__get_noexits_info(_current_meta, _current_media)
# 批量下载
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
no_exists=no_exists,

View File

@@ -1,8 +1,9 @@
import pickle
import re
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Dict, Tuple
from typing import Dict
from typing import List, Optional
from app.chain import ChainBase
@@ -74,7 +75,7 @@ class SearchChain(ChainBase):
try:
return pickle.loads(results)
except Exception as e:
print(str(e))
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
return []
def process(self, mediainfo: MediaInfo,
@@ -122,10 +123,8 @@ class SearchChain(ChainBase):
# 搜索关键词
if keyword:
keywords = [keyword]
elif mediainfo.original_title and mediainfo.title != mediainfo.original_title:
keywords = [mediainfo.title, mediainfo.original_title]
else:
keywords = [mediainfo.title]
keywords = list({mediainfo.title, mediainfo.original_title, mediainfo.en_title} - {None})
# 执行搜索
torrents: List[TorrentInfo] = self.__search_all_sites(
mediainfo=mediainfo,
@@ -136,28 +135,8 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
return []
# 过滤种子
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始过滤资源,当前规则:{priority_rule} ...')
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
torrent_list=torrents,
season_episodes=season_episodes,
mediainfo=mediainfo)
if result is not None:
torrents = result
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
# 使用过滤规则再次过滤
torrents = self.filter_torrents_by_rule(torrents=torrents,
mediainfo=mediainfo,
filter_rule=filter_rule)
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
# 开始新进度
self.progress.start(ProgressKey.Search)
# 匹配的资源
_match_torrents = []
# 总数
@@ -165,28 +144,32 @@ class SearchChain(ChainBase):
# 已处理数
_count = 0
if mediainfo:
self.progress.start(ProgressKey.Search)
logger.info(f'开始匹配,总 {_total} 个资源 ...')
# 英文标题应该在别名/原标题中,不需要再匹配
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
for torrent in torrents:
_count += 1
self.progress.update(value=(_count / _total) * 100,
self.progress.update(value=(_count / _total) * 96,
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
key=ProgressKey.Search)
# 比对IMDBID
if torrent.imdbid \
and mediainfo.imdb_id \
and torrent.imdbid == mediainfo.imdb_id:
logger.info(f'{mediainfo.title} 匹配到资源:{torrent.site_name} - {torrent.title}')
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent)
continue
# 识别
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
# 比对类型
if (torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV) \
or (torrent_meta.type != MediaType.TV and mediainfo.type == MediaType.TV):
logger.warn(f'{torrent.site_name} - {torrent.title} 类型不匹配')
# 比对种子识别类型
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value}'
f'需要是 {mediainfo.type.value}不匹配')
continue
# 比对种子在站点中的类型
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category}'
f'需要是 {mediainfo.type.value},不匹配')
continue
# 比对年份
if mediainfo.year:
@@ -231,21 +214,55 @@ class SearchChain(ChainBase):
break
else:
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
self.progress.update(value=100,
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
self.progress.update(value=97,
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
key=ProgressKey.Search)
self.progress.end(ProgressKey.Search)
else:
_match_torrents = torrents
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
# 开始过滤
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
key=ProgressKey.Search)
# 过滤种子
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始优先级规则过滤,当前规则:{priority_rule} ...')
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
torrent_list=_match_torrents,
season_episodes=season_episodes,
mediainfo=mediainfo)
if result is not None:
_match_torrents = result
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
# 使用过滤规则再次过滤
if filter_rule:
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
mediainfo=mediainfo,
filter_rule=filter_rule)
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
# 去掉mediainfo中多余的数据
mediainfo.clear()
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
media_info=mediainfo,
torrent_info=torrent) for torrent in _match_torrents]
logger.info(f"过滤完成,剩余 {_total} 个资源")
self.progress.update(value=99, text=f'过滤完成,剩余 {_total} 个资源', key=ProgressKey.Search)
# 排序
self.progress.update(value=100,
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
key=ProgressKey.Search)
contexts = self.torrenthelper.sort_torrents(contexts)
# 结束进度
self.progress.end(ProgressKey.Search)
# 返回
return contexts
@@ -352,102 +369,13 @@ class SearchChain(ChainBase):
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
if not filter_rule:
return torrents
# 包含
include = filter_rule.get("include")
# 排除
exclude = filter_rule.get("exclude")
# 质量
quality = filter_rule.get("quality")
# 分辨率
resolution = filter_rule.get("resolution")
# 特效
effect = filter_rule.get("effect")
# 电影大小
movie_size = filter_rule.get("movie_size")
# 剧集单集大小
tv_size = filter_rule.get("tv_size")
def __get_size_range(size_str: str) -> Tuple[float, float]:
"""
获取大小范围
"""
if not size_str:
return 0, 0
try:
size_range = size_str.split("-")
if len(size_range) == 1:
return 0, float(size_range[0])
elif len(size_range) == 2:
return float(size_range[0]), float(size_range[1])
except Exception as e:
print(str(e))
return 0, 0
def __filter_torrent(t: TorrentInfo) -> bool:
"""
过滤种子
"""
# 包含
if include:
if not re.search(r"%s" % include,
f"{t.title} {t.description}", re.I):
logger.info(f"{t.title} 不匹配包含规则 {include}")
return False
# 排除
if exclude:
if re.search(r"%s" % exclude,
f"{t.title} {t.description}", re.I):
logger.info(f"{t.title} 匹配排除规则 {exclude}")
return False
# 质量
if quality:
if not re.search(r"%s" % quality, t.title, re.I):
logger.info(f"{t.title} 不匹配质量规则 {quality}")
return False
# 分辨率
if resolution:
if not re.search(r"%s" % resolution, t.title, re.I):
logger.info(f"{t.title} 不匹配分辨率规则 {resolution}")
return False
# 特效
if effect:
if not re.search(r"%s" % effect, t.title, re.I):
logger.info(f"{t.title} 不匹配特效规则 {effect}")
return False
# 大小
if movie_size or tv_size:
if mediainfo.type == MediaType.TV:
size = tv_size
else:
size = movie_size
# 大小范围
begin_size, end_size = __get_size_range(size)
if begin_size is not None and end_size is not None:
meta = MetaInfo(title=t.title, subtitle=t.description)
# 集数
if mediainfo.type == MediaType.TV:
# 电视剧
season = meta.begin_season or 1
if meta.total_episode:
# 识别的总集数
episodes_num = meta.total_episode
else:
# 整季集数
episodes_num = len(mediainfo.seasons.get(season) or [1])
# 比较大小
if not (begin_size * 1024 ** 3 <= (t.size / episodes_num) <= end_size * 1024 ** 3):
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} "
f"{episodes_num}集,不匹配大小规则 {size}")
return False
else:
# 电影比较大小
if not (begin_size * 1024 ** 3 <= t.size <= end_size * 1024 ** 3):
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} 不匹配大小规则 {size}")
return False
return True
# 使用默认过滤规则再次过滤
return list(filter(lambda t: __filter_torrent(t), torrents))
return list(filter(
lambda t: self.torrenthelper.filter_torrent(
torrent_info=t,
filter_rule=filter_rule,
mediainfo=mediainfo
),
torrents
))

View File

@@ -1,16 +1,27 @@
import base64
import re
from typing import Union, Tuple
from typing import Tuple, Optional
from typing import Union
from urllib.parse import urljoin
from lxml import etree
from app.chain import ChainBase
from app.core.config import settings
from app.core.event import eventmanager, Event, EventManager
from app.db.models.site import Site
from app.db.site_oper import SiteOper
from app.db.siteicon_oper import SiteIconOper
from app.helper.browser import PlaywrightHelper
from app.helper.cloudflare import under_challenge
from app.helper.cookie import CookieHelper
from app.helper.cookiecloud import CookieCloudHelper
from app.helper.message import MessageHelper
from app.helper.rss import RssHelper
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas import MessageChannel, Notification
from app.schemas.types import EventType
from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
from app.utils.string import StringUtils
@@ -24,8 +35,16 @@ class SiteChain(ChainBase):
def __init__(self):
super().__init__()
self.siteoper = SiteOper()
self.siteiconoper = SiteIconOper()
self.siteshelper = SitesHelper()
self.rsshelper = RssHelper()
self.cookiehelper = CookieHelper()
self.message = MessageHelper()
self.cookiecloud = CookieCloudHelper(
server=settings.COOKIECLOUD_HOST,
key=settings.COOKIECLOUD_KEY,
password=settings.COOKIECLOUD_PASSWORD
)
# 特殊站点登录验证
self.special_site_test = {
@@ -87,6 +106,190 @@ class SiteChain(ChainBase):
return True, "连接成功"
return False, "Cookie已失效"
@staticmethod
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
"""
解析站点favicon,返回base64 fav图标
:param url: 站点地址
:param cookie: Cookie
:param ua: User-Agent
:return:
"""
favicon_url = urljoin(url, "favicon.ico")
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
if res:
html_text = res.text
else:
logger.error(f"获取站点页面失败:{url}")
return favicon_url, None
html = etree.HTML(html_text)
if html:
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
if fav_link:
favicon_url = urljoin(url, fav_link[0])
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
logger.error(f"获取站点图标失败:{favicon_url}")
return favicon_url, None
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
"""
通过CookieCloud同步站点Cookie
"""
def __indexer_domain(inx: dict, sub_domain: str) -> str:
"""
根据主域名获取索引器地址
"""
if StringUtils.get_url_domain(inx.get("domain")) == sub_domain:
return inx.get("domain")
for ext_d in inx.get("ext_domains"):
if StringUtils.get_url_domain(ext_d) == sub_domain:
return ext_d
return sub_domain
logger.info("开始同步CookieCloud站点 ...")
cookies, msg = self.cookiecloud.download()
if not cookies:
logger.error(f"CookieCloud同步失败{msg}")
if manual:
self.message.put(f"CookieCloud同步失败 {msg}")
return False, msg
# 保存Cookie或新增站点
_update_count = 0
_add_count = 0
_fail_count = 0
for domain, cookie in cookies.items():
# 索引器信息
indexer = self.siteshelper.get_indexer(domain)
# 数据库的站点信息
site_info = self.siteoper.get_by_domain(domain)
if site_info:
# 站点已存在,检查站点连通性
status, msg = self.test(domain)
# 更新站点Cookie
if status:
logger.info(f"站点【{site_info.name}】连通性正常不同步CookieCloud数据")
# 更新站点rss地址
if not site_info.public and not site_info.rss:
# 自动生成rss地址
rss_url, errmsg = self.rsshelper.get_rss_link(
url=site_info.url,
cookie=cookie,
ua=settings.USER_AGENT,
proxy=True if site_info.proxy else False
)
if rss_url:
logger.info(f"更新站点 {domain} RSS地址 ...")
self.siteoper.update_rss(domain=domain, rss=rss_url)
else:
logger.warn(errmsg)
continue
# 更新站点Cookie
logger.info(f"更新站点 {domain} Cookie ...")
self.siteoper.update_cookie(domain=domain, cookies=cookie)
_update_count += 1
elif indexer:
# 新增站点
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
res = RequestUtils(cookies=cookie,
ua=settings.USER_AGENT
).get_res(url=domain_url)
if res and res.status_code in [200, 500, 403]:
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
_fail_count += 1
if under_challenge(res.text):
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护无法登录无法添加站点")
continue
logger.warn(
f"站点 {indexer.get('name')} 登录失败没有该站点账号或Cookie已失效无法添加站点")
continue
elif res is not None:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
continue
else:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
continue
# 获取rss地址
rss_url = None
if not indexer.get("public") and domain_url:
# 自动生成rss地址
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
cookie=cookie,
ua=settings.USER_AGENT)
if errmsg:
logger.warn(errmsg)
# 插入数据库
logger.info(f"新增站点 {indexer.get('name')} ...")
self.siteoper.add(name=indexer.get("name"),
url=domain_url,
domain=domain,
cookie=cookie,
rss=rss_url,
public=1 if indexer.get("public") else 0)
_add_count += 1
# 通知缓存站点图标
if indexer:
EventManager().send_event(EventType.CacheSiteIcon, {
"domain": domain,
})
# 处理完成
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
if _fail_count > 0:
ret_msg += f"{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.message.put(f"CookieCloud同步成功, {ret_msg}")
logger.info(f"CookieCloud同步成功{ret_msg}")
return True, ret_msg
@eventmanager.register(EventType.CacheSiteIcon)
def cache_site_icon(self, event: Event):
"""
缓存站点图标
"""
if not event:
return
event_data = event.event_data or {}
# 主域名
domain = event_data.get("domain")
if not domain:
return
if str(domain).startswith("http"):
domain = StringUtils.get_url_domain(domain)
# 站点信息
siteinfo = self.siteoper.get_by_domain(domain)
if not siteinfo:
logger.warn(f"未维护站点 {domain} 信息!")
return
# Cookie
cookie = siteinfo.cookie
# 索引器
indexer = self.siteshelper.get_indexer(domain)
if not indexer:
logger.warn(f"站点 {domain} 索引器不存在!")
return
# 查询站点图标
site_icon = self.siteiconoper.get_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:
self.siteiconoper.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')} 图标失败")
def test(self, url: str) -> Tuple[bool, str]:
"""
测试站点是否可用
@@ -161,7 +364,7 @@ class SiteChain(ChainBase):
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
f"\n- 禁用站点:/site_disable [id]" \
f"\n- 启用站点:/site_enable [id]" \
f"\n- 更新站点Cookie/site_cookie [id] [username] [password]"
f"\n- 更新站点Cookie/site_cookie [id] [username] [password] [2fa_code/secret]"
messages = []
for site in site_list:
if site.render:
@@ -227,12 +430,13 @@ class SiteChain(ChainBase):
self.remote_list(channel, userid)
def update_cookie(self, site_info: Site,
username: str, password: str) -> Tuple[bool, str]:
username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
"""
根据用户名密码更新站点Cookie
:param site_info: 站点信息
:param username: 用户名
:param password: 密码
:param two_step_code: 二步验证码或密钥
:return: (是否成功, 错误信息)
"""
# 更新站点Cookie
@@ -240,6 +444,7 @@ class SiteChain(ChainBase):
url=site_info.url,
username=username,
password=password,
two_step_code=two_step_code,
proxies=settings.PROXY_HOST if site_info.proxy else None
)
if result:
@@ -257,8 +462,8 @@ class SiteChain(ChainBase):
"""
使用用户名密码更新站点Cookie
"""
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password]" \
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码"
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password] [2fa_code/secret]" \
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码[2fa_code/secret]为站点二步验证码或密钥"
if not arg_str:
self.post_message(Notification(
channel=channel,
@@ -266,7 +471,11 @@ class SiteChain(ChainBase):
return
arg_str = str(arg_str).strip()
args = arg_str.split()
if len(args) != 3:
# 二步验证码
two_step_code = None
if len(args) == 4:
two_step_code = args[3]
elif len(args) != 3:
self.post_message(Notification(
channel=channel,
title=err_title, userid=userid))
@@ -296,7 +505,8 @@ class SiteChain(ChainBase):
# 更新Cookie
status, msg = self.update_cookie(site_info=site_info,
username=username,
password=password)
password=password,
two_step_code=two_step_code)
if not status:
logger.error(msg)
self.post_message(Notification(

View File

@@ -18,9 +18,11 @@ from app.db.models.subscribe import Subscribe
from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo, Notification
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType
from app.utils.string import StringUtils
class SubscribeChain(ChainBase):
@@ -37,6 +39,7 @@ class SubscribeChain(ChainBase):
self.mediachain = MediaChain()
self.message = MessageHelper()
self.systemconfig = SystemConfigOper()
self.torrenthelper = TorrentHelper()
def add(self, title: str, year: str,
mtype: MediaType = None,
@@ -71,11 +74,11 @@ class SubscribeChain(ChainBase):
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
else:
# 识别TMDB信息
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
# 识别TMDB信息,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, cache=False)
else:
# 豆瓣识别模式
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid)
# 豆瓣识别模式,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
if mediainfo:
# 豆瓣标题处理
meta = MetaInfo(mediainfo.title)
@@ -96,7 +99,8 @@ class SubscribeChain(ChainBase):
# 补充媒体信息
mediainfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
doubanid=mediainfo.douban_id,
cache=False)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
return None, "媒体信息识别失败"
@@ -197,7 +201,8 @@ class SubscribeChain(ChainBase):
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid)
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -257,10 +262,7 @@ class SubscribeChain(ChainBase):
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
# 站点范围
if subscribe.sites:
sites = json.loads(subscribe.sites)
else:
sites = None
sites = self.get_sub_sites(subscribe)
# 优先级过滤规则
if subscribe.best_version:
@@ -277,7 +279,8 @@ class SubscribeChain(ChainBase):
no_exists=no_exists,
sites=sites,
priority_rule=priority_rule,
filter_rule=filter_rule)
filter_rule=filter_rule,
area="imdbid" if subscribe.search_imdbid else "title")
if not contexts:
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
@@ -416,6 +419,15 @@ class SubscribeChain(ChainBase):
self.torrentschain.refresh(sites=sites)
)
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
"""
获取订阅中涉及的站点清单
"""
if subscribe.sites:
return json.loads(subscribe.sites)
# 默认站点
return self.systemconfig.get(SystemConfigKey.RssSites) or []
def get_subscribed_sites(self) -> Optional[List[int]]:
"""
获取订阅中涉及的所有站点清单(节约资源)
@@ -428,13 +440,8 @@ class SubscribeChain(ChainBase):
ret_sites = []
# 刷新订阅选中的Rss站点
for subscribe in subscribes:
# 如果有一个订阅没有选择站点,则刷新所有订阅站点
if not subscribe.sites:
return []
# 刷新选中的站点
sub_sites = json.loads(subscribe.sites)
if sub_sites:
ret_sites.extend(sub_sites)
ret_sites.extend(self.get_sub_sites(subscribe))
# 去重
if ret_sites:
ret_sites = list(set(ret_sites))
@@ -443,64 +450,20 @@ class SubscribeChain(ChainBase):
def get_filter_rule(self, subscribe: Subscribe):
"""
获取订阅过滤规则,没有则返回默认规则
获取订阅过滤规则,同时组合默认规则
"""
# 默认过滤规则
if (subscribe.include
or subscribe.exclude
or subscribe.quality
or subscribe.resolution
or subscribe.effect):
return {
"include": subscribe.include,
"exclude": subscribe.exclude,
"quality": subscribe.quality,
"resolution": subscribe.resolution,
"effect": subscribe.effect,
}
# 订阅默认过滤规则
return self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
@staticmethod
def check_filter_rule(torrent_info: TorrentInfo, filter_rule: Dict[str, str]) -> bool:
"""
检查种子是否匹配订阅过滤规则
"""
if not filter_rule:
return True
# 包含
include = filter_rule.get("include")
if include:
if not re.search(r"%s" % include,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
return False
# 排除
exclude = filter_rule.get("exclude")
if exclude:
if re.search(r"%s" % exclude,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
return False
# 质量
quality = filter_rule.get("quality")
if quality:
if not re.search(r"%s" % quality, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
return False
# 分辨率
resolution = filter_rule.get("resolution")
if resolution:
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
return False
# 特效
effect = filter_rule.get("effect")
if effect:
if not re.search(r"%s" % effect, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
return False
return True
default_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
return {
"include": subscribe.include or default_rule.get("include"),
"exclude": subscribe.exclude or default_rule.get("exclude"),
"quality": subscribe.quality or default_rule.get("quality"),
"resolution": subscribe.resolution or default_rule.get("resolution"),
"effect": subscribe.effect or default_rule.get("effect"),
"tv_size": default_rule.get("tv_size"),
"movie_size": default_rule.get("movie_size"),
"min_seeders": default_rule.get("min_seeders"),
}
def match(self, torrents: Dict[str, List[Context]]):
"""
@@ -523,7 +486,8 @@ class SubscribeChain(ChainBase):
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid)
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -587,15 +551,73 @@ class SubscribeChain(ChainBase):
# 遍历缓存种子
_match_context = []
for domain, contexts in torrents.items():
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
for context in contexts:
# 检查是否匹配
torrent_meta = context.meta_info
torrent_mediainfo = context.media_info
torrent_info = context.torrent_info
# 比对TMDBID和类型
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
or torrent_mediainfo.type != mediainfo.type:
continue
# 如果识别了媒体信息则比对TMDBID和类型
if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
continue
if torrent_mediainfo.douban_id \
and torrent_mediainfo.douban_id != mediainfo.douban_id:
continue
logger.info(f'{mediainfo.title_year} 通过媒体信ID匹配到资源{torrent_info.site_name} - {torrent_info.title}')
else:
# 按标题匹配
# 比对种子识别类型
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
continue
# 比对种子在站点中的类型
if torrent_info.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
continue
# 比对年份
if mediainfo.year:
if mediainfo.type == MediaType.TV:
# 剧集年份,每季的年份可能不同
if torrent_meta.year and torrent_meta.year not in [year for year in
mediainfo.season_years.values()]:
continue
else:
# 电影年份上下浮动1年
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
mediainfo.year,
str(int(mediainfo.year) + 1)]:
continue
# 标题匹配标志
title_match = False
# 比对标题和原语种标题
meta_name = StringUtils.clear_upper(torrent_meta.name)
if meta_name in [
StringUtils.clear_upper(mediainfo.title),
StringUtils.clear_upper(mediainfo.original_title)
]:
title_match = True
# 在副标题中判断是否存在标题与原语种标题
if not title_match and torrent_info.description:
subtitle = re.split(r'[\s/|]+', torrent_info.description)
if (StringUtils.is_chinese(mediainfo.title)
and str(mediainfo.title) in subtitle) \
or (StringUtils.is_chinese(mediainfo.original_title)
and str(mediainfo.original_title) in subtitle):
title_match = True
# 比对别名和译名
if not title_match:
for name in mediainfo.names:
if StringUtils.clear_upper(name) == meta_name:
title_match = True
break
if not title_match:
continue
# 标题匹配成功
logger.info(f'{mediainfo.title_year} 通过名称匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
# 优先级过滤规则
if subscribe.best_version:
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
@@ -609,12 +631,13 @@ class SubscribeChain(ChainBase):
# 不符合过滤规则
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
continue
# 不在订阅站点范围的不处理
if subscribe.sites:
sub_sites = json.loads(subscribe.sites)
if sub_sites and torrent_info.site not in sub_sites:
logger.info(f"{torrent_info.title} 不符合 {torrent_mediainfo.title_year} 订阅站点要求")
continue
sub_sites = self.get_sub_sites(subscribe)
if sub_sites and torrent_info.site not in sub_sites:
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
continue
# 如果是电视剧
if torrent_mediainfo.type == MediaType.TV:
# 有多季的不要
@@ -658,8 +681,9 @@ class SubscribeChain(ChainBase):
continue
# 过滤规则
if not self.check_filter_rule(torrent_info=torrent_info,
filter_rule=filter_rule):
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
filter_rule=filter_rule,
mediainfo=torrent_mediainfo):
continue
# 洗版时,优先级小于已下载优先级的不要
@@ -701,7 +725,7 @@ class SubscribeChain(ChainBase):
return
# 遍历订阅
for subscribe in subscribes:
logger.info(f'开始检查订阅{subscribe.name} ...')
logger.info(f'开始更新订阅元数据{subscribe.name} ...')
# 生成元数据
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
@@ -709,14 +733,16 @@ class SubscribeChain(ChainBase):
meta.type = MediaType(subscribe.type)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue
# 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or []
if len(episodes) > (subscribe.total_episode or 0):
if not subscribe.manual_total_episode and len(episodes):
total_episode = len(episodes)
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
logger.info(
@@ -737,7 +763,7 @@ class SubscribeChain(ChainBase):
"total_episode": total_episode,
"lack_episode": lack_episode
})
logger.info(f'订阅 {subscribe.name} 更新完成')
logger.info(f'{subscribe.name} 订阅元数据更新完成')
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
"""

View File

@@ -1,4 +1,5 @@
import re
import traceback
from typing import Dict, List, Union
from cachetools import cached, TTLCache
@@ -15,7 +16,7 @@ from app.helper.sites import SitesHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import Notification
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
@@ -98,7 +99,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址')
return []
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False)
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
if rss_items is None:
# rss过期尝试保留原配置生成新的rss
self.__renew_rss_url(domain=domain, site=site)
@@ -183,6 +184,10 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
logger.info(f'处理资源:{torrent.title} ...')
# 识别
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
# 使用站点种子分类,校正类型识别
if meta.type != MediaType.TV \
and torrent.category == MediaType.TV.value:
meta.type = MediaType.TV
# 识别媒体信息
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
if not mediainfo:
@@ -246,5 +251,5 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
except Exception as e:
print(str(e))
logger.error(f"站点 {domain} RSS链接自动获取失败{str(e)} - {traceback.format_exc()}")
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))

View File

@@ -482,7 +482,7 @@ class TransferChain(ChainBase):
return
@staticmethod
def get_root_path(path: str, type_name: str, category: str) -> Path:
def get_root_path(path: str, type_name: str, category: str) -> Optional[Path]:
"""
计算媒体库目录的根路径
"""

View File

@@ -315,8 +315,7 @@ class Command(metaclass=Singleton):
else:
logger.info(f"{command.get('description')} 执行完成")
except Exception as err:
logger.error(f"执行命令 {cmd} 出错:{str(err)}")
traceback.print_exc()
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
@staticmethod
def send_plugin_event(etype: EventType, data: dict) -> None:

View File

@@ -3,7 +3,7 @@ import sys
from pathlib import Path
from typing import List, Optional
from pydantic import BaseSettings
from pydantic import BaseSettings, validator
from app.utils.system import SystemUtils
@@ -32,7 +32,7 @@ class Settings(BaseSettings):
# 是否开发模式
DEV: bool = False
# 配置文件目录
CONFIG_DIR: str = None
CONFIG_DIR: Optional[str] = None
# 超级管理员
SUPERUSER: str = "admin"
# API密钥需要更换
@@ -40,7 +40,7 @@ class Settings(BaseSettings):
# 登录页面电影海报,tmdb/bing
WALLPAPER: str = "tmdb"
# 网络代理 IP:PORT
PROXY_HOST: str = None
PROXY_HOST: Optional[str] = None
# 媒体识别来源 themoviedb/douban
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
@@ -68,7 +68,7 @@ class Settings(BaseSettings):
'.m4v', '.flv', '.m2ts', '.strm',
'.tp']
# 支持的字幕文件后缀格式
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa']
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
# 支持的音轨文件后缀格式
RMT_AUDIO_TRACK_EXT: list = ['.mka']
# 索引器
@@ -82,27 +82,27 @@ class Settings(BaseSettings):
# 用户认证站点
AUTH_SITE: str = ""
# 交互搜索自动下载用户ID使用,分割
AUTO_DOWNLOAD_USER: str = None
# 消息通知渠道 telegram/wechat/slack多个通知渠道用,分隔
AUTO_DOWNLOAD_USER: Optional[str] = None
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔
MESSAGER: str = "telegram"
# WeChat企业ID
WECHAT_CORPID: str = None
WECHAT_CORPID: Optional[str] = None
# WeChat应用Secret
WECHAT_APP_SECRET: str = None
WECHAT_APP_SECRET: Optional[str] = None
# WeChat应用ID
WECHAT_APP_ID: str = None
WECHAT_APP_ID: Optional[str] = None
# WeChat代理服务器
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
# WeChat Token
WECHAT_TOKEN: str = None
WECHAT_TOKEN: Optional[str] = None
# WeChat EncodingAESKey
WECHAT_ENCODING_AESKEY: str = None
WECHAT_ENCODING_AESKEY: Optional[str] = None
# WeChat 管理员
WECHAT_ADMINS: str = None
WECHAT_ADMINS: Optional[str] = None
# Telegram Bot Token
TELEGRAM_TOKEN: str = None
TELEGRAM_TOKEN: Optional[str] = None
# Telegram Chat ID
TELEGRAM_CHAT_ID: str = None
TELEGRAM_CHAT_ID: Optional[str] = None
# Telegram 用户ID使用,分隔
TELEGRAM_USERS: str = ""
# Telegram 管理员ID使用,分隔
@@ -117,16 +117,22 @@ class Settings(BaseSettings):
SYNOLOGYCHAT_WEBHOOK: str = ""
# SynologyChat Token
SYNOLOGYCHAT_TOKEN: str = ""
# VoceChat地址
VOCECHAT_HOST: str = ""
# VoceChat ApiKey
VOCECHAT_API_KEY: str = ""
# VoceChat 频道ID
VOCECHAT_CHANNEL_ID: str = ""
# 下载器 qbittorrent/transmission
DOWNLOADER: str = "qbittorrent"
# 下载器监控开关
DOWNLOADER_MONITOR: bool = True
# Qbittorrent地址IP:PORT
QB_HOST: str = None
QB_HOST: Optional[str] = None
# Qbittorrent用户名
QB_USER: str = None
QB_USER: Optional[str] = None
# Qbittorrent密码
QB_PASSWORD: str = None
QB_PASSWORD: Optional[str] = None
# Qbittorrent分类自动管理
QB_CATEGORY: bool = False
# Qbittorrent按顺序下载
@@ -134,21 +140,21 @@ class Settings(BaseSettings):
# Qbittorrent忽略队列限制强制继续
QB_FORCE_RESUME: bool = False
# Transmission地址IP:PORT
TR_HOST: str = None
TR_HOST: Optional[str] = None
# Transmission用户名
TR_USER: str = None
TR_USER: Optional[str] = None
# Transmission密码
TR_PASSWORD: str = None
TR_PASSWORD: Optional[str] = None
# 种子标签
TORRENT_TAG: str = "MOVIEPILOT"
# 下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: str = None
DOWNLOAD_PATH: Optional[str] = None
# 电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: str = None
DOWNLOAD_MOVIE_PATH: Optional[str] = None
# 电视剧下载保存目录,容器内映射路径需要一致
DOWNLOAD_TV_PATH: str = None
DOWNLOAD_TV_PATH: Optional[str] = None
# 动漫下载保存目录,容器内映射路径需要一致
DOWNLOAD_ANIME_PATH: str = None
DOWNLOAD_ANIME_PATH: Optional[str] = None
# 下载目录二级分类
DOWNLOAD_CATEGORY: bool = False
# 下载站点字幕
@@ -158,33 +164,33 @@ class Settings(BaseSettings):
# 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
# 媒体服务器同步黑名单,多个媒体库名称,分割
MEDIASERVER_SYNC_BLACKLIST: str = None
MEDIASERVER_SYNC_BLACKLIST: Optional[str] = None
# EMBY服务器地址IP:PORT
EMBY_HOST: str = None
EMBY_HOST: Optional[str] = None
# EMBY外网地址http(s)://DOMAIN:PORT未设置时使用EMBY_HOST
EMBY_PLAY_HOST: str = None
EMBY_PLAY_HOST: Optional[str] = None
# EMBY Api Key
EMBY_API_KEY: str = None
EMBY_API_KEY: Optional[str] = None
# Jellyfin服务器地址IP:PORT
JELLYFIN_HOST: str = None
JELLYFIN_HOST: Optional[str] = None
# Jellyfin外网地址http(s)://DOMAIN:PORT未设置时使用JELLYFIN_HOST
JELLYFIN_PLAY_HOST: str = None
JELLYFIN_PLAY_HOST: Optional[str] = None
# Jellyfin Api Key
JELLYFIN_API_KEY: str = None
JELLYFIN_API_KEY: Optional[str] = None
# Plex服务器地址IP:PORT
PLEX_HOST: str = None
PLEX_HOST: Optional[str] = None
# Plex外网地址http(s)://DOMAIN:PORT未设置时使用PLEX_HOST
PLEX_PLAY_HOST: str = None
PLEX_PLAY_HOST: Optional[str] = None
# Plex Token
PLEX_TOKEN: str = None
PLEX_TOKEN: Optional[str] = None
# 转移方式 link/copy/move/softlink
TRANSFER_TYPE: str = "copy"
# CookieCloud服务器地址
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
# CookieCloud用户KEY
COOKIECLOUD_KEY: str = None
COOKIECLOUD_KEY: Optional[str] = None
# CookieCloud端对端加密密码
COOKIECLOUD_PASSWORD: str = None
COOKIECLOUD_PASSWORD: Optional[str] = None
# CookieCloud同步间隔分钟
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
# OCR服务器地址
@@ -192,13 +198,13 @@ class Settings(BaseSettings):
# CookieCloud对应的浏览器UA
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
# 媒体库目录,多个目录使用,分隔
LIBRARY_PATH: str = None
LIBRARY_PATH: Optional[str] = None
# 电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影"
# 电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧"
# 动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: str = None
LIBRARY_ANIME_NAME: Optional[str] = None
# 二级分类
LIBRARY_CATEGORY: bool = True
# 电视剧动漫的分类genre_ids
@@ -219,10 +225,22 @@ class Settings(BaseSettings):
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
# Github token提高请求api限流阈值 ghp_****
GITHUB_TOKEN: str = None
GITHUB_TOKEN: Optional[str] = None
# 自动检查和更新站点资源包(站点索引、认证等)
AUTO_UPDATE_RESOURCE: bool = True
@validator("SUBSCRIBE_RSS_INTERVAL",
"COOKIECLOUD_INTERVAL",
"MEDIASERVER_SYNC_INTERVAL",
pre=True, always=True)
def convert_int(cls, value):
if not value:
return 0
try:
return int(value)
except (ValueError, TypeError):
raise ValueError(f"{value} 格式错误,不是有效数字!")
@property
def INNER_CONFIG_PATH(self):
return self.ROOT_PATH / "config"

View File

@@ -57,6 +57,8 @@ class TorrentInfo:
labels: list = field(default_factory=list)
# 种子优先级
pri_order: int = 0
# 种子分类 电影/电视剧
category: str = None
def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value
@@ -135,6 +137,8 @@ class MediaInfo:
type: MediaType = None
# 媒体标题
title: str = None
# 英文标题
en_title: str = None
# 年份
year: str = None
# 季
@@ -160,7 +164,7 @@ class MediaInfo:
# LOGO
logo_path: str = None
# 评分
vote_average: int = 0
vote_average: float = 0
# 描述
overview: str = None
# 风格ID
@@ -368,6 +372,8 @@ class MediaInfo:
self.genre_ids = info.get('genre_ids') or []
# 原语种
self.original_language = info.get('original_language')
# 英文标题
self.en_title = info.get('en_title')
if self.type == MediaType.MOVIE:
# 标题
self.title = info.get('title')
@@ -439,6 +445,9 @@ class MediaInfo:
# 标题
if not self.title:
self.title = info.get("title")
# 英文标题,暂时不支持
if not self.en_title:
self.en_title = info.get('original_title')
# 原语种标题
if not self.original_title:
self.original_title = info.get("original_title")

View File

@@ -37,9 +37,13 @@ class EventManager(metaclass=Singleton):
def check(self, etype: EventType):
"""
检查事件是否存在响应
检查事件是否存在响应,去除掉被禁用的事件响应
"""
return etype.value in self._handlers
if etype.value not in self._handlers:
return False
handlers = self._handlers.get(etype.value)
return any([handler for handler in handlers.values()
if handler.__qualname__.split(".")[0] not in self._disabled_handlers])
def add_event_listener(self, etype: EventType, handler: type):
"""
@@ -70,7 +74,7 @@ class EventManager(metaclass=Singleton):
"""
if class_name in self._disabled_handlers:
self._disabled_handlers.remove(class_name)
logger.debug(f"Event Enabled{class_name}")
logger.debug(f"Event Enabled{class_name}")
def send_event(self, etype: EventType, data: dict = None):
"""

View File

@@ -1,9 +1,12 @@
import re
import traceback
import zhconv
import anitopy
from app.core.meta.customization import CustomizationMatcher
from app.core.meta.metabase import MetaBase
from app.core.meta.releasegroup import ReleaseGroupsMatcher
from app.log import logger
from app.utils.string import StringUtils
from app.schemas.types import MediaType
@@ -117,7 +120,7 @@ class MetaAnime(MetaBase):
else:
self.total_episode = 1
except Exception as err:
print(str(err))
logger.debug(f"解析集数失败:{str(err)} - {traceback.format_exc()}")
self.begin_episode = None
self.end_episode = None
self.type = MediaType.TV
@@ -162,7 +165,7 @@ class MetaAnime(MetaBase):
if not self.type:
self.type = MediaType.TV
except Exception as e:
print(str(e))
logger.error(f"解析动漫信息失败:{str(e)} - {traceback.format_exc()}")
@staticmethod
def __prepare_title(title: str):

View File

@@ -1,9 +1,11 @@
import traceback
from dataclasses import dataclass, asdict
from typing import Union, Optional, List, Self
import cn2an
import regex as re
from app.log import logger
from app.utils.string import StringUtils
from app.schemas.types import MediaType
@@ -127,7 +129,7 @@ class MetaBase(object):
else:
begin_season = int(cn2an.cn2an(seasons, mode='smart'))
except Exception as err:
print(str(err))
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
return
if self.begin_season is None and isinstance(begin_season, int):
self.begin_season = begin_season
@@ -158,7 +160,7 @@ class MetaBase(object):
else:
begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
except Exception as err:
print(str(err))
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if self.begin_episode is None and isinstance(begin_episode, int):
self.begin_episode = begin_episode
@@ -181,7 +183,7 @@ class MetaBase(object):
try:
self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
except Exception as err:
print(str(err))
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
self.begin_episode = None
self.end_episode = None
@@ -197,7 +199,7 @@ class MetaBase(object):
try:
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
except Exception as err:
print(str(err))
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
return
self.begin_season = 1
self.end_season = self.total_season

View File

@@ -1,9 +1,11 @@
import traceback
from typing import List, Tuple
import cn2an
import regex as re
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.singleton import Singleton
@@ -24,7 +26,7 @@ class WordsMatcher(metaclass=Singleton):
# 读取自定义识别词
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
for word in words:
if not word:
if not word or word.find('#') == 0:
continue
try:
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
@@ -62,7 +64,7 @@ class WordsMatcher(metaclass=Singleton):
appley_words.append(word)
except Exception as err:
print(str(err))
logger.error(f"自定义识别词预处理标题失败:{str(err)} - {traceback.format_exc()}")
return title, appley_words
@@ -77,7 +79,7 @@ class WordsMatcher(metaclass=Singleton):
else:
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
except Exception as err:
print(str(err))
logger.error(f"自定义识别词正则替换失败:{str(err)} - {traceback.format_exc()}")
return title, str(err), False
@staticmethod
@@ -129,5 +131,5 @@ class WordsMatcher(metaclass=Singleton):
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
return title, "", True
except Exception as err:
print(str(err))
logger.error(f"自定义识别词集数偏移失败:{str(err)} - {traceback.format_exc()}")
return title, str(err), False

View File

@@ -1,4 +1,4 @@
from typing import Generator, Optional
from typing import Generator, Optional, Tuple
from app.core.config import settings
from app.helper.module import ModuleHelper
@@ -51,6 +51,18 @@ class ModuleManager(metaclass=Singleton):
if hasattr(module, "stop"):
module.stop()
def test(self, modleid: str) -> Tuple[bool, str]:
"""
测试模块
"""
if modleid not in self._running_modules:
return False, "模块未加载,请检查参数设置"
module = self._running_modules[modleid]
if hasattr(module, "test") \
and ObjectUtils.check_method(getattr(module, "test")):
return module.test()
return True, "模块不支持测试"
@staticmethod
def check_setting(setting: Optional[tuple]) -> bool:
"""

View File

@@ -72,9 +72,12 @@ class PluginManager(metaclass=Singleton):
plugin_obj.init_plugin(self.get_plugin_config(plugin_id))
# 存储运行实例
self._running_plugins[plugin_id] = plugin_obj
logger.info(f"Plugin Loaded{plugin_id}")
# 设置事件注册状态可用
eventmanager.enable_events_hander(plugin_id)
logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
# 启用的插件才设置事件注册状态可用
if plugin_obj.get_state():
eventmanager.enable_events_hander(plugin_id)
else:
eventmanager.disable_events_hander(plugin_id)
except Exception as err:
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
@@ -85,6 +88,12 @@ class PluginManager(metaclass=Singleton):
if not self._running_plugins.get(plugin_id):
return
self._running_plugins[plugin_id].init_plugin(conf)
if self._running_plugins[plugin_id].get_state():
# 设置启用的插件事件注册状态可用
eventmanager.enable_events_hander(plugin_id)
else:
# 设置事件状态为不可用
eventmanager.disable_events_hander(plugin_id)
def stop(self):
"""
@@ -137,7 +146,11 @@ class PluginManager(metaclass=Singleton):
"""
if not self._plugins.get(pid):
return {}
return self.systemconfig.get(self._config_key % pid) or {}
conf = self.systemconfig.get(self._config_key % pid)
if conf:
# 去掉空Key
return {k: v for k, v in conf.items() if k}
return {}
def save_plugin_config(self, pid: str, conf: dict) -> bool:
"""
@@ -233,6 +246,16 @@ class PluginManager(metaclass=Singleton):
ret_services.extend(services)
return ret_services
def get_plugin_attr(self, pid: str, attr: str) -> Any:
"""
获取插件属性
"""
if not self._running_plugins.get(pid):
return None
if not hasattr(self._running_plugins[pid], attr):
return None
return getattr(self._running_plugins[pid], attr)
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
"""
运行插件方法
@@ -249,6 +272,12 @@ class PluginManager(metaclass=Singleton):
"""
return list(self._plugins.keys())
def get_running_plugin_ids(self) -> List[str]:
"""
获取所有运行态插件ID
"""
return list(self._running_plugins.keys())
def get_online_plugins(self) -> List[dict]:
"""
获取所有在线插件信息

View File

@@ -3,12 +3,13 @@ import hashlib
import hmac
import json
import os
import traceback
from datetime import datetime, timedelta
from typing import Any, Union, Optional
from typing import Any, Union, Optional, Annotated
import jwt
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from fastapi import HTTPException, status, Depends
from fastapi import HTTPException, status, Depends, Header
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
@@ -16,6 +17,8 @@ from app import schemas
from app.core.config import settings
from cryptography.fernet import Fernet
from app.log import logger
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
@@ -65,11 +68,11 @@ def get_token(token: str = None) -> str:
return token
def get_apikey(apikey: str = None) -> str:
def get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str:
"""
从请求URL中获取apikey
"""
return apikey
return apikey or x_api_key
def verify_uri_token(token: str = Depends(get_token)) -> str:
@@ -112,7 +115,7 @@ def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
try:
return fernet.decrypt(data)
except Exception as e:
print(str(e))
logger.error(f"解密失败:{str(e)} - {traceback.format_exc()}")
return None

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import List
from app.db import DbOper
@@ -10,12 +9,12 @@ class DownloadHistoryOper(DbOper):
下载历史管理
"""
def get_by_path(self, path: Path) -> DownloadHistory:
def get_by_path(self, path: str) -> DownloadHistory:
"""
按路径查询下载记录
:param path: 数据key
"""
return DownloadHistory.get_by_path(self._db, str(path))
return DownloadHistory.get_by_path(self._db, path)
def get_by_hash(self, download_hash: str) -> DownloadHistory:
"""

View File

@@ -67,6 +67,10 @@ class Subscribe(Base):
current_priority = Column(Integer)
# 保存路径
save_path = Column(String)
# 是否使用 imdbid 搜索
search_imdbid = Column(Integer, default=0)
# 是否手动修改过总集数 0否 1是
manual_total_episode = Column(Integer, default=0)
@staticmethod
@db_query

View File

@@ -28,18 +28,21 @@ class PluginDataOper(DbOper):
else:
PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db)
def get_data(self, plugin_id: str, key: str) -> Any:
def get_data(self, plugin_id: str, key: str = None) -> Any:
"""
获取插件数据
:param plugin_id: 插件id
:param key: 数据key
"""
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
if not data:
return None
if ObjectUtils.is_obj(data.value):
return json.loads(data.value)
return data.value
if key:
data = PluginData.get_plugin_data_by_key(self._db, plugin_id, key)
if not data:
return None
if ObjectUtils.is_obj(data.value):
return json.loads(data.value)
return data.value
else:
return PluginData.get_plugin_data(self._db, plugin_id)
def del_data(self, plugin_id: str, key: str) -> Any:
"""

View File

@@ -26,9 +26,9 @@ class SiteIconOper(DbOper):
更新站点图标
"""
icon_base64 = f"data:image/ico;base64,{icon_base64}" if icon_base64 else ""
siteicon = SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64)
if not self.get_by_domain(domain):
siteicon.create(self._db)
siteicon = self.get_by_domain(domain)
if not siteicon:
SiteIcon(name=name, domain=domain, url=icon_url, base64=icon_base64).create(self._db)
elif icon_base64:
siteicon.update(self._db, {
"url": icon_url,

View File

@@ -6,6 +6,7 @@ from playwright.sync_api import Page
from app.helper.browser import PlaywrightHelper
from app.helper.ocr import OcrHelper
from app.helper.twofa import TwoFactorAuth
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
@@ -51,7 +52,8 @@ class CookieHelper:
],
"twostep": [
'//input[@name="two_step_code"]',
'//input[@name="2fa_secret"]'
'//input[@name="2fa_secret"]',
'//input[@name="otp"]'
]
}
@@ -71,12 +73,14 @@ class CookieHelper:
url: str,
username: str,
password: str,
two_step_code: str = None,
proxies: dict = None) -> Tuple[Optional[str], Optional[str], str]:
"""
获取站点cookie和ua
:param url: 站点地址
:param username: 用户名
:param password: 密码
:param two_step_code: 二步验证码或密钥
:param proxies: 代理
:return: cookie、ua、message
"""
@@ -107,6 +111,15 @@ class CookieHelper:
break
if not password_xpath:
return None, None, "未找到密码输入框"
# 处理二步验证码
otp_code = TwoFactorAuth(two_step_code).get_code()
# 查找二步验证码输入框
twostep_xpath = None
if otp_code:
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
if html.xpath(xpath):
twostep_xpath = xpath
break
# 查找验证码输入框
captcha_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
@@ -138,6 +151,9 @@ class CookieHelper:
page.fill(username_xpath, username)
# 输入密码
page.fill(password_xpath, password)
# 输入二步验证码
if twostep_xpath:
page.fill(twostep_xpath, otp_code)
# 识别验证码
if captcha_xpath and captcha_img_url:
captcha_element = page.query_selector(captcha_xpath)
@@ -164,6 +180,24 @@ class CookieHelper:
except Exception as e:
logger.error(f"仿真登录失败:{str(e)}")
return None, None, f"仿真登录失败:{str(e)}"
# 对于某二次验证码为单页面的站点,输入二次验证码
if "verify" in page.url:
if not otp_code:
return None, None, "需要二次验证码"
html = etree.HTML(page.content())
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
if html.xpath(xpath):
try:
# 刷新一下 2fa code
otp_code = TwoFactorAuth(two_step_code).get_code()
page.fill(xpath, otp_code)
# 登录按钮 xpath 理论上相同,不再重复查找
page.click(submit_xpath)
page.wait_for_load_state("networkidle", timeout=30 * 1000)
except Exception as e:
logger.error(f"二次验证码输入失败:{str(e)}")
return None, None, f"二次验证码输入失败:{str(e)}"
break
# 登录后的源码
html_text = page.content()
if not html_text:

View File

@@ -8,7 +8,7 @@ class CookieCloudHelper:
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
def __init__(self, server, key, password):
def __init__(self, server: str, key: str, password: str):
self._server = server
self._key = key
self._password = password
@@ -21,8 +21,8 @@ class CookieCloudHelper:
"""
if not self._server or not self._key or not self._password:
return None, "CookieCloud参数不正确"
req_url = "%s/get/%s" % (self._server, self._key)
ret = self._req.post_res(url=req_url, json={"password": self._password})
req_url = "%s/get/%s" % (self._server, str(self._key).strip())
ret = self._req.post_res(url=req_url, json={"password": str(self._password).strip()})
if ret and ret.status_code == 200:
result = ret.json()
if not result:

View File

@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
import importlib
import pkgutil
import traceback
from pathlib import Path
from app.log import logger
class ModuleHelper:
"""
@@ -33,7 +36,7 @@ class ModuleHelper:
if isinstance(obj, type) and filter_func(name, obj):
submodules.append(obj)
except Exception as err:
print(f'加载模块 {package_name} 失败:{err}')
logger.error(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
return submodules

View File

@@ -1,11 +1,13 @@
import json
import shutil
import traceback
from pathlib import Path
from typing import Dict, Tuple, Optional, List
from cachetools import TTLCache, cached
from app.core.config import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.system import SystemUtils
@@ -18,7 +20,7 @@ class PluginHelper(metaclass=Singleton):
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
@cached(cache=TTLCache(maxsize=10, ttl=1800))
@cached(cache=TTLCache(maxsize=100, ttl=1800))
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
"""
获取Github所有最新插件列表
@@ -33,7 +35,11 @@ class PluginHelper(metaclass=Singleton):
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=10).get_res(f"{raw_url}package.json")
if res:
return json.loads(res.text)
try:
return json.loads(res.text)
except json.JSONDecodeError:
logger.error(f"插件包数据解析失败:{res.text}")
return {}
return {}
@staticmethod
@@ -51,7 +57,7 @@ class PluginHelper(metaclass=Singleton):
try:
user, repo = repo_url.split("/")[-4:-2]
except Exception as e:
print(str(e))
logger.error(f"解析Github仓库地址失败{str(e)} - {traceback.format_exc()}")
return None, None
return user, repo

View File

@@ -34,7 +34,11 @@ class ResourceHelper(metaclass=Singleton):
logger.info("开始检测资源包版本...")
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
if res:
resource_info = json.loads(res.text)
try:
resource_info = json.loads(res.text)
except json.JSONDecodeError:
logger.error("资源包仓库数据解析失败!")
return
else:
logger.warn("无法连接资源包仓库!")
return

View File

@@ -1,4 +1,5 @@
import re
import traceback
import xml.dom.minidom
from typing import List, Tuple, Union
from urllib.parse import urljoin
@@ -224,11 +225,12 @@ class RssHelper:
}
@staticmethod
def parse(url, proxy: bool = False) -> Union[List[dict], None]:
def parse(url, proxy: bool = False, timeout: int = 30) -> Union[List[dict], None]:
"""
解析RSS订阅URL获取RSS中的种子信息
:param url: RSS地址
:param proxy: 是否使用代理
:param timeout: 请求超时
:return: 种子信息列表如为None代表Rss过期
"""
# 开始处理
@@ -236,11 +238,11 @@ class RssHelper:
if not url:
return []
try:
ret = RequestUtils(proxies=settings.PROXY if proxy else None).get_res(url)
ret = RequestUtils(proxies=settings.PROXY if proxy else None, timeout=timeout).get_res(url)
if not ret:
return []
except Exception as err:
print(str(err))
logger.error(f"获取RSS失败{str(err)} - {traceback.format_exc()}")
return []
if ret:
ret_xml = ""
@@ -306,10 +308,10 @@ class RssHelper:
'pubdate': pubdate}
ret_array.append(tmp_dict)
except Exception as e1:
print(str(e1))
logger.debug(f"解析RSS失败{str(e1)} - {traceback.format_exc()}")
continue
except Exception as e2:
print(str(e2))
logger.error(f"解析RSS失败{str(e2)} - {traceback.format_exc()}")
# RSS过期 观众RSS 链接已过期,您需要获得一个新的! pthome RSS Link has expired, You need to get a new one!
_rss_expired_msg = [
"RSS 链接已过期, 您需要获得一个新的!",

View File

@@ -1,20 +1,22 @@
import datetime
import re
import traceback
from pathlib import Path
from typing import Tuple, Optional, List, Union
from typing import Tuple, Optional, List, Union, Dict
from urllib.parse import unquote
from requests import Response
from torrentool.api import Torrent
from app.core.config import settings
from app.core.context import Context
from app.core.context import Context, TorrentInfo, MediaInfo
from app.core.metainfo import MetaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.utils.http import RequestUtils
from app.schemas.types import MediaType, SystemConfigKey
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class TorrentHelper(metaclass=Singleton):
@@ -295,3 +297,103 @@ class TorrentHelper(metaclass=Singleton):
"""
if url not in self._invalid_torrents:
self._invalid_torrents.append(url)
@staticmethod
def filter_torrent(torrent_info: TorrentInfo,
filter_rule: Dict[str, str],
mediainfo: MediaInfo) -> bool:
"""
检查种子是否匹配订阅过滤规则
"""
def __get_size_range(size_str: str) -> Tuple[float, float]:
"""
获取大小范围
"""
if not size_str:
return 0, 0
try:
size_range = size_str.split("-")
if len(size_range) == 1:
return 0, float(size_range[0])
elif len(size_range) == 2:
return float(size_range[0]), float(size_range[1])
except Exception as e:
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
return 0, 0
if not filter_rule:
return True
# 最少做种人数
min_seeders = filter_rule.get("min_seeders")
if min_seeders and torrent_info.seeders < int(min_seeders):
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
return False
# 包含
include = filter_rule.get("include")
if include:
if not re.search(r"%s" % include,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
return False
# 排除
exclude = filter_rule.get("exclude")
if exclude:
if re.search(r"%s" % exclude,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
return False
# 质量
quality = filter_rule.get("quality")
if quality:
if not re.search(r"%s" % quality, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
return False
# 分辨率
resolution = filter_rule.get("resolution")
if resolution:
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
return False
# 特效
effect = filter_rule.get("effect")
if effect:
if not re.search(r"%s" % effect, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
return False
# 大小
tv_size = filter_rule.get("tv_size")
movie_size = filter_rule.get("movie_size")
if movie_size or tv_size:
if mediainfo.type == MediaType.TV:
size = tv_size
else:
size = movie_size
# 大小范围
begin_size, end_size = __get_size_range(size)
if begin_size or end_size:
meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description)
# 集数
if mediainfo.type == MediaType.TV:
# 电视剧
season = meta.begin_season or 1
if meta.total_episode:
# 识别的总集数
episodes_num = meta.total_episode
else:
# 整季集数
episodes_num = len(mediainfo.seasons.get(season) or [1])
# 比较大小
if not (begin_size * 1024 ** 3 <= (torrent_info.size / episodes_num) <= end_size * 1024 ** 3):
logger.info(f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} "
f"{episodes_num}集,不匹配大小规则 {size}")
return False
else:
# 电影比较大小
if not (begin_size * 1024 ** 3 <= torrent_info.size <= end_size * 1024 ** 3):
logger.info(
f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} 不匹配大小规则 {size}")
return False
return True

43
app/helper/twofa.py Normal file
View File

@@ -0,0 +1,43 @@
import base64
import hashlib
import hmac
import struct
import sys
import time
from app.log import logger
class TwoFactorAuth:
def __init__(self, code_or_secret: str):
if code_or_secret and len(code_or_secret) > 16:
self.code = None
self.secret = code_or_secret
else:
self.code = code_or_secret
self.secret = None
@staticmethod
def __calc(secret_key: str) -> str:
if not secret_key:
return ""
try:
input_time = int(time.time()) // 30
key = base64.b32decode(secret_key)
msg = struct.pack(">Q", input_time)
google_code = hmac.new(key, msg, hashlib.sha1).digest()
o = (
google_code[19] & 15
if sys.version_info > (2, 7)
else ord(str(google_code[19])) & 15
)
google_code = str(
(struct.unpack(">I", google_code[o: o + 4])[0] & 0x7FFFFFFF) % 1000000
)
return f"0{google_code}" if len(google_code) == 5 else google_code
except Exception as e:
logger.error(f"计算动态验证码失败:{str(e)}")
return ""
def get_code(self) -> str:
return self.code or self.__calc(self.secret)

View File

@@ -1,6 +1,8 @@
import inspect
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Dict, Any
import click
@@ -26,32 +28,156 @@ class CustomFormatter(logging.Formatter):
def format(self, record):
seperator = " " * (8 - len(record.levelname))
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
if record.filename == "__init__.py":
record.filename = Path(record.pathname).parent.name
return super().format(record)
# DEBUG
logger = logging.getLogger()
if settings.DEBUG:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
class LoggerManager:
"""
日志管理
"""
# 管理所有的Logger
_loggers: Dict[str, Any] = {}
# 默认日志文件
_default_log_file = "moviepilot.log"
# 终端日志
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = CustomFormatter("%(leveltext)s%(filename)s - %(message)s")
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
@staticmethod
def __get_caller():
"""
获取调用者的文件名称与插件名称(如果是插件调用内置的模块, 也能写入到插件日志文件中)
"""
# 调用者文件名称
caller_name = None
# 调用者插件名称
plugin_name = None
for i in inspect.stack()[3:]:
filepath = Path(i.filename)
parts = filepath.parts
if not caller_name:
# 设定调用者文件名称
if parts[-1] == "__init__.py":
caller_name = parts[-2]
else:
caller_name = parts[-1]
if "app" in parts:
if not plugin_name and "plugins" in parts:
# 设定调用者插件名称
plugin_name = parts[parts.index("plugins") + 1]
if plugin_name == "__init__.py":
plugin_name = "plugin"
break
if "main.py" in parts:
# 已经到达程序的入口
break
elif len(parts) != 1:
# 已经超出程序范围
break
return caller_name or "log.py", plugin_name
# 文件日志
file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log',
mode='w',
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_formater = CustomFormatter("%(levelname)s%(asctime)s - %(filename)s - %(message)s")
file_handler.setFormatter(file_formater)
logger.addHandler(file_handler)
@staticmethod
def __setup_logger(log_file: str):
"""
设置日志
log_file日志文件相对路径
"""
log_file_path = settings.LOG_PATH / log_file
if not log_file_path.parent.exists():
log_file_path.parent.mkdir(parents=True, exist_ok=True)
# 创建新实例
_logger = logging.getLogger(log_file_path.stem)
# DEBUG
if settings.DEBUG:
_logger.setLevel(logging.DEBUG)
else:
_logger.setLevel(logging.INFO)
# 移除已有的 handler避免重复添加
for handler in _logger.handlers:
_logger.removeHandler(handler)
# 终端日志
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = CustomFormatter(f"%(leveltext)s%(message)s")
console_handler.setFormatter(console_formatter)
_logger.addHandler(console_handler)
# 文件日志
file_handler = RotatingFileHandler(filename=log_file_path,
mode='w',
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_formater = CustomFormatter(f"【%(levelname)s】%(asctime)s - %(message)s")
file_handler.setFormatter(file_formater)
_logger.addHandler(file_handler)
return _logger
def logger(self, method: str, msg: str, *args, **kwargs):
"""
获取模块的logger
:param method: 日志方法
:param msg: 日志信息
"""
# 获取调用者文件名和插件名
caller_name, plugin_name = self.__get_caller()
# 区分插件日志
if plugin_name:
# 使用插件日志文件
logfile = Path("plugins") / f"{plugin_name}.log"
else:
# 使用默认日志文件
logfile = self._default_log_file
# 获取调用者的模块的logger
_logger = self._loggers.get(logfile)
if not _logger:
_logger = self.__setup_logger(logfile)
self._loggers[logfile] = _logger
if hasattr(_logger, method):
method = getattr(_logger, method)
method(f"{caller_name} - {msg}", *args, **kwargs)
def info(self, msg: str, *args, **kwargs):
"""
重载info方法
"""
self.logger("info", msg, *args, **kwargs)
def debug(self, msg: str, *args, **kwargs):
"""
重载debug方法
"""
self.logger("debug", msg, *args, **kwargs)
def warning(self, msg: str, *args, **kwargs):
"""
重载warning方法
"""
self.logger("warning", msg, *args, **kwargs)
def warn(self, msg: str, *args, **kwargs):
"""
重载warn方法
"""
self.logger("warning", msg, *args, **kwargs)
def error(self, msg: str, *args, **kwargs):
"""
重载error方法
"""
self.logger("error", msg, *args, **kwargs)
def critical(self, msg: str, *args, **kwargs):
"""
重载critical方法
"""
self.logger("critical", msg, *args, **kwargs)
# 初始化公共日志
logger = LoggerManager()

View File

@@ -35,6 +35,13 @@ class _ModuleBase(metaclass=ABCMeta):
"""
pass
@abstractmethod
def test(self) -> Tuple[bool, str]:
"""
模块测试, 返回测试结果和错误信息
"""
pass
def checkMessage(channel_type: MessageChannel):
"""
@@ -60,6 +67,8 @@ def checkMessage(channel_type: MessageChannel):
return None
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
return None
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
return None
return func(self, message, *args, **kwargs)
return wrapper

View File

@@ -13,6 +13,7 @@ from app.modules.douban.douban_cache import DoubanCache
from app.modules.douban.scraper import DoubanScraper
from app.schemas.types import MediaType
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils
@@ -29,18 +30,31 @@ class DoubanModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
ret = RequestUtils().get_res("https://movie.douban.com/")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
return False, "豆瓣网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: str = None,
cache: bool = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
if settings.RECOGNIZE_SOURCE != "douban":
@@ -48,11 +62,17 @@ class DoubanModule(_ModuleBase):
if not meta:
cache_info = {}
elif not meta.name:
logger.error("识别媒体信息时未提供元数据名称")
return None
else:
if mtype:
meta.type = mtype
if doubanid:
meta.doubanid = doubanid
# 读取缓存
cache_info = self.cache.get(meta)
if not cache_info:
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存
if doubanid:
# 直接查询详情
@@ -80,7 +100,7 @@ class DoubanModule(_ModuleBase):
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
return None
# 保存到缓存
if meta:
if meta and cache:
self.cache.update(meta, info)
else:
# 使用缓存信息

View File

@@ -1,6 +1,7 @@
import pickle
import random
import time
import traceback
from pathlib import Path
from threading import RLock
from typing import Optional
@@ -8,6 +9,7 @@ from typing import Optional
from app.core.config import settings
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.singleton import Singleton
from app.schemas.types import MediaType
@@ -49,7 +51,8 @@ class DoubanCache(metaclass=Singleton):
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]{meta.name}-{meta.year}-{meta.begin_season}"
return f"[{meta.type.value if meta.type else '未知'}]" \
f"{meta.name or meta.doubanid}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""
@@ -119,7 +122,7 @@ class DoubanCache(metaclass=Singleton):
return data
return {}
except Exception as e:
print(str(e))
logger.error(f"加载缓存失败: {str(e)} - {traceback.format_exc()}")
return {}
def update(self, meta: MetaBase, info: dict) -> None:

View File

@@ -17,6 +17,16 @@ class EmbyModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.emby.is_inactive():
self.emby.reconnect()
if not self.emby.get_user():
return False, "无法连接Emby请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "emby"

View File

@@ -1,5 +1,6 @@
import json
import re
import traceback
from pathlib import Path
from typing import List, Optional, Union, Dict, Generator, Tuple
@@ -10,10 +11,9 @@ from app.core.config import settings
from app.log import logger
from app.schemas.types import MediaType
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class Emby(metaclass=Singleton):
class Emby:
def __init__(self):
self._host = settings.EMBY_HOST
@@ -535,7 +535,7 @@ class Emby(metaclass=Singleton):
if item_path.is_relative_to(subfolder_path):
return folder.get("Id")
except Exception as err:
print(str(err))
logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}")
# 如果找不到,只要路径中有分类目录名就命中
for folder in self.folders:
for subfolder in folder.get("SubFolders"):
@@ -932,9 +932,9 @@ class Emby(metaclass=Singleton):
"""
if not self._host or not self._apikey:
return None
url = url.replace("[HOST]", self._host) \
.replace("[APIKEY]", self._apikey) \
.replace("[USER]", self.user)
url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user or '')
try:
return RequestUtils(content_type="application/json").get_res(url=url)
except Exception as e:
@@ -950,9 +950,9 @@ class Emby(metaclass=Singleton):
"""
if not self._host or not self._apikey:
return None
url = url.replace("[HOST]", self._host) \
.replace("[APIKEY]", self._apikey) \
.replace("[USER]", self.user)
url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user or '')
try:
return RequestUtils(
headers=headers,

View File

@@ -317,6 +317,17 @@ class FanartModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
ret = RequestUtils().get_res("https://webservice.fanart.tv")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接fanart错误码{ret.status_code}"
return False, "fanart网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FANART_API_KEY", True

View File

@@ -27,6 +27,30 @@ class FileTransferModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if not settings.DOWNLOAD_PATH:
return False, "下载目录未设置"
# 检查下载目录
download_path = Path(settings.DOWNLOAD_PATH)
if not download_path.exists():
return False, "下载目录不存在"
if not settings.LIBRARY_PATH:
return False, "媒体库目录未设置"
# 下载目录的设备ID
download_devid = download_path.stat().st_dev
# 比较媒体库目录的设备ID
for path in settings.LIBRARY_PATHS:
library_path = Path(path)
if not library_path.exists():
return False, f"目录不存在:{library_path}"
if settings.TRANSFER_TYPE == "link":
if library_path.stat().st_dev != download_devid:
return False, f"下载目录 {download_path} 与媒体库目录 {library_path} 不在同一设备,将无法硬链接"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@@ -319,7 +343,7 @@ class FileTransferModule(_ModuleBase):
:param transfer_type: RmtMode转移方式
:param over_flag: 是否覆盖为True时会先删除再转移
"""
if new_file.exists():
if new_file.exists() or new_file.is_symlink():
if not over_flag:
logger.warn(f"文件已存在:{new_file}")
return 0
@@ -486,37 +510,46 @@ class FileTransferModule(_ModuleBase):
# 判断是否要覆盖
overflag = False
if new_file.exists():
# 目标文件已存在
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
match settings.OVERWRITE_MODE:
case 'always':
# 总是覆盖同名文件
target_file = new_file
if new_file.exists() or new_file.is_symlink():
if new_file.is_symlink():
target_file = new_file.readlink()
if not target_file.exists():
overflag = True
case 'size':
# 存在时大覆盖小
if new_file.stat().st_size < in_path.stat().st_size:
logger.info(f"目标文件文件大小更小,将被覆盖:{new_file}")
if not overflag:
# 目标文件已存在
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
match settings.OVERWRITE_MODE:
case 'always':
# 总是覆盖同名文件
overflag = True
else:
case 'size':
# 存在时大覆盖小
if target_file.stat().st_size < in_path.stat().st_size:
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
overflag = True
else:
return TransferInfo(success=False,
message=f"媒体库中已存在,且质量更好",
path=in_path,
target_path=new_file,
fail_list=[str(in_path)])
case 'never':
# 存在不覆盖
return TransferInfo(success=False,
message=f"媒体库中已存在,且质量更好",
message=f"媒体库中已存在,当前设置为不覆盖",
path=in_path,
target_path=new_file,
fail_list=[str(in_path)])
case 'never':
# 存在不覆盖
return TransferInfo(success=False,
message=f"媒体库中已存在,当前设置为不覆盖",
path=in_path,
target_path=new_file,
fail_list=[str(in_path)])
case 'latest':
# 仅保留最新版本
self.delete_all_version_files(new_file)
overflag = True
case _:
pass
case 'latest':
# 仅保留最新版本
logger.info(f"仅保留最新版本,将覆盖:{new_file}")
overflag = True
else:
if settings.OVERWRITE_MODE == 'latest':
# 文件不存在,但仅保留最新版本
logger.info(f"转移覆盖模式:{settings.OVERWRITE_MODE},仅保留最新版本")
self.delete_all_version_files(new_file)
# 原文件大小
file_size = in_path.stat().st_size
# 转移文件
@@ -552,6 +585,20 @@ class FileTransferModule(_ModuleBase):
:param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息
"""
def __convert_invalid_characters(filename: str):
if not filename:
return filename
invalid_characters = r'\/:*?"<>|'
# 创建半角到全角字符的转换表
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
# 将不支持的字符替换为对应的全角字符
for char in invalid_characters:
filename = filename.replace(char, char.translate(translation_table))
return filename
# 获取集标题
episode_title = None
if meta.begin_episode and episodes_info:
@@ -562,9 +609,11 @@ class FileTransferModule(_ModuleBase):
return {
# 标题
"title": mediainfo.title,
"title": __convert_invalid_characters(mediainfo.title),
# 英文标题
"en_title": __convert_invalid_characters(mediainfo.en_title),
# 原语种标题
"original_title": mediainfo.original_title,
"original_title": __convert_invalid_characters(mediainfo.original_title),
# 原文件名
"original_name": f"{meta.org_string}{file_ext}",
# 识别名称(优先使用中文)

View File

@@ -39,7 +39,7 @@ class FilterModule(_ModuleBase):
# 中字
"CNSUB": {
"include": [
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体|([\s,.-\[])(CHT|CHS|cht|chs)(|[\s,.-\]])'],
"exclude": [],
"tmdb": {
"original_language": "zh,cn"
@@ -132,6 +132,9 @@ class FilterModule(_ModuleBase):
def stop(self):
pass
def test(self):
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass

View File

@@ -4,6 +4,7 @@ from typing import List, Optional, Tuple, Union
from ruamel.yaml import CommentedMap
from app.core.context import TorrentInfo
from app.helper.sites import SitesHelper
from app.log import logger
from app.modules import _ModuleBase
from app.modules.indexer.mtorrent import MTorrentSpider
@@ -25,6 +26,15 @@ class IndexerModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
sites = SitesHelper().get_indexers()
if not sites:
return False, "未配置站点或未通过用户认证"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "INDEXER", "builtin"

View File

@@ -1,8 +1,9 @@
import copy
import datetime
import re
import traceback
from typing import List
from urllib.parse import quote, urlencode
from urllib.parse import quote, urlencode, urlparse, parse_qs
import chardet
from jinja2 import Template
@@ -56,12 +57,14 @@ class TorrentSpider:
fields: dict = {}
# 页码
page: int = 0
# 搜索条数
# 搜索条数, 默认: 100条
result_num: int = 100
# 单个种子信息
torrents_info: dict = {}
# 种子列表
torrents_info_array: list = []
# 搜索超时, 默认: 30秒
_timeout = 30
def __init__(self,
indexer: CommentedMap,
@@ -91,6 +94,8 @@ class TorrentSpider:
self.fields = indexer.get('torrents').get('fields')
self.render = indexer.get('render')
self.domain = indexer.get('domain')
self.result_num = int(indexer.get('result_num') or 100)
self._timeout = int(indexer.get('timeout') or 30)
self.page = page
if self.domain and not str(self.domain).endswith("/"):
self.domain = self.domain + "/"
@@ -152,7 +157,7 @@ class TorrentSpider:
search_mode = "0"
# 搜索URL
indexer_params = self.search.get("params") or {}
indexer_params = self.search.get("params", {}).copy()
if indexer_params:
search_area = indexer_params.get('search_area')
# search_area非0表示支持imdbid搜索
@@ -233,14 +238,15 @@ class TorrentSpider:
url=searchurl,
cookies=self.cookie,
ua=self.ua,
proxies=self.proxy_server
proxies=self.proxy_server,
timeout=self._timeout
)
else:
# requests请求
ret = RequestUtils(
ua=self.ua,
cookies=self.cookie,
timeout=30,
timeout=self._timeout,
referer=self.referer,
proxies=self.proxies
).get_res(searchurl, allow_redirects=True)
@@ -270,7 +276,7 @@ class TorrentSpider:
return self.parse(page_source)
def __get_title(self, torrent):
# title default
# title default text
if 'title' not in self.fields:
return
selector = self.fields.get('title', {})
@@ -300,7 +306,7 @@ class TorrentSpider:
selector.get('filters'))
def __get_description(self, torrent):
# title optional
# title optional text
if 'description' not in self.fields:
return
selector = self.fields.get('description', {})
@@ -346,7 +352,7 @@ class TorrentSpider:
selector.get('filters'))
def __get_detail(self, torrent):
# details
# details page text
if 'details' not in self.fields:
return
selector = self.fields.get('details', {})
@@ -367,7 +373,7 @@ class TorrentSpider:
self.torrents_info['page_url'] = detail_link
def __get_download(self, torrent):
# download link
# download link text
if 'download' not in self.fields:
return
selector = self.fields.get('download', {})
@@ -397,7 +403,7 @@ class TorrentSpider:
selector.get('filters'))
def __get_size(self, torrent):
# torrent size
# torrent size int
if 'size' not in self.fields:
return
selector = self.fields.get('size', {})
@@ -414,7 +420,7 @@ class TorrentSpider:
self.torrents_info['size'] = 0
def __get_leechers(self, torrent):
# torrent leechers
# torrent leechers int
if 'leechers' not in self.fields:
return
selector = self.fields.get('leechers', {})
@@ -424,6 +430,7 @@ class TorrentSpider:
item = self.__index(items, selector)
if item:
peers_val = item.split("/")[0]
peers_val = peers_val.replace(",", "")
peers_val = self.__filter_text(peers_val,
selector.get('filters'))
self.torrents_info['peers'] = int(peers_val) if peers_val and peers_val.isdigit() else 0
@@ -431,7 +438,7 @@ class TorrentSpider:
self.torrents_info['peers'] = 0
def __get_seeders(self, torrent):
# torrent leechers
# torrent leechers int
if 'seeders' not in self.fields:
return
selector = self.fields.get('seeders', {})
@@ -441,6 +448,7 @@ class TorrentSpider:
item = self.__index(items, selector)
if item:
seeders_val = item.split("/")[0]
seeders_val = seeders_val.replace(",", "")
seeders_val = self.__filter_text(seeders_val,
selector.get('filters'))
self.torrents_info['seeders'] = int(seeders_val) if seeders_val and seeders_val.isdigit() else 0
@@ -448,7 +456,7 @@ class TorrentSpider:
self.torrents_info['seeders'] = 0
def __get_grabs(self, torrent):
# torrent grabs
# torrent grabs int
if 'grabs' not in self.fields:
return
selector = self.fields.get('grabs', {})
@@ -458,6 +466,7 @@ class TorrentSpider:
item = self.__index(items, selector)
if item:
grabs_val = item.split("/")[0]
grabs_val = grabs_val.replace(",", "")
grabs_val = self.__filter_text(grabs_val,
selector.get('filters'))
self.torrents_info['grabs'] = int(grabs_val) if grabs_val and grabs_val.isdigit() else 0
@@ -465,7 +474,7 @@ class TorrentSpider:
self.torrents_info['grabs'] = 0
def __get_pubdate(self, torrent):
# torrent pubdate
# torrent pubdate yyyy-mm-dd hh:mm:ss
if 'date_added' not in self.fields:
return
selector = self.fields.get('date_added', {})
@@ -477,7 +486,7 @@ class TorrentSpider:
selector.get('filters'))
def __get_date_elapsed(self, torrent):
# torrent pubdate
# torrent data elaspsed text
if 'date_elapsed' not in self.fields:
return
selector = self.fields.get('date_elapsed', {})
@@ -489,7 +498,7 @@ class TorrentSpider:
selector.get('filters'))
def __get_downloadvolumefactor(self, torrent):
# downloadvolumefactor
# downloadvolumefactor int
selector = self.fields.get('downloadvolumefactor', {})
if not selector:
return
@@ -512,7 +521,7 @@ class TorrentSpider:
self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1))
def __get_uploadvolumefactor(self, torrent):
# uploadvolumefactor
# uploadvolumefactor int
selector = self.fields.get('uploadvolumefactor', {})
if not selector:
return
@@ -535,7 +544,7 @@ class TorrentSpider:
self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1))
def __get_labels(self, torrent):
# labels
# labels ['label1', 'label2']
if 'labels' not in self.fields:
return
selector = self.fields.get('labels', {})
@@ -548,7 +557,7 @@ class TorrentSpider:
self.torrents_info['labels'] = []
def __get_free_date(self, torrent):
# free date
# free date yyyy-mm-dd hh:mm:ss
if 'freedate' not in self.fields:
return
selector = self.fields.get('freedate', {})
@@ -560,7 +569,7 @@ class TorrentSpider:
selector.get('filters'))
def __get_hit_and_run(self, torrent):
# hitandrun
# hitandrun True/False
if 'hr' not in self.fields:
return
selector = self.fields.get('hr', {})
@@ -570,28 +579,71 @@ class TorrentSpider:
else:
self.torrents_info['hit_and_run'] = False
def __get_category(self, torrent):
# category 电影/电视剧
if 'category' not in self.fields:
return
selector = self.fields.get('category', {})
category = torrent(selector.get('selector', '')).clone()
self.__remove(category, selector)
items = self.__attribute_or_text(category, selector)
category_value = self.__index(items, selector)
category_value = self.__filter_text(category_value,
selector.get('filters'))
if category_value and self.category:
tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []]
movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []]
if category_value in tv_cats \
and category_value not in movie_cats:
self.torrents_info['category'] = MediaType.TV.value
elif category_value in movie_cats:
self.torrents_info['category'] = MediaType.MOVIE.value
else:
self.torrents_info['category'] = MediaType.UNKNOWN.value
else:
self.torrents_info['category'] = MediaType.UNKNOWN.value
def get_info(self, torrent) -> dict:
"""
解析单条种子数据
"""
self.torrents_info = {}
try:
# 标题
self.__get_title(torrent)
# 描述
self.__get_description(torrent)
# 详情页面
self.__get_detail(torrent)
# 下载链接
self.__get_download(torrent)
# 完成数
self.__get_grabs(torrent)
# 下载数
self.__get_leechers(torrent)
# 做种数
self.__get_seeders(torrent)
# 大小
self.__get_size(torrent)
# IMDBID
self.__get_imdbid(torrent)
# 下载系数
self.__get_downloadvolumefactor(torrent)
# 上传系数
self.__get_uploadvolumefactor(torrent)
# 发布时间
self.__get_pubdate(torrent)
# 已发布时间
self.__get_date_elapsed(torrent)
# 免费载止时间
self.__get_free_date(torrent)
# 标签
self.__get_labels(torrent)
# HR
self.__get_hit_and_run(torrent)
# 分类
self.__get_category(torrent)
except Exception as err:
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
return self.torrents_info
@@ -608,8 +660,8 @@ class TorrentSpider:
for filter_item in filters:
if not text:
break
method_name = filter_item.get("name")
try:
method_name = filter_item.get("name")
args = filter_item.get("args")
if method_name == "re_search" and isinstance(args, list):
text = re.search(r"%s" % args[0], text).group(args[-1])
@@ -623,8 +675,13 @@ class TorrentSpider:
text = text.strip()
elif method_name == "appendleft":
text = f"{args}{text}"
elif method_name == "querystring":
parsed_url = urlparse(text)
query_params = parse_qs(parsed_url.query)
param_value = query_params.get(args)
text = param_value[0] if param_value else ''
except Exception as err:
print(str(err))
logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}')
return text.strip()
@staticmethod

View File

@@ -28,6 +28,16 @@ class JellyfinModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.jellyfin.is_inactive():
self.jellyfin.reconnect()
if not self.jellyfin.get_user():
return False, "无法连接Jellyfin请检查参数配置"
return True, ""
def user_authenticate(self, name: str, password: str) -> Optional[str]:
"""
使用Emby用户辅助完成用户认证

View File

@@ -8,10 +8,9 @@ from app.core.config import settings
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class Jellyfin(metaclass=Singleton):
class Jellyfin:
def __init__(self):
self._host = settings.JELLYFIN_HOST
@@ -51,7 +50,7 @@ class Jellyfin(metaclass=Singleton):
"""
if not self._host or not self._apikey:
return []
req_url = "%Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
req_url = "%sLibrary/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -633,9 +632,9 @@ class Jellyfin(metaclass=Singleton):
"""
if not self._host or not self._apikey:
return None
url = url.replace("[HOST]", self._host) \
.replace("[APIKEY]", self._apikey) \
.replace("[USER]", self.user)
url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user or '')
try:
return RequestUtils(accept_type="application/json").get_res(url=url)
except Exception as e:
@@ -651,9 +650,9 @@ class Jellyfin(metaclass=Singleton):
"""
if not self._host or not self._apikey:
return None
url = url.replace("[HOST]", self._host) \
.replace("[APIKEY]", self._apikey) \
.replace("[USER]", self.user)
url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user or '')
try:
return RequestUtils(
headers=headers

View File

@@ -17,6 +17,16 @@ class PlexModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.plex.is_inactive():
self.plex.reconnect()
if not self.plex.get_librarys():
return False, "无法连接Plex请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "plex"

View File

@@ -11,10 +11,9 @@ from app import schemas
from app.core.config import settings
from app.log import logger
from app.schemas import MediaType
from app.utils.singleton import Singleton
class Plex(metaclass=Singleton):
class Plex:
_plex = None

View File

@@ -26,6 +26,16 @@ class QbittorrentModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.qbittorrent.is_inactive():
self.qbittorrent.reconnect()
if self.qbittorrent.is_inactive():
return False, "无法连接Qbittorrent请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "DOWNLOADER", "qbittorrent"
@@ -113,7 +123,7 @@ class QbittorrentModule(_ModuleBase):
# 获取种子Hash
torrent_hash = self.qbittorrent.get_torrent_id_by_tag(tags=tag)
if not torrent_hash:
return None, f"获取种子Hash失败:{content}"
return None, f"下载任务添加成功但获取Qbittorrent任务信息失败:{content}"
else:
if is_paused:
# 种子文件
@@ -206,7 +216,7 @@ class QbittorrentModule(_ModuleBase):
season_episode=meta.season_episode,
progress=torrent.get('progress') * 100,
size=torrent.get('total_size'),
state="paused" if torrent.get('state') == "paused" else "downloading",
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
left_time=StringUtils.str_secends(
@@ -236,13 +246,14 @@ class QbittorrentModule(_ModuleBase):
logger.warn(f"删除残留文件夹:{path}")
shutil.rmtree(path, ignore_errors=True)
def remove_torrents(self, hashs: Union[str, list]) -> bool:
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
"""
删除下载器种子
:param hashs: 种子Hash
:param delete_file: 是否删除文件
:return: bool
"""
return self.qbittorrent.delete_torrents(delete_file=True, ids=hashs)
return self.qbittorrent.delete_torrents(delete_file=delete_file, ids=hashs)
def start_torrents(self, hashs: Union[list, str]) -> bool:
"""

View File

@@ -8,11 +8,10 @@ from qbittorrentapi.transfer import TransferInfoDictionary
from app.core.config import settings
from app.log import logger
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class Qbittorrent(metaclass=Singleton):
class Qbittorrent:
_host: str = None
_port: int = None
_username: str = None
@@ -180,7 +179,7 @@ class Qbittorrent(metaclass=Singleton):
通过标签多次尝试获取刚添加的种子ID并移除标签
"""
torrent_id = None
# QB添加下载后需要时间重试5次每次等待5
# QB添加下载后需要时间重试10次每次等待3
for i in range(1, 10):
time.sleep(3)
torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags,

View File

@@ -19,6 +19,15 @@ class SlackModule(_ModuleBase):
def stop(self):
self.slack.stop()
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
state = self.slack.get_state()
if state:
return True, ""
return False, "Slack未就续请检查参数设置和网络连接"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "slack"

View File

@@ -87,6 +87,12 @@ class Slack:
except Exception as err:
logger.error("Slack消息接收服务停止失败: %s" % str(err))
def get_state(self) -> bool:
"""
获取状态
"""
return True if self._client else False
def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""):
"""
发送Telegram消息

View File

@@ -34,6 +34,9 @@ class SubtitleModule(_ModuleBase):
def stop(self) -> None:
pass
def test(self):
pass
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
"""
添加下载任务成功后,从站点下载字幕,保存到下载目录

View File

@@ -16,6 +16,15 @@ class SynologyChatModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
state = self.synologychat.get_state()
if state:
return True, ""
return False, "SynologyChat未就续请检查参数设置、网络连接以及机器人是否可见"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "synologychat"

View File

@@ -9,13 +9,12 @@ from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
lock = Lock()
class SynologyChat(metaclass=Singleton):
class SynologyChat:
def __init__(self):
self._req = RequestUtils(content_type="application/x-www-form-urlencoded")
self._webhook_url = settings.SYNOLOGYCHAT_WEBHOOK
@@ -26,6 +25,17 @@ class SynologyChat(metaclass=Singleton):
def check_token(self, token: str) -> bool:
return True if token == self._token else False
def get_state(self) -> bool:
"""
获取状态
"""
if not self._webhook_url or not self._token:
return False
ret = self.__get_bot_users()
if ret:
return True
return False
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
"""
发送Telegram消息

View File

@@ -18,6 +18,15 @@ class TelegramModule(_ModuleBase):
def stop(self):
self.telegram.stop()
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
state = self.telegram.get_state()
if state:
return True, ""
return False, "Telegram未就续请检查参数设置和网络连接"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "telegram"

View File

@@ -14,13 +14,12 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
apihelper.proxy = settings.PROXY
class Telegram(metaclass=Singleton):
class Telegram:
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_event = Event()
_bot: telebot.TeleBot = None
@@ -62,6 +61,12 @@ class Telegram(metaclass=Singleton):
self._polling_thread.start()
logger.info("Telegram消息接收服务启动")
def get_state(self) -> bool:
"""
获取状态
"""
return self._bot is not None
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
"""
发送Telegram消息

View File

@@ -12,6 +12,7 @@ from app.modules.themoviedb.scraper import TmdbScraper
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbapi import TmdbHelper
from app.schemas.types import MediaType, MediaImageType
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils
@@ -38,18 +39,32 @@ class TheMovieDbModule(_ModuleBase):
def stop(self):
self.cache.save()
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
ret = RequestUtils(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, ""
elif ret:
return False, f"无法连接 {settings.TMDB_API_DOMAIN},错误码:{ret.status_code}"
return False, f"{settings.TMDB_API_DOMAIN} 网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
tmdbid: int = None,
cache: bool = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
@@ -57,11 +72,17 @@ class TheMovieDbModule(_ModuleBase):
if not meta:
cache_info = {}
elif not meta.name:
logger.warn("识别媒体信息时未提供元数据名称")
return None
else:
if mtype:
meta.type = mtype
if tmdbid:
meta.tmdbid = tmdbid
# 读取缓存
cache_info = self.cache.get(meta)
if not cache_info:
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存
if tmdbid:
# 直接查询详情
@@ -111,7 +132,7 @@ class TheMovieDbModule(_ModuleBase):
logger.error("识别媒体信息时未提供元数据或tmdbid")
return None
# 保存到缓存
if meta:
if meta and cache:
self.cache.update(meta, info)
else:
# 使用缓存信息

View File

@@ -18,6 +18,12 @@ class CategoryHelper(metaclass=Singleton):
def __init__(self):
self._category_path: Path = settings.CONFIG_PATH / "category.yaml"
self.init()
def init(self):
"""
初始化
"""
# 二级分类策略关闭
if not settings.LIBRARY_CATEGORY:
return

View File

@@ -1,4 +1,5 @@
import time
import traceback
from pathlib import Path
from typing import Union
from xml.dom import minidom
@@ -135,14 +136,15 @@ class TmdbScraper:
file_path=file_path)
# 集的图片
episode_image = episodeinfo.get("still_path")
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
Path(episode_image).suffix)
if episode_image and (self._force_img or not image_path.exists()):
self.__save_image(
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
image_path)
if episode_image:
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
Path(episode_image).suffix)
if self._force_img or not image_path.exists():
self.__save_image(
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
image_path)
except Exception as e:
logger.error(f"{file_path} 刮削失败:{str(e)}")
logger.error(f"{file_path} 刮削失败:{str(e)} - {traceback.format_exc()}")
@staticmethod
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
@@ -358,7 +360,7 @@ class TmdbScraper:
"""
try:
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
r = RequestUtils().get_res(url=url, raise_exception=True)
r = RequestUtils(proxies=settings.PROXY).get_res(url=url, raise_exception=True)
if r:
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content)

View File

@@ -1,12 +1,14 @@
import pickle
import random
import time
import traceback
from pathlib import Path
from threading import RLock
from typing import Optional
from app.core.config import settings
from app.core.meta import MetaBase
from app.log import logger
from app.utils.singleton import Singleton
from app.schemas.types import MediaType
@@ -48,7 +50,7 @@ class TmdbCache(metaclass=Singleton):
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]{meta.name}-{meta.year}-{meta.begin_season}"
return f"[{meta.type.value if meta.type else '未知'}]{meta.name or meta.tmdbid}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""
@@ -118,7 +120,7 @@ class TmdbCache(metaclass=Singleton):
return data
return {}
except Exception as e:
print(str(e))
logger.error(f'加载缓存失败:{str(e)} - {traceback.format_exc()}')
return {}
def update(self, meta: MetaBase, info: dict) -> None:

View File

@@ -213,8 +213,7 @@ class TmdbHelper:
logger.error(f"连接TMDB出错{str(err)}")
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
print(traceback.print_exc())
logger.error(f"连接TMDB出错{str(e)} - {traceback.format_exc()}")
return None
logger.debug(f"API返回{str(self.search.total_results)}")
if len(movies) == 0:
@@ -261,8 +260,7 @@ class TmdbHelper:
logger.error(f"连接TMDB出错{str(err)}")
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
print(traceback.print_exc())
logger.error(f"连接TMDB出错{str(e)} - {traceback.format_exc()}")
return None
logger.debug(f"API返回{str(self.search.total_results)}")
if len(tvs) == 0:
@@ -313,7 +311,7 @@ class TmdbHelper:
return True
except Exception as e1:
logger.error(f"连接TMDB出错{e1}")
print(traceback.print_exc())
print(traceback.format_exc())
return False
return False
@@ -324,7 +322,7 @@ class TmdbHelper:
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
print(traceback.print_exc())
print(traceback.format_exc())
return None
if len(tvs) == 0:
@@ -404,7 +402,7 @@ class TmdbHelper:
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
print(traceback.print_exc())
print(traceback.format_exc())
return None
logger.debug(f"API返回{str(self.search.total_results)}")
# 返回结果
@@ -557,6 +555,8 @@ class TmdbHelper:
tmdb_info['names'] = self.__get_names(tmdb_info)
# 转换中文标题
self.__update_tmdbinfo_cn_title(tmdb_info)
# 转换英文标题
self.__update_tmdbinfo_en_title(tmdb_info)
return tmdb_info
@@ -597,6 +597,38 @@ class TmdbHelper:
else:
tmdb_info['name'] = cn_title
@staticmethod
def __update_tmdbinfo_en_title(tmdb_info: dict):
"""
更新TMDB信息中的英文名称
"""
def __get_tmdb_english_title(tmdbinfo):
"""
从别名中获取英文标题
"""
if not tmdbinfo:
return None
translations = tmdb_info.get("translations", {}).get("translations", [])
for translation in translations:
if translation.get("iso_3166_1") == "US":
return translation.get("data", {}).get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE \
else translation.get("data", {}).get("name")
return None
# 查找英文名
org_title = (
tmdb_info.get("original_title")
if tmdb_info.get("media_type") == MediaType.MOVIE
else tmdb_info.get("original_name")
)
if tmdb_info.get("original_language") == "en":
tmdb_info['en_title'] = org_title
# TODO: 对于日文标题,使用罗马字作为英文标题可能更合适?
else:
en_title = __get_tmdb_english_title(tmdb_info)
tmdb_info['en_title'] = en_title or org_title
def __get_movie_detail(self,
tmdbid: int,
append_to_response: str = "images,"

View File

@@ -1,14 +1,13 @@
from typing import Optional, Tuple, Union
from app.core.config import settings
from app.log import logger
from app.modules import _ModuleBase
from app.modules.thetvdb import tvdbapi
from app.utils.http import RequestUtils
class TheTvDbModule(_ModuleBase):
tvdb: tvdbapi.Tvdb = None
def init_module(self) -> None:
@@ -20,6 +19,17 @@ class TheTvDbModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.thetvdb.com/series/81189")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接 api.thetvdb.com错误码{ret.status_code}"
return False, "api.thetvdb.com 网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass

View File

@@ -26,6 +26,16 @@ class TransmissionModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.transmission.is_inactive():
self.transmission.reconnect()
if self.transmission.is_inactive():
return False, "无法连接Transmission请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "DOWNLOADER", "transmission"
@@ -121,20 +131,25 @@ class TransmissionModule(_ModuleBase):
return torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
# 需要的文件信息
file_ids = []
unwanted_file_ids = []
for torrent_file in torrent_files:
file_id = torrent_file.id
file_name = torrent_file.name
meta_info = MetaInfo(file_name)
if not meta_info.episode_list:
unwanted_file_ids.append(file_id)
continue
selected = set(meta_info.episode_list).issubset(set(episodes))
if not selected:
unwanted_file_ids.append(file_id)
continue
file_ids.append(file_id)
# 选择文件
self.transmission.set_files(torrent_hash, file_ids)
self.transmission.set_unwanted_files(torrent_hash, unwanted_file_ids)
# 开始任务
self.transmission.start_torrents(torrent_hash)
return torrent_hash, "添加下载任务成功"
else:
return torrent_hash, "添加下载任务成功"
@@ -220,13 +235,14 @@ class TransmissionModule(_ModuleBase):
logger.warn(f"删除残留文件夹:{path}")
shutil.rmtree(path, ignore_errors=True)
def remove_torrents(self, hashs: Union[str, list]) -> bool:
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
"""
删除下载器种子
:param hashs: 种子Hash
:param delete_file: 是否删除文件
:return: bool
"""
return self.transmission.delete_torrents(delete_file=True, ids=hashs)
return self.transmission.delete_torrents(delete_file=delete_file, ids=hashs)
def start_torrents(self, hashs: Union[list, str]) -> bool:
"""

View File

@@ -6,11 +6,10 @@ from transmission_rpc.session import SessionStats
from app.core.config import settings
from app.log import logger
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class Transmission(metaclass=Singleton):
class Transmission:
_host: str = None
_port: int = None
_username: str = None
@@ -243,6 +242,19 @@ class Transmission(metaclass=Singleton):
logger.error(f"设置下载文件状态出错:{str(err)}")
return False
def set_unwanted_files(self, tid: str, file_ids: list) -> bool:
"""
设置下载文件的状态
"""
if not self.trc:
return False
try:
self.trc.change_torrent(ids=tid, files_unwanted=file_ids)
return True
except Exception as err:
logger.error(f"设置下载文件状态出错:{str(err)}")
return False
def transfer_info(self) -> Optional[SessionStats]:
"""
获取传输信息
@@ -360,3 +372,4 @@ class Transmission(metaclass=Singleton):
except Exception as err:
logger.error(f"修改tracker出错{str(err)}")
return False

View File

@@ -0,0 +1,128 @@
import json
from typing import Optional, Union, List, Tuple, Any, Dict
from app.core.config import settings
from app.core.context import Context, MediaInfo
from app.log import logger
from app.modules import _ModuleBase, checkMessage
from app.modules.vocechat.vocechat import VoceChat
from app.schemas import MessageChannel, CommingMessage, Notification
class VoceChatModule(_ModuleBase):
vocechat: VoceChat = None
def init_module(self) -> None:
self.vocechat = VoceChat()
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
state = self.vocechat.get_state()
if state:
return True, ""
return False, "获取VoceChat频道失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "vocechat"
@staticmethod
def message_parser(body: Any, form: Any,
args: Any) -> Optional[CommingMessage]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
username: 用户名
text: 内容
:param body: 请求体
:param form: 表单
:param args: 参数
:return: 渠道、消息体
"""
try:
"""
{
"created_at": 1672048481664, //消息创建的时间戳
"detail": {
"content": "hello this is my message to you", //消息内容
"content_type": "text/plain", //消息类型text/plain纯文本消息text/markdownmarkdown消息vocechat/file文件类消息
"expires_in": null, //消息过期时长如果有大于0数字说明该消息是个限时消息
"properties": null, //一些有关消息的元数据比如at信息文件消息的具体类型信息如果是个图片消息还会有一些宽高图片名称等元信息
"type": "normal" //消息类型normal代表是新消息
},
"from_uid": 7910, //来自于谁
"mid": 2978, //消息ID
"target": { "gid": 2 } //发送给谁gid代表是发送给频道uid代表是发送给个人此时的数据结构举例{"uid":1}
}
"""
# 报文体
msg_body = json.loads(body)
# 类型
msg_type = msg_body.get("detail", {}).get("type")
if msg_type != "normal":
# 非新消息
return None
logger.debug(f"收到VoceChat请求{msg_body}")
# token校验
token = args.get("token")
if not token or token != settings.API_TOKEN:
logger.warn(f"VoceChat请求token校验失败{token}")
return None
# 文本内容
content = msg_body.get("detail", {}).get("content")
# 用户ID
gid = msg_body.get("target", {}).get("gid")
if gid and str(gid) == str(settings.VOCECHAT_CHANNEL_ID):
# 来自监听频道的消息
userid = f"GID#{gid}"
else:
# 来自个人的消息
userid = f"UID#{msg_body.get('from_uid')}"
# 处理消息内容
if content and userid:
logger.info(f"收到VoceChat消息userid={userid}, text={content}")
return CommingMessage(channel=MessageChannel.VoceChat,
userid=userid, username=userid, text=content)
except Exception as err:
logger.error(f"VoceChat消息处理发生错误{str(err)}")
return None
@checkMessage(MessageChannel.VoceChat)
def post_message(self, message: Notification) -> None:
"""
发送消息
:param message: 消息内容
:return: 成功或失败
"""
self.vocechat.send_msg(title=message.title, text=message.text, userid=message.userid)
@checkMessage(MessageChannel.VoceChat)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
"""
发送媒体信息选择列表
:param message: 消息内容
:param medias: 媒体列表
:return: 成功或失败
"""
# 先发送标题
self.vocechat.send_msg(title=message.title, userid=message.userid)
# 再发送内容
return self.vocechat.send_medias_msg(title=message.title, medias=medias, userid=message.userid)
@checkMessage(MessageChannel.VoceChat)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
"""
发送种子信息选择列表
:param message: 消息内容
:param torrents: 种子列表
:return: 成功或失败
"""
return self.vocechat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
def register_commands(self, commands: Dict[str, dict]):
pass

View File

@@ -0,0 +1,191 @@
import re
import threading
from typing import Optional, List
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.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
lock = threading.Lock()
class VoceChat:
# host
_host = None
# apikey
_apikey = None
# 频道ID
_channel_id = None
# 请求对象
_client = None
def __init__(self):
"""
初始化
"""
self._host = settings.VOCECHAT_HOST
if self._host:
if not self._host.endswith("/"):
self._host += "/"
if not self._host.startswith("http"):
self._playhost = "http://" + self._host
self._apikey = settings.VOCECHAT_API_KEY
self._channel_id = settings.VOCECHAT_CHANNEL_ID
if self._apikey and self._host and self._channel_id:
self._client = RequestUtils(headers={
"content-type": "text/markdown",
"x-api-key": self._apikey,
"accept": "application/json; charset=utf-8"
})
def get_state(self):
"""
获取状态
"""
return True if self.get_groups() else False
def get_groups(self):
"""
获取频道列表
"""
if not self._client:
return None
result = self._client.get_res(f"{self._host}api/bot")
if result and result.status_code == 200:
return result.json()
def send_msg(self, title: str, text: str = "", userid: str = None) -> Optional[bool]:
"""
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
:param title: 消息标题
:param text: 消息内容
:param userid: 消息发送对象的ID为空则发给所有人
:return: 发送状态,错误信息
"""
if not self._client:
return None
if not title and not text:
logger.warn("标题和内容不能同时为空")
return False
try:
if text:
caption = f"**{title}**\n{text}"
else:
caption = f"**{title}**"
if userid:
chat_id = userid
else:
chat_id = f"GID#{self._channel_id}"
return self.__send_request(userid=chat_id, caption=caption)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_medias_msg(self, title: str, medias: List[MediaInfo], userid: str = "") -> Optional[bool]:
"""
发送列表类消息
"""
if not self._client:
return None
try:
index, caption = 1, "**%s**" % title
for media in medias:
if media.vote_average:
caption = "%s\n%s. [%s](%s)\n_%s%s_" % (caption,
index,
media.title_year,
media.detail_link,
f"类型:{media.type.value}",
f"评分:{media.vote_average}")
else:
caption = "%s\n%s. [%s](%s)\n_%s_" % (caption,
index,
media.title_year,
media.detail_link,
f"类型:{media.type.value}")
index += 1
if userid:
chat_id = userid
else:
chat_id = f"GID#{self._channel_id}"
return self.__send_request(userid=chat_id, caption=caption)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
"""
发送列表消息
"""
if not self._client:
return None
if not torrents:
return False
try:
index, caption = 1, "**%s**" % title
for context in torrents:
torrent = context.torrent_info
site_name = torrent.site_name
meta = MetaInfo(torrent.title, torrent.description)
link = torrent.page_url
title = f"{meta.season_episode} " \
f"{meta.resource_term} " \
f"{meta.video_term} " \
f"{meta.release_group}"
title = re.sub(r"\s+", " ", title).strip()
free = torrent.volume_factor
seeder = f"{torrent.seeders}"
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
index += 1
if userid:
chat_id = userid
else:
chat_id = f"GID#{self._channel_id}"
return self.__send_request(userid=chat_id, caption=caption)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
@retry(Exception, logger=logger)
def __send_request(self, userid: str, caption: str) -> bool:
"""
向VoceChat发送报文
userid格式UID#xxx / GID#xxx
"""
if not self._client:
return False
if userid.startswith("GID#"):
action = "send_to_group"
else:
action = "send_to_user"
idstr = userid[4:]
with lock:
result = self._client.post_res(f"{self._host}api/bot/{action}/{idstr}", data=caption.encode("utf-8"))
if result and result.status_code == 200:
return True
elif result is not None:
logger.error(f"VoceChat发送消息失败错误码{result.status_code}")
return False
else:
raise Exception("VoceChat发送消息失败连接失败")

View File

@@ -20,6 +20,15 @@ class WechatModule(_ModuleBase):
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
state = self.wechat.get_state()
if state:
return True, ""
return False, "获取微信token失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "wechat"

View File

@@ -8,14 +8,14 @@ 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.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
lock = threading.Lock()
class WeChat(metaclass=Singleton):
class WeChat:
# 企业微信Token
_access_token = None
# 企业微信Token过期时间
@@ -47,6 +47,13 @@ class WeChat(metaclass=Singleton):
if self._corpid and self._appsecret and self._appid:
self.__get_access_token()
def get_state(self):
"""
获取状态
"""
return True if self.__get_access_token else False
@retry(Exception, logger=logger)
def __get_access_token(self, force=False):
"""
获取微信Token
@@ -75,6 +82,7 @@ class WeChat(metaclass=Singleton):
logger.error(f"获取微信access_token失败错误码{res.status_code},错误原因:{res.reason}")
else:
logger.error(f"获取微信access_token失败未获取到返回信息")
raise Exception("获取微信access_token失败网络连接失败")
except Exception as e:
logger.error(f"获取微信access_token失败错误信息{str(e)}")
return None

View File

@@ -166,7 +166,7 @@ class _PluginBase(metaclass=ABCMeta):
plugin_id = self.__class__.__name__
self.plugindata.save(plugin_id, key, value)
def get_data(self, key: str, plugin_id: str = None) -> Any:
def get_data(self, key: str = None, plugin_id: str = None) -> Any:
"""
获取插件数据
:param key: 数据key

View File

@@ -1,16 +1,18 @@
import logging
import threading
import traceback
from datetime import datetime, timedelta
from typing import List
import pytz
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.jobstores.base import JobLookupError
from apscheduler.schedulers.background import BackgroundScheduler
from app import schemas
from app.chain import ChainBase
from app.chain.cookiecloud import CookieCloudChain
from app.chain.mediaserver import MediaServerChain
from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
@@ -39,10 +41,12 @@ class Scheduler(metaclass=Singleton):
# 定时服务
_scheduler = BackgroundScheduler(timezone=settings.TZ,
executors={
'default': ThreadPoolExecutor(20)
'default': ThreadPoolExecutor(100)
})
# 退出事件
_event = threading.Event()
# 锁
_lock = threading.Lock()
def __init__(self):
@@ -56,7 +60,7 @@ class Scheduler(metaclass=Singleton):
# 各服务的运行状态
self._jobs = {
"cookiecloud": {
"func": CookieCloudChain().process,
"func": SiteChain().sync_cookies,
"running": False,
},
"mediaserver_sync": {
@@ -93,13 +97,14 @@ class Scheduler(metaclass=Singleton):
return
# CookieCloud定时同步
if settings.COOKIECLOUD_INTERVAL:
if settings.COOKIECLOUD_INTERVAL \
and str(settings.COOKIECLOUD_INTERVAL).isdigit():
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=settings.COOKIECLOUD_INTERVAL,
minutes=int(settings.COOKIECLOUD_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
kwargs={
'job_id': 'cookiecloud'
@@ -107,13 +112,14 @@ class Scheduler(metaclass=Singleton):
)
# 媒体服务器同步
if settings.MEDIASERVER_SYNC_INTERVAL:
if settings.MEDIASERVER_SYNC_INTERVAL \
and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit():
self._scheduler.add_job(
self.start,
"interval",
id="mediaserver_sync",
name="同步媒体服务器",
hours=settings.MEDIASERVER_SYNC_INTERVAL,
hours=int(settings.MEDIASERVER_SYNC_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
kwargs={
'job_id': 'mediaserver_sync'
@@ -173,16 +179,17 @@ class Scheduler(metaclass=Singleton):
})
else:
# RSS订阅模式
if not settings.SUBSCRIBE_RSS_INTERVAL:
if not settings.SUBSCRIBE_RSS_INTERVAL \
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit():
settings.SUBSCRIBE_RSS_INTERVAL = 30
elif settings.SUBSCRIBE_RSS_INTERVAL < 5:
elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:
settings.SUBSCRIBE_RSS_INTERVAL = 5
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=settings.SUBSCRIBE_RSS_INTERVAL,
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
kwargs={
'job_id': 'subscribe_refresh'
}
@@ -229,25 +236,8 @@ class Scheduler(metaclass=Singleton):
)
# 注册插件公共服务
plugin_services = PluginManager().get_plugin_services()
for service in plugin_services:
try:
self._jobs[service["id"]] = {
"func": service["func"],
"running": False,
}
self._scheduler.add_job(
self.start,
service["trigger"],
id=service["id"],
name=service["name"],
**service["kwargs"],
kwargs={
'job_id': service["id"]
}
)
except Exception as e:
logger.error(f"注册插件服务失败:{str(e)} - {service}")
for pid in PluginManager().get_running_plugin_ids():
self.update_plugin_job(pid)
# 打印服务
logger.debug(self._scheduler.print_jobs())
@@ -260,51 +250,136 @@ class Scheduler(metaclass=Singleton):
启动定时服务
"""
# 处理job_id格式
job = self._jobs.get(job_id)
if not job:
return
if job.get("running"):
logger.warning(f"定时任务 {job_id} 正在运行 ...")
return
self._jobs[job_id]["running"] = True
with self._lock:
job = self._jobs.get(job_id)
if not job:
return
if job.get("running"):
logger.warning(f"定时任务 {job_id} 正在运行 ...")
return
self._jobs[job_id]["running"] = True
# 开始运行
try:
if not kwargs:
kwargs = job.get("kwargs") or {}
job["func"](*args, **kwargs)
except Exception as e:
logger.error(f"定时任务 {job_id} 执行失败:{str(e)}")
self._jobs[job_id]["running"] = False
# 运行结束
with self._lock:
try:
self._jobs[job_id]["running"] = False
except KeyError:
pass
def update_plugin_job(self, pid: str):
"""
更新插件定时服务
"""
# 移除该插件的全部服务
self.remove_plugin_job(pid)
# 获取插件服务列表
with self._lock:
try:
plugin_services = PluginManager().run_plugin_method(pid, "get_service") or []
except Exception as e:
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
return
# 获取插件名称
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
# 开始注册插件服务
for service in plugin_services:
try:
sid = f"{service['id']}"
job_id = sid.split("|")[0]
if job_id not in self._jobs:
self._jobs[job_id] = {
"func": service["func"],
"name": service["name"],
"pid": pid,
"plugin_name": plugin_name,
"running": False,
}
self._scheduler.add_job(
self.start,
service["trigger"],
id=sid,
name=service["name"],
**service["kwargs"],
kwargs={
'job_id': job_id
}
)
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
except Exception as e:
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
def remove_plugin_job(self, pid: str):
"""
移除插件定时服务
"""
with self._lock:
# 获取插件名称
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
for job_id, service in self._jobs.copy().items():
try:
if service.get("pid") == pid:
self._jobs.pop(job_id, None)
try:
self._scheduler.remove_job(job_id)
except JobLookupError:
pass
logger.info(f"移除插件服务({plugin_name}){service.get('name')}")
except Exception as e:
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
def list(self) -> List[schemas.ScheduleInfo]:
"""
当前所有任务
"""
# 返回计时任务
schedulers = []
# 去重
added = []
jobs = self._scheduler.get_jobs()
# 按照下次运行时间排序
jobs.sort(key=lambda x: x.next_run_time)
for job in jobs:
if job.name not in added:
added.append(job.name)
else:
continue
job_id = job.id.split("|")[0]
if not self._jobs.get(job_id):
continue
# 任务状态
status = "正在运行" if self._jobs[job_id].get("running") else "等待"
# 下次运行时间
next_run = TimerUtils.time_difference(job.next_run_time)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=job.name,
status=status,
next_run=next_run
))
return schedulers
with self._lock:
# 返回计时任务
schedulers = []
# 去重
added = []
jobs = self._scheduler.get_jobs()
# 按照下次运行时间排序
jobs.sort(key=lambda x: x.next_run_time)
# 将正在运行的任务提取出来 (保障一次性任务正常显示)
for job_id, service in self._jobs.items():
name = service.get("name")
plugin_name = service.get("plugin_name")
if service.get("running") and name and plugin_name:
if name not in added:
added.append(name)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=name,
provider=plugin_name,
status="正在运行",
))
# 获取其他待执行任务
for job in jobs:
if job.name not in added:
added.append(job.name)
else:
continue
job_id = job.id.split("|")[0]
service = self._jobs.get(job_id)
if not service:
continue
# 任务状态
status = "正在运行" if service.get("running") else "等待"
# 下次运行时间
next_run = TimerUtils.time_difference(job.next_run_time)
schedulers.append(schemas.ScheduleInfo(
id=job_id,
name=job.name,
provider=service.get("plugin_name", "[系统]"),
status=status,
next_run=next_run
))
return schedulers
def stop(self):
"""

View File

@@ -67,6 +67,8 @@ class MediaInfo(BaseModel):
type: Optional[str] = None
# 媒体标题
title: Optional[str] = None
# 英文标题
en_title: Optional[str] = None
# 年份
year: Optional[str] = None
# 标题(年份)
@@ -92,7 +94,7 @@ class MediaInfo(BaseModel):
# 海报图片
poster_path: Optional[str] = None
# 评分
vote_average: Optional[int] = 0
vote_average: Optional[float] = 0
# 描述
overview: Optional[str] = None
# 二级分类

View File

@@ -56,6 +56,8 @@ class ScheduleInfo(BaseModel):
id: Optional[str] = None
# 名称
name: Optional[str] = None
# 提供者
provider: Optional[str] = None
# 状态
status: Optional[str] = None
# 下次执行时间

View File

@@ -53,3 +53,5 @@ class NotificationSwitch(BaseModel):
slack: Optional[bool] = False
# SynologyChat开关
synologychat: Optional[bool] = False
# VoceChat开关
vocechat: Optional[bool] = False

View File

@@ -59,6 +59,8 @@ class Subscribe(BaseModel):
current_priority: Optional[int] = None
# 保存路径
save_path: Optional[str] = None
# 是否使用 imdbid 搜索
search_imdbid: Optional[int] = 0
class Config:
orm_mode = True

View File

@@ -40,6 +40,8 @@ class EventType(Enum):
NameRecognize = "name.recognize"
# 名称识别结果
NameRecognizeResult = "name.recognize.result"
# 缓存站点图标
CacheSiteIcon = "cache.siteicon"
# 系统配置Key字典
@@ -114,3 +116,4 @@ class MessageChannel(Enum):
Telegram = "Telegram"
Slack = "Slack"
SynologyChat = "SynologyChat"
VoceChat = "VoceChat"

View File

@@ -24,7 +24,8 @@ class SiteUtils:
' or contains(@onclick, "logout")'
' or contains(@href, "usercp")]',
'//form[contains(@action, "logout")]',
'//div[@class="user-info-side"]'
'//div[@class="user-info-side"]',
'//a[@id="myitem"]'
]
for xpath in xpaths:
if html.xpath(xpath):

View File

@@ -56,7 +56,7 @@ class TimerUtils:
days = time_difference.days
hours, remainder = divmod(time_difference.seconds, 3600)
minutes, _ = divmod(remainder, 60)
minutes, second = divmod(remainder, 60)
time_difference_string = ""
if days > 0:
@@ -65,6 +65,8 @@ class TimerUtils:
time_difference_string += f"{hours}小时"
if minutes > 0:
time_difference_string += f"{minutes}分钟"
if not time_difference_string and second:
time_difference_string = f"{second}"
return time_difference_string

View File

@@ -1,10 +1,6 @@
#######################################################################
# 【*】为必配项,其余为选配项,选配项可以删除整项配置项或者保留配置默认值 #
#######################################################################
####################################
# 系统设置 #
####################################
# 【*】API监听地址注意不是前端访问地址
HOST=0.0.0.0
# 是否调试模式,打开后将输出更多日志
@@ -17,96 +13,6 @@ SUPERUSER=admin
BIG_MEMORY_MODE=false
# 自动检查和更新站点资源包(索引、认证等)
AUTO_UPDATE_RESOURCE=true
####################################
# 消息通知渠道(按需配置) #
####################################
# WeChat企业ID
WECHAT_CORPID=
# WeChat应用Secret
WECHAT_APP_SECRET=
# WeChat应用ID
WECHAT_APP_ID=
# WeChat代理服务器无需代理需保留默认值
WECHAT_PROXY=https://qyapi.weixin.qq.com
# WeChat Token
WECHAT_TOKEN=
# WeChat EncodingAESKey
WECHAT_ENCODING_AESKEY=
# WeChat 管理员
WECHAT_ADMINS=
# Telegram Bot Token
TELEGRAM_TOKEN=
# Telegram Chat ID
TELEGRAM_CHAT_ID=
# Telegram 用户ID使用,分隔
TELEGRAM_USERS=
# Telegram 管理员ID使用,分隔
TELEGRAM_ADMINS=
# Slack Bot User OAuth Token
SLACK_OAUTH_TOKEN=
# Slack App-Level Token
SLACK_APP_TOKEN=
# Slack 频道名称
SLACK_CHANNEL=
# SynologyChat Webhook
SYNOLOGYCHAT_WEBHOOK=
# SynologyChat Token
SYNOLOGYCHAT_TOKEN=
####################################
# 下载器(按需配置) #
####################################
# Qbittorrent地址IP:PORT
QB_HOST=
# Qbittorrent用户名
QB_USER=
# Qbittorrent密码
QB_PASSWORD=
# Qbittorrent分类自动管理
QB_CATEGORY=false
# Qbittorrent按顺序下载
QB_SEQUENTIAL=true
# Qbittorrent忽略队列限制强制继续
QB_FORCE_RESUME=true
# Transmission地址IP:PORT
TR_HOST=
# Transmission用户名
TR_USER=
# Transmission密码
TR_PASSWORD=
####################################
# 媒体服务器(按需配置) #
####################################
# EMBY服务器地址IP:PORT
EMBY_HOST=
# EMBY外网地址http(s)://DOMAIN:PORT未设置时使用EMBY_HOST
EMBY_PLAY_HOST=
# EMBY Api Key
EMBY_API_KEY=
# Jellyfin服务器地址IP:PORT
JELLYFIN_HOST=
# Jellyfin外网地址http(s)://DOMAIN:PORT未设置时使用JELLYFIN_HOST
JELLYFIN_PLAY_HOST=
# Jellyfin Api Key
JELLYFIN_API_KEY=
# Plex服务器地址IP:PORT
PLEX_HOST=
# Plex外网地址http(s)://DOMAIN:PORT未设置时使用PLEX_HOST
PLEX_PLAY_HOST=
# Plex Token
PLEX_TOKEN=
####################################
# 基础设置 #
####################################
# 【*】API密钥建议更换复杂字符串有Jellyseerr/Overseerr、媒体服务器Webhook等配置以及部分支持API_TOKEN的API中使用
API_TOKEN=moviepilot
# 登录页面电影海报tmdb/bingtmdb要求能正常连接api.themoviedb.org
@@ -119,104 +25,18 @@ TMDB_API_DOMAIN=api.themoviedb.org
RECOGNIZE_SOURCE=themoviedb
# Fanart开关
FANART_ENABLE=true
# 【*】消息通知渠道 telegram/wechat/slack多个通知渠道用,分隔,需要在上面配置对应消息通知渠道的参数
MESSAGER=telegram
# 【*】下载器 qbittorrent/transmission仅支持单个下载器做为主下载器使用需要在上面配置对应消下载器的参数
DOWNLOADER=qbittorrent
# 下载器监控开关
DOWNLOADER_MONITOR=true
# 【*】媒体服务器 emby/jellyfin/plex多个媒体服务器,分割
MEDIASERVER=emby
# 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL=6
# 媒体服务器同步黑名单,多个媒体库名称,分割
MEDIASERVER_SYNC_BLACKLIST=
####################################
# 媒体识别&刮削 #
####################################
# 刮削入库的媒体文件 true/false
SCRAP_METADATA=true
# 新增已入库媒体是否跟随TMDB信息变化true/false为false时即使TMDB信息变化时也会仍然按历史记录中已入库的信息进行刮削
SCRAP_FOLLOW_TMDB=true
# 刮削来源 themoviedb/douban使用themoviedb时需要确保能正常连接api.themoviedb.org使用douban时会缺失部分信息
SCRAP_SOURCE=themoviedb
####################################
# 文件整理 & 媒体库 #
####################################
# 【*】转移方式 link/copy/move/softlink/rclone_copy/rclone_move
TRANSFER_TYPE=copy
# 转移覆盖模式,`nerver`/`size`/`always`/`latest`,分别表示`不覆盖同名文件`/`同名文件根据文件大小覆盖(大覆盖小)`/`总是覆盖同名文件`/`仅保留最新版本,删除旧版本文件(包括非同名文件)`
OVERWRITE_MODE=size
# 【*】媒体库目录,多个目录使用,分隔
LIBRARY_PATH=/media
# 电影媒体库目录名,默认电影
LIBRARY_MOVIE_NAME=电影
# 电视剧媒体库目录名,默认电视剧
LIBRARY_TV_NAME=电视剧
# 动漫媒体库目录名,默认电视剧/动漫
LIBRARY_ANIME_NAME=电视剧/动漫
# 二级分类,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
LIBRARY_CATEGORY=true
# 电影重命名格式Jinja2语法参考https://jinja.palletsprojects.com/en/3.0.x/templates/
MOVIE_RENAME_FORMAT={{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
# 电视剧重命名格式Jinja2语法参考https://jinja.palletsprojects.com/en/3.0.x/templates/
TV_RENAME_FORMAT={{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}}{% endif %}{{fileExt}}
####################################
# 站点同步 #
####################################
# 【*】CookieCloud服务器地址默认为公共服务器
COOKIECLOUD_HOST=https://movie-pilot.org/cookiecloud
# 【*】CookieCloud用户KEY
COOKIECLOUD_KEY=
# 【*】CookieCloud端对端加密密码
COOKIECLOUD_PASSWORD=
# 【*】CookieCloud同步间隔分钟
COOKIECLOUD_INTERVAL=1440
# 【*】CookieCloud对应的浏览器UA
USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57
####################################
# 订阅 & 搜索 #
####################################
# 订阅模式 spider/rss`rss`模式通过定时刷新RSS来匹配订阅RSS地址会自动获取也可手动维护`spider`为爬虫模式随机时间间隔20-40分钟爬取站点首页种子
SUBSCRIBE_MODE=spider
# RSS订阅模式刷新时间间隔分钟
SUBSCRIBE_RSS_INTERVAL=30
# 订阅搜索开关开启后会每隔24小时对所有订阅进行全量搜索以补齐缺失剧集
SUBSCRIBE_SEARCH=false
# 交互搜索自动下载用户ID消息通知渠道的用户ID使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载
AUTO_DOWNLOAD_USER=
####################################
# 下载 #
####################################
# 【*】下载保存目录,容器内映射路径需要一致,支持不同类型设置不同的下载目录(跨盘)
DOWNLOAD_PATH=/downloads
# 电影下载保存目录(路径),容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH=
# 电视剧下载保存目录(路径),容器内映射路径需要一致
DOWNLOAD_TV_PATH=
# 动漫下载保存目录(路径),容器内映射路径需要一致
DOWNLOAD_ANIME_PATH=
# 下载目录二级分类,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在下载目录下建立二级目录分类
DOWNLOAD_CATEGORY=false
# 种子标签
TORRENT_TAG=MOVIEPILOT
# 自动下载站点字幕(如有)
DOWNLOAD_SUBTITLE=true
####################################
# 扩展 #
####################################
# OCR服务器地址
OCR_HOST=https://movie-pilot.org
# 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/

View File

@@ -1,23 +1,29 @@
# 配置电影的分类策略, 配置为空或者不配置该项则不启用电影分类
####### 配置说明 #######
# 1. 该配置文件用于配置电影和电视剧的分类策略配置后程序会按照配置的分类策略名称进行分类配置文件采用yaml格式需要严格附合语法规则
# 2. 配置文件中的一级分类名称:`movie`、`tv` 为固定名称不可修改,二级名称同时也是目录名称,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
# 3. 支持的分类条件:
# `original_language` 语种,具体含义参考下方字典
# `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典
# `genre_ids` 内容类型,具体含义参考下方字典
# themoviedb 详情API返回的其它一级字段
# 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔
# 配置电影的分类策略
movie:
# 分类名同时也是目录名,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
# 分类名同时也是目录名
动画电影:
# 分类依据可以是original_language 语种、production_countries 国家或地区、genre_ids 内容类型等只要TMDB API返回的字段中有就行
# 配置多项条件时,需要同时满足;不需要的匹配项可以删掉或者配置为空
# 匹配 genre_ids 内容类型16是动漫
genre_ids: '16'
华语电影:
# 匹配语种
original_language: 'zh,cn,bo,za'
# 未配置任何过滤条件时,则按先后顺序不符合上面分类的都会在这个分类下,建议配置在最末尾
# 未匹配以上条件时,分类为外语电影
外语电影:
# 配置电视剧的分类策略 配置为空或者不配置该项则不启用电视剧分类
# 配置电视剧的分类策略
tv:
# 分类名同时也是目录名,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
# 分类名同时也是目录名
动漫:
# 分类依据可以是original_language 语种、origin_country 国家或地区、genre_ids 内容类型等只要TMDB API返回的字段中有就行
# 配置多项条件时,需要同时满足;不需要的匹配项可以删掉或者配置为空
# 匹配 genre_ids 内容类型16是动漫
genre_ids: '16'
纪录片:
@@ -163,7 +169,7 @@ tv:
# cn 中文
# zu 祖鲁语
## origin_country 国家地区 字典
## origin_country/production_countries 国家地区 字典
# AR 阿根廷
# AU 澳大利亚
# BE 比利时

View File

@@ -0,0 +1,30 @@
"""1.0.13
Revision ID: 127a25fdf0e8
Revises: d71e624f0208
Create Date: 2024-02-24 03:11:32.005540
"""
import contextlib
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '127a25fdf0e8'
down_revision = 'd71e624f0208'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with contextlib.suppress(Exception):
with op.batch_alter_table("subscribe") as batch_op:
batch_op.add_column(sa.Column('search_imdbid', sa.Integer, nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
pass

View File

@@ -0,0 +1,31 @@
"""1_0_14
Revision ID: f94cd1217fd7
Revises: 127a25fdf0e8
Create Date: 2024-03-06 19:19:33.053186
"""
import contextlib
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'f94cd1217fd7'
down_revision = '127a25fdf0e8'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with contextlib.suppress(Exception):
with op.batch_alter_table("subscribe") as batch_op:
batch_op.add_column(sa.Column('manual_total_episode', sa.Integer, nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
pass

View File

@@ -1,17 +1,19 @@
#!/bin/bash
# shellcheck shell=bash
# shellcheck disable=SC2016
# 使用 `envsubst` 将模板文件中的 ${NGINX_PORT} 替换为实际的环境变量值
envsubst '${NGINX_PORT}${PORT}' < /etc/nginx/nginx.template.conf > /etc/nginx/nginx.conf
# 自动更新
cd /
/usr/local/bin/mp_update
cd /app
cd /app || exit
# 更改 moviepilot userid 和 groupid
groupmod -o -g ${PGID} moviepilot
usermod -o -u ${PUID} moviepilot
groupmod -o -g "${PGID}" moviepilot
usermod -o -u "${PUID}" moviepilot
# 更改文件权限
chown -R moviepilot:moviepilot \
${HOME} \
"${HOME}" \
/app \
/public \
/config \
@@ -27,6 +29,6 @@ if [ -S "/var/run/docker.sock" ]; then
haproxy -f /app/haproxy.cfg
fi
# 设置后端服务权限掩码
umask ${UMASK}
umask "${UMASK}"
# 启动后端服务
exec dumb-init gosu moviepilot:moviepilot python3 app/main.py

View File

@@ -2,7 +2,7 @@
from unittest import TestCase
from app.chain.cookiecloud import CookieCloudChain
from app.chain.site import SiteChain
class CookieCloudTest(TestCase):
@@ -13,5 +13,5 @@ class CookieCloudTest(TestCase):
pass
def test_cookiecloud(self):
result = CookieCloudChain().process()
result = SiteChain().sync_cookies()
self.assertTrue(result[0])

92
update
View File

@@ -1,14 +1,16 @@
#!/bin/bash
# shellcheck shell=bash
# shellcheck disable=SC2086
# shellcheck disable=SC2144
# 下载及解压
download_and_unzip() {
url="$1"
target_dir="$2"
echo "正在下载 ${url}..."
curl ${CURL_OPTIONS} "$url" ${CURL_HEADERS} | busybox unzip -d /tmp -
if [ $? -eq 0 ]; then
if curl ${CURL_OPTIONS} "${url}" ${CURL_HEADERS} | busybox unzip -d /tmp -; then
if [ -e /tmp/MoviePilot-* ]; then
mv /tmp/MoviePilot-* /tmp/${target_dir}
mv /tmp/MoviePilot-* /tmp/"${target_dir}"
fi
else
return 1
@@ -17,54 +19,64 @@ download_and_unzip() {
# 下载程序资源,$1: 后端版本路径
install_backend_and_download_resources() {
download_and_unzip "https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"
if [ $? -eq 0 ]; then
# 清理临时目录,上次安装失败可能有残留
rm -rf /tmp/*
if download_and_unzip "https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
echo "后端程序下载成功"
pip install ${PIP_OPTIONS} --upgrade pip
pip install ${PIP_OPTIONS} -r /tmp/App/requirements.txt
if [ $? -eq 0 ]; then
if pip install ${PIP_OPTIONS} -r /tmp/App/requirements.txt; then
echo "安装依赖成功"
download_and_unzip "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"
if [ $? -eq 0 ]; then
echo "插件下载成功"
download_and_unzip "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"
if [ $? -eq 0 ]; then
echo "资源包下载成功"
frontend_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
if [[ "${frontend_version}" == *v* ]]; then
download_and_unzip "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"
if [ $? -eq 0 ]; then
echo "前端程序下载成功"
# 备份插件目录
rm -rf /plugins
mv /app/app/plugins /plugins
# 清空目录
rm -rf /app
# 后端程序
mv /tmp/App /app
# 恢复插件目录
mv -f /plugins/* /app/app/plugins/
# 插件仓库
rsync -av --remove-source-files /tmp/Plugins/plugins/* /app/app/plugins/
frontend_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
if [[ "${frontend_version}" == *v* ]]; then
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
echo "前端程序下载成功"
# 提前备份插件目录
rm -rf /plugins
mkdir -p /plugins
cp -a /app/app/plugins/* /plugins/
# 不备份__init__.py
rm -f /plugins/__init__.py
# 清空目录
rm -rf /app
mkdir -p /app
# 后端程序
cp -a /tmp/App/* /app/
# 前端程序
rm -rf /public
mkdir -p /public
cp -a /tmp/dist/* /public/
# 清理临时目录
rm -rf /tmp/*
echo "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}"s
echo "开始更新插件..."
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"; then
echo "插件下载成功"
# 恢复插件目录
cp -a /plugins/* /app/app/plugins/
# 插件仓库
rsync -av --remove-source-files /tmp/Plugins/plugins/* /app/app/plugins/
# 清理临时目录
rm -rf /tmp/*
echo "插件更新成功"
echo "开始更新资源包..."
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"; then
echo "资源包下载成功"
# 资源包
mv -f /tmp/Resources/resources/* /app/app/helper/
# 前端程序
rm -rf /public
mv /tmp/dist /public
cp -a /tmp/Resources/resources/* /app/app/helper/
# 清理临时目录
rm -rf /tmp/*
echo "程序更新成功,前端版本:${frontend_version},后端版本:${1}"
echo "资源包更新成功"
else
echo "前端程序下载失败,继续使用旧的程序来启动..."
echo "资源包下载失败,继续使用旧的资源包来启动..."
fi
else
echo "前端最新版本号获取失败,继续启动..."
echo "插件下载失败,继续使用旧的插件来启动..."
fi
else
echo "资源包下载失败,继续使用旧的程序来启动..."
echo "前端程序下载失败,继续使用旧的程序来启动..."
fi
else
echo "插件下载失败,继续使用旧的程序来启动..."
echo "前端最新版本号获取失败,继续启动..."
fi
else
echo "安装依赖失败,请重新拉取镜像"
@@ -95,7 +107,7 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
echo "Release 更新模式"
old_version=$(cat /app/version.py)
if [[ "${old_version}" == *APP_VERSION* ]]; then
current_version=v$(echo ${old_version} | sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
current_version=v$(echo "${old_version}" | sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "当前版本号:${current_version}"
new_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
if [[ "${new_version}" == *v* ]]; then
@@ -118,4 +130,4 @@ elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
echo "程序自动升级已关闭如需自动升级请在创建容器时设置环境变量MOVIEPILOT_AUTO_UPDATE=release"
else
echo "MOVIEPILOT_AUTO_UPDATE 变量设置错误"
fi
fi

View File

@@ -1 +1 @@
APP_VERSION = 'v1.6.1'
APP_VERSION = 'v1.7.0-1'