Compare commits

...

627 Commits
v ... v1.6.2

Author SHA1 Message Date
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
jxxghp
b98a033cd2 v1.6.1
- 更改IYUU认证及辅种服务器地址
2024-01-30 17:24:49 +08:00
jxxghp
c69853ce4b Merge pull request #1428 from EkkoG/debug_step 2024-01-30 16:14:19 +08:00
EkkoG
e00a440336 修正按 README 中步骤本地运行时提示 No module named 'app' 2024-01-30 15:31:18 +08:00
jxxghp
c0eb6b0600 Merge pull request #1423 from EkkoG/fixed_size_limit 2024-01-29 16:38:09 +08:00
EkkoG
4d1c8c3764 Fixed #1416 2024-01-29 16:24:23 +08:00
jxxghp
62628e526c 更新 README.md 2024-01-24 11:45:33 +08:00
jxxghp
ad7761a785 rollback #1399 2024-01-24 10:56:39 +08:00
jxxghp
e545b8d900 Merge pull request #1399 from falling/main 2024-01-23 07:12:12 +08:00
falling
f2f1ecfdf1 更新qbittorrent下载判断值
https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#get-torrent-list
pausedDL	Torrent is paused and has NOT finished downloading
2024-01-21 19:51:38 +08:00
jxxghp
fdec997ed0 更新 app.env 2024-01-19 23:00:07 +08:00
jxxghp
9b653ceec9 更新 README.md 2024-01-19 22:58:21 +08:00
jxxghp
fbaaed1c61 更新 message.py 2024-01-19 22:55:45 +08:00
jxxghp
639abf67c2 v1.6.0
- 全新安装时,超级管理员初始密码为随机生成,并只能在首次启动的后台日志中查看(使用初始密码登录成功后可在设定中修改)。
- 用户密码修改需要同时包含大小写和数字。
- 修复了第三个插件依赖库无法自动安装的问题。
2024-01-19 11:20:30 +08:00
jxxghp
1f56ceaea9 更新 user.py 2024-01-19 10:54:13 +08:00
jxxghp
16a4f61fec Merge pull request #1386 from hussion/dev 2024-01-19 10:52:10 +08:00
jxxghp
ea0aba96fd Merge pull request #1383 from thsrite/main 2024-01-19 10:51:31 +08:00
嫣识
4393dad77c fix:修复bearer auth请求头设置错误,导致github_token参数应用失败 2024-01-18 19:58:06 +08:00
jxxghp
d099c0e702 更新 README.md 2024-01-18 12:05:12 +08:00
thsrite
a299d786fe feat 超级管理员初始化密码随机生成 && 修改密码强制要求大写小写数字组合 2024-01-18 11:08:16 +08:00
jxxghp
3500f5b9a6 Merge pull request #1378 from thsrite/main 2024-01-18 08:22:16 +08:00
thsrite
64233c89d7 fix emby/jellyfin首页继续观看、最近添加兼容共享路径 2024-01-17 16:59:22 +08:00
jxxghp
8c727da58a Merge pull request #1375 from thsrite/main 2024-01-17 12:06:03 +08:00
thsrite
152a87d109 fix 解决三方插件依赖安装失败 2024-01-17 10:14:49 +08:00
jxxghp
6a2cde0664 - Bug修复 2024-01-16 20:24:58 +08:00
jxxghp
c86cc2cb51 Merge pull request #1353 from thsrite/main 2024-01-12 19:04:14 +08:00
thsrite
6d7a63ff61 fix c044e594 2024-01-12 10:07:39 +08:00
thsrite
c044e59481 fix emby/jellyfin首页继续观看、最近添加 2024-01-12 09:56:55 +08:00
jxxghp
3c31bf24e5 v1.5.9
- 修复了个别情况下订阅重复下载的问题
- 仪表板中继续观看和最近添加组件支持过滤媒体库黑名单内容
- 增加了Fanart的开关设置(FANART_ENABLE,默认开),关闭后可减少网络请求但刮削图片数量会大幅减少
2024-01-11 20:18:58 +08:00
jxxghp
d89c80ac89 fix #1296 2024-01-11 16:35:51 +08:00
jxxghp
8236d6c8d7 Merge pull request #1328 from thsrite/main 2024-01-10 12:25:19 +08:00
thsrite
3646540a7f fix 2024-01-10 11:21:03 +08:00
thsrite
c1ecdfc61d Revert "fix #907"
This reverts commit 4dcefb141a.
2024-01-10 11:19:25 +08:00
thsrite
7587946d51 fix c674e320 2024-01-10 10:53:32 +08:00
jxxghp
3ad64baaeb 更新 README.md 2024-01-10 10:38:43 +08:00
jxxghp
24c43b53a2 Merge pull request #1338 from thsrite/fanart_switch 2024-01-10 10:29:59 +08:00
thsrite
53a6a1c691 feat Fanart开关支持环境变量配置,默认开启 2024-01-10 10:13:51 +08:00
jxxghp
c3ba83c7ca fix:订阅重复下载问题 2024-01-09 13:16:39 +08:00
jxxghp
d9b349873e v1.5.8
- 修复了启用内置代理时媒体组件无法显示图片的问题
- 优化媒体组件用户匹配,优先展示媒体服务器中同名用户的信息
- 用户认证失败时发送消息提醒
- UI主题支持跟随系统主题自动切换
2024-01-08 13:18:34 +08:00
thsrite
4dcefb141a fix #907 2024-01-08 13:05:48 +08:00
thsrite
c674e32046 fix 首页继续观看、最近添加排除黑名单媒体库 2024-01-08 13:05:09 +08:00
jxxghp
8aa1027aae fix image proxy 2024-01-08 12:24:00 +08:00
jxxghp
b4cb9c3fb3 fix 2024-01-07 18:24:13 +08:00
jxxghp
d82ab5d60d feat:用户认证失败时发送消息提醒 2024-01-07 12:10:51 +08:00
jxxghp
979b636eec fix bug 2024-01-07 11:51:49 +08:00
jxxghp
bf8a75b201 fix:优化emby、jellyfin用户匹配 2024-01-07 11:46:29 +08:00
jxxghp
87111c8736 fix exists api 2024-01-06 11:20:08 +08:00
jxxghp
9b97e478aa - 修复Plex媒体图片展示与跳转 2024-01-06 10:59:46 +08:00
jxxghp
2af7abee3c fix #1320 2024-01-06 08:46:18 +08:00
jxxghp
2c8a41ebad fix #1316 2024-01-06 08:31:07 +08:00
jxxghp
c632cfd6b9 - 优化媒体组件的图片代理 2024-01-05 21:40:21 +08:00
jxxghp
7f05df2fb3 fix count 2024-01-05 21:37:19 +08:00
jxxghp
ff33432809 fix api 2024-01-05 21:35:01 +08:00
jxxghp
0a57e69bcf v1.5.7
- 媒体服务器支持配置外网播放地址,媒体详情支持跳转在线播放
- `设定-订阅`中增加了文件大少过滤规则,以及控制订阅时是否立即弹出编辑框的选项(默认关闭)
- 仪表板显示的组件支持自定义,同时增加了媒体库相关面板组件
- 支持插件将定时作业任务注册到主程序,以在`设定-服务`中统一管理
2024-01-05 20:47:53 +08:00
jxxghp
7af8b15dbb fix apis 2024-01-05 20:31:15 +08:00
jxxghp
bc4931d971 fix api 2024-01-05 20:21:19 +08:00
jxxghp
cfb029b6b4 fix api 2024-01-05 15:58:47 +08:00
jxxghp
6fa50101a6 Merge pull request #1314 from thsrite/main 2024-01-05 13:00:38 +08:00
thsrite
843fbc83f4 fix 集如果带有.会刮削错误 2024-01-05 12:53:47 +08:00
jxxghp
55f8fb3b66 Merge pull request #1313 from thsrite/main 2024-01-05 11:52:39 +08:00
thsrite
a47774472d fix bug 2024-01-05 11:50:05 +08:00
jxxghp
713f4ca356 fix typo 2024-01-05 08:18:01 +08:00
jxxghp
b06795510a feat:插件支持注册公共服务 2024-01-05 08:12:27 +08:00
jxxghp
0f57ec099a Merge remote-tracking branch 'origin/main' 2024-01-04 20:54:46 +08:00
jxxghp
8325caabdc fix api 2024-01-04 20:53:59 +08:00
jxxghp
44d276d7e7 Merge pull request #1305 from honue/main 2024-01-04 07:08:07 +08:00
honue
935340561b package获取失败,增加日志warn 2024-01-03 22:34:01 +08:00
jxxghp
a60fde3b91 fix 2024-01-03 21:29:23 +08:00
jxxghp
163a855d5c fix play url api 2024-01-03 18:38:40 +08:00
jxxghp
c9b1e75361 fix 2024-01-03 18:07:48 +08:00
jxxghp
a9932d0866 fix 2024-01-03 17:40:13 +08:00
jxxghp
11d29919bf feat:大小过滤 2024-01-03 17:28:11 +08:00
jxxghp
4fe755332d fix bug 2024-01-03 12:42:47 +08:00
jxxghp
0095e0f4dd feat:播放跳转api 2024-01-03 12:02:08 +08:00
jxxghp
322c72ab54 feat:mediaserver apis 2024-01-02 20:54:54 +08:00
jxxghp
4d51459a47 v1.5.6
- 修复了插件重复显示的问题
- 站点资源支持显示免费剩余时间和H&R标志(仅部分站点)
- 刷流插件升级,支持排除H&R

提示:涉及前端改动时,可能需要清理浏览器缓存才能显示更新内容
2024-01-01 20:18:20 +08:00
jxxghp
d51de30898 Merge remote-tracking branch 'origin/main' 2024-01-01 19:44:08 +08:00
jxxghp
90f9edbf24 fix bug 2024-01-01 19:43:55 +08:00
jxxghp
8aa10457a7 Merge pull request #1294 from honue/main 2024-01-01 15:46:09 +08:00
honue
ab584720c6 fix 本地插件未安装,但不在市场显示的情况(v2) 2024-01-01 15:33:13 +08:00
jxxghp
56ad281cb6 feat:4X 2024-01-01 11:56:03 +08:00
jxxghp
61281cca02 feat:免费剩余时间 && HR 2024-01-01 10:22:18 +08:00
jxxghp
b53dbbc38e rollback #1287 2023-12-31 10:36:09 +08:00
jxxghp
3f88cfba28 fix #1287 2023-12-31 10:34:21 +08:00
jxxghp
e855d8b9af - 修复了1PTBA无法认证的问题
- 修复了个别情况下仍有一集缺失时提前完成订阅的问题
- 修复了电影订阅本地已存在时不完成订阅的问题
- 优化了资源搜索、订阅日历、历史记录界面
2023-12-31 09:56:46 +08:00
jxxghp
171720e629 fix bug 2023-12-31 09:41:02 +08:00
jxxghp
8aa6b33fba v1.5.5
- 修复了1PTBA无法认证的问题
- 修复了个别情况下仍有一集缺失时提前完成订阅的问题
- 修复了电影订阅本地已存在时不完成订阅的问题
- 优化了资源搜索、订阅日历、历史记录界面
2023-12-31 08:54:54 +08:00
jxxghp
505fc803db fix README.md 2023-12-31 08:46:38 +08:00
jxxghp
b5146620a6 fix #1266 2023-12-31 08:38:54 +08:00
jxxghp
7d44f24347 fix #1276 2023-12-31 08:21:58 +08:00
jxxghp
4dccc6e860 Merge pull request #1287 from honue/main 2023-12-29 21:30:34 +08:00
honue
ee6585c737 fix 本地插件未安装,但不在市场显示的情况 2023-12-29 18:32:01 +08:00
jxxghp
62e5e8a69f Merge pull request #1279 from thsrite/main 2023-12-25 17:08:13 +08:00
thsrite
e942a99ff0 fix bug 2023-12-25 15:30:10 +08:00
jxxghp
b3fe49684b fix bug 2023-12-23 19:51:35 +08:00
jxxghp
dcf1985361 - 修复了未设置订阅站点时无法编辑订阅的问题
- 历史记录支持过滤状态
2023-12-23 19:32:34 +08:00
jxxghp
8f4f4cc004 fix #1215 2023-12-23 18:49:01 +08:00
jxxghp
f49baadb76 fix #1225 2023-12-23 18:24:07 +08:00
jxxghp
5233484fc5 Merge pull request #1265 from honue/main 2023-12-20 07:57:29 +08:00
Summer⛱
84c4cc8b5d Update .gitignore 2023-12-19 17:36:58 +08:00
jxxghp
77036eccd8 v1.5.3 2023-12-17 10:59:27 +08:00
jxxghp
dcdb08ec80 feat:路径识别支持到3级 2023-12-17 10:59:02 +08:00
jxxghp
cd7f688e78 feat:刮削模块支持覆盖 2023-12-17 10:49:00 +08:00
jxxghp
cb12a052ac - 修复历史记录重新整理时路径不正确的问题 2023-12-16 12:21:22 +08:00
jxxghp
995c359f20 Merge pull request #1234 from thsrite/main 2023-12-14 06:28:50 +08:00
jxxghp
690066ad32 - 修复整理时不自动创建目标路径的问题 2023-12-13 06:53:11 +08:00
thsrite
73942e315a feat 订阅增加保存路径设置 2023-12-12 14:01:14 +08:00
jxxghp
48badb3243 Merge pull request #1228 from EkkoG/fixed_move_failed_msg 2023-12-11 19:31:19 +08:00
EkkoG
d5eb12cc4e 修复无法入库消息发送到 Telegram 时格式异常 2023-12-11 17:51:21 +08:00
jxxghp
7d7539df4c - 目录监控、手动整理等不指定目的目录时,不再强制创建一级分类目录,根据开关判定是否创建二级分类目录 2023-12-11 17:24:34 +08:00
jxxghp
14a8f44f8c fix bug 2023-12-10 18:51:20 +08:00
jxxghp
a7be470f33 v1.5.0 2023-12-10 17:49:05 +08:00
jxxghp
a677169f60 fix #1219 指定转移目录时不强制添加一级分类目录 2023-12-10 17:48:14 +08:00
jxxghp
b72ef4f2aa fix #1139 洗版重复下载问题 2023-12-10 17:27:34 +08:00
jxxghp
403054751b Merge remote-tracking branch 'origin/main' 2023-12-10 13:35:01 +08:00
jxxghp
b3e5c734d4 add ffmpeg 2023-12-10 13:34:54 +08:00
jxxghp
5732125ff6 Merge pull request #1221 from Vincwnt/main 2023-12-10 11:52:53 +08:00
林晓昱
eb66cf7aad fix 自定义识别词的媒体type正则bug 2023-12-10 11:34:39 +08:00
jxxghp
a317c35eab fix 集图片命名 2023-12-06 13:08:15 +08:00
jxxghp
ab138560c1 v1.4.9 2023-12-06 10:55:31 +08:00
jxxghp
f0fbad889d - 偿试修复可执行文件打包插件数据表缺失问题 2023-12-05 20:57:53 +08:00
jxxghp
1323cd5dc6 fix 插件下载Bug 2023-12-04 17:27:33 +08:00
jxxghp
2c43d8e145 fix 插件下载Bug 2023-12-04 16:58:39 +08:00
jxxghp
0214beb679 - 修复二进制打包插件表缺失的问题 2023-12-04 11:33:16 +08:00
jxxghp
7d73cdef33 Merge remote-tracking branch 'origin/main' 2023-12-04 11:03:00 +08:00
jxxghp
fcfab2c750 feat 命名增加豆瓣ID/识别英文名称 2023-12-04 11:02:53 +08:00
jxxghp
e048be17a5 Merge pull request #1195 from WithdewHua/fix-mediaserver 2023-12-02 12:47:20 +08:00
WithdewHua
024f1de4f1 fix: 清空 MediaServerItem 表 2023-12-02 12:31:38 +08:00
jxxghp
d2c9f7a778 更新 README.md 2023-12-01 18:39:40 +08:00
jxxghp
1784d2ec61 v1.4.8 2023-12-01 10:51:49 +08:00
jxxghp
8f4a213f55 fix QB&TR 判重 2023-12-01 08:12:06 +08:00
jxxghp
6a8c684af0 feat 兼容TR下载任务重复添加 2023-11-30 15:58:44 +08:00
jxxghp
aefba83319 fix 2023-11-30 13:35:26 +08:00
jxxghp
e411f4062a feat 兼容QB下载任务重复添加 2023-11-30 13:14:55 +08:00
jxxghp
3c6802860d fix #1186 2023-11-30 11:19:43 +08:00
jxxghp
3daad5ea90 Merge pull request #1186 from honue/main 2023-11-30 11:08:00 +08:00
jxxghp
6835c38c24 Merge remote-tracking branch 'origin/main' 2023-11-30 08:16:30 +08:00
honue
32be9b71d5 Merge remote-tracking branch 'origin/main' 2023-11-30 07:42:19 +08:00
honue
84bc0e0fb4 fix 2023-11-30 07:41:58 +08:00
Summer⛱
8105dc9c82 Merge branch 'jxxghp:main' into main 2023-11-30 00:42:23 +08:00
honue
840c968454 tr增加修改tracker方法 2023-11-30 00:39:57 +08:00
honue
8c5f19a0f4 fix 2023-11-30 00:39:35 +08:00
jxxghp
bf813aa906 Merge pull request #1181 from honue/main 2023-11-29 13:29:40 +08:00
honue
e5ac7f10d4 update downloader init 2023-11-29 13:23:28 +08:00
jxxghp
d049e04fa2 v1.4.7 2023-11-29 13:13:58 +08:00
jxxghp
ef45b08ee5 Merge pull request #1180 from honue/main 2023-11-29 13:01:18 +08:00
honue
d77644ab16 fix singleton.py 2023-11-29 12:46:49 +08:00
jxxghp
1a90b4a3cb v1.4.7 2023-11-29 12:39:37 +08:00
jxxghp
9e5d401a85 remove plugin_color 2023-11-29 12:07:54 +08:00
jxxghp
6da6dc2b8c Merge pull request #1178 from cikezhu/main 2023-11-29 08:43:26 +08:00
叮叮当
2ca7021df0 fix item_id 2023-11-29 08:35:44 +08:00
jxxghp
1682cdad37 fix README.md 2023-11-28 14:55:05 +08:00
jxxghp
ec5e898feb fix app.env 2023-11-27 08:36:45 +08:00
jxxghp
ccf6fb1b36 fix 插件重置 2023-11-27 08:30:13 +08:00
jxxghp
83c8d619d0 fix README.md 2023-11-27 08:17:23 +08:00
jxxghp
dd5b8219a1 更新 plugin.py 2023-11-27 07:09:38 +08:00
jxxghp
34fd927972 更新 README.md 2023-11-26 22:50:43 +08:00
jxxghp
6db5ad2697 feat:插件重置 2023-11-26 21:40:43 +08:00
jxxghp
0be1f27970 - 修复了种子失效时订阅一直偿试下载的问题
- 新增配置中心插件,可快速调整配置
2023-11-26 20:16:44 +08:00
jxxghp
54603798fc fix bug 2023-11-24 15:26:54 +08:00
jxxghp
2f1e947323 fix #1144 2023-11-24 15:24:38 +08:00
jxxghp
ef2cfe1c1d fix #1141 修复错误种子链接一直偿试下载的问题 2023-11-24 14:32:56 +08:00
jxxghp
f8edf79c59 fix api doc 2023-11-24 14:00:52 +08:00
jxxghp
dec80b6567 fix 2023-11-23 17:25:54 +08:00
jxxghp
4dac9237ef fix README.md 2023-11-23 17:22:13 +08:00
jxxghp
12f5f373b3 fix frozen 2023-11-23 10:59:33 +08:00
jxxghp
76472770bf v1.4.5 2023-11-22 15:35:12 +08:00
jxxghp
f5baf77c3c Merge pull request #1155 from thsrite/main 2023-11-22 13:42:33 +08:00
thsrite
126276c727 fix #1154 2023-11-22 13:39:39 +08:00
jxxghp
5d6ba83fc5 Merge pull request #1153 from thsrite/main 2023-11-22 09:23:19 +08:00
thsrite
047c3b596d add 插件方法 2023-11-22 09:21:51 +08:00
jxxghp
2240ff08a1 fix 插件事件依赖 2023-11-22 07:25:00 +08:00
jxxghp
4d6bf56fa0 fix bug 2023-11-21 21:44:50 +08:00
jxxghp
10ad9a5601 fix bug 2023-11-21 21:20:02 +08:00
jxxghp
fb39500428 feat 优化插件仓库URL格式 2023-11-21 21:10:34 +08:00
jxxghp
615a52cef9 更新 build.yml 2023-11-20 21:53:38 +08:00
jxxghp
791be0583a fix build 2023-11-20 20:14:07 +08:00
jxxghp
a324731061 - 优化豆瓣详情展示 2023-11-20 20:06:25 +08:00
jxxghp
539d9cf537 - 优化豆瓣详情展示 2023-11-20 20:01:04 +08:00
jxxghp
699312ff28 - 优化豆瓣详情展示 2023-11-20 19:56:52 +08:00
jxxghp
b92b0ec149 fix build 2023-11-20 19:54:41 +08:00
jxxghp
51536062f1 fix 2023-11-20 16:43:49 +08:00
jxxghp
4c230b4c1e feat 清理缓存时清理种子缓存 2023-11-20 10:56:28 +08:00
jxxghp
a7752ceb17 fix bug 2023-11-19 09:48:06 +08:00
jxxghp
ed59a90d78 fix update timeout 2023-11-19 09:45:13 +08:00
jxxghp
11d65e7527 fix douban scraper 2023-11-19 09:05:58 +08:00
jxxghp
b6ac5f0f84 feat 完善豆瓣详情API 2023-11-18 22:37:23 +08:00
jxxghp
c336e62885 Merge remote-tracking branch 'origin/main' 2023-11-18 21:54:10 +08:00
jxxghp
b868cdb25e feat 完善豆瓣详情API 2023-11-18 21:53:58 +08:00
jxxghp
04339539d1 fix bug 2023-11-17 14:25:00 +08:00
jxxghp
2d146880ec fix 2023-11-17 13:36:23 +08:00
jxxghp
6eec4ef7f4 fix build 2023-11-17 12:27:43 +08:00
jxxghp
9841f3dd18 fix build 2023-11-17 11:43:01 +08:00
jxxghp
03e0118fb7 add linux frozen build 2023-11-17 11:39:03 +08:00
jxxghp
c7c222b357 v1.4.3 2023-11-17 11:12:52 +08:00
jxxghp
2d8e45cd1b feat TG消息重试 2023-11-17 10:41:14 +08:00
jxxghp
e2bd5cc245 更新 resource.py 2023-11-16 21:10:21 +08:00
jxxghp
47ddfec30e feat 资源包自动更新 2023-11-16 20:57:41 +08:00
jxxghp
9344b2a324 add AGSVPT 2023-11-16 17:27:58 +08:00
jxxghp
8496fcccc5 Merge pull request #1133 from honue/main 2023-11-16 14:59:50 +08:00
honue
c6fa3b9d25 fix torrent_cache ttl 2023-11-16 13:34:23 +08:00
jxxghp
ce13987748 - 修复Windows打包 2023-11-15 08:20:57 +08:00
jxxghp
5221fc4f6a fix #1125 2023-11-15 08:08:46 +08:00
jxxghp
d4dc388d3f fix search 2023-11-14 17:54:41 +08:00
jxxghp
42966c2537 fix #1122 2023-11-14 17:35:31 +08:00
jxxghp
7921dcd86b Merge pull request #1122 from thsrite/main 2023-11-14 17:30:52 +08:00
thsrite
4c69bb6c48 fix 2023-11-14 16:22:04 +08:00
thsrite
0aad809c82 feat 增加GITHUB_TOKEN,提高API限流阈值 2023-11-14 16:12:02 +08:00
thsrite
f514a5a416 fix 793a7460 2023-11-14 14:17:14 +08:00
thsrite
793a7460c6 fix 动漫电影 剧场版 2023-11-14 14:10:48 +08:00
jxxghp
6dcc979fd5 fix #1072
fix #1118
2023-11-14 08:19:57 +08:00
jxxghp
5a07732712 fix 豆瓣已订阅显示 2023-11-13 20:25:57 +08:00
jxxghp
61d71b32ff fix 豆瓣已订阅显示 2023-11-13 20:22:51 +08:00
jxxghp
ba62ca3d18 - 文件整理支持仅保留最新版本文件(转移覆盖模式设为:latest
- 重启时会识别用户已安装过但本地不存在的插件并自动下载安装,避免升级重置后第三方插件消失的问题
2023-11-13 12:11:43 +08:00
jxxghp
612271bf0c fix 缺失状态识判 2023-11-13 12:00:53 +08:00
jxxghp
3b99fb5c96 fix #1109 2023-11-12 19:10:46 +08:00
jxxghp
bb61f8197c fix #1110 2023-11-12 19:01:25 +08:00
jxxghp
b54f04a35b fix #1110 2023-11-12 18:51:24 +08:00
jxxghp
d47639bada fix #1110 2023-11-12 18:51:08 +08:00
jxxghp
ae9bab2981 Merge pull request #1110 from thsrite/update
fix 重启或重置后三方插件丢失问题
2023-11-12 18:44:45 +08:00
thsrite
2116b094ad fix 三方插件丢失问题 2023-11-11 23:40:39 -06:00
jxxghp
288883a13b feat 文件整理支持仅保留最新版本 2023-11-12 08:03:15 +08:00
jxxghp
07c988abae fix transfer message 2023-11-12 07:21:57 +08:00
jxxghp
fd4a3b5671 fix bug 2023-11-11 15:23:10 +08:00
jxxghp
71adfad94d fix bug 2023-11-11 14:57:38 +08:00
jxxghp
7faaaf3dcd fix bug 2023-11-11 14:14:09 +08:00
jxxghp
25e7db5ac9 fix seerr api 2023-11-11 12:20:27 +08:00
jxxghp
07bd5f1926 fix seerr api 2023-11-11 12:16:45 +08:00
jxxghp
9439d02351 fix TMDB缓存None的问题 2023-11-11 10:37:33 +08:00
jxxghp
cbea7ccdf6 fix TMDB缓存None的问题 2023-11-11 10:34:49 +08:00
jxxghp
93661dfde4 v1.4.1 2023-11-10 22:23:13 +08:00
jxxghp
b98f5351cf fix #1098 2023-11-10 21:56:38 +08:00
jxxghp
83a7261fcd fix #1101 2023-11-10 21:54:45 +08:00
jxxghp
daa2b7a8cd feat 部分API支持api token访问 2023-11-10 21:40:24 +08:00
jxxghp
d245fedb3f fix api token 2023-11-10 20:51:09 +08:00
jxxghp
b0fee2cb3c add token verify 2023-11-10 17:31:19 +08:00
jxxghp
9a102056d8 fix douban rank 2023-11-10 12:23:03 +08:00
jxxghp
3905463940 fix douban mode 2023-11-09 23:44:08 +08:00
jxxghp
746fde592d fix douban mode 2023-11-09 23:22:38 +08:00
jxxghp
3e5f5554da fix bug 2023-11-09 21:47:22 +08:00
jxxghp
01fb6e8772 fix bug 2023-11-09 21:15:36 +08:00
jxxghp
b7448232e6 fix bug 2023-11-09 19:56:56 +08:00
jxxghp
05f1a24199 feat 支持豆瓣做为识别源 2023-11-09 17:32:26 +08:00
jxxghp
4072799c13 fix #1064 2023-11-06 11:29:43 +08:00
jxxghp
9744032f93 Merge remote-tracking branch 'origin/main' 2023-11-06 08:14:13 +08:00
jxxghp
eb9a92d76d v1.4.0 2023-11-06 08:06:57 +08:00
jxxghp
89a4932823 fix plugin get_state 2023-11-05 21:20:27 +08:00
jxxghp
cef06a8894 fix message event 2023-11-05 08:41:45 +08:00
jxxghp
c741edffb0 fix 2023-11-04 07:52:48 +08:00
jxxghp
e7c543fcb9 feat 第三方插件支持依赖 2023-11-04 07:31:14 +08:00
jxxghp
2a61720b0a Merge pull request #1052 from DDS-Derek/main 2023-11-03 19:00:06 +08:00
DDSRem
73484647ba feat: optimize restart update 2023-11-03 18:33:53 +08:00
jxxghp
c9d461f8c8 更新 update 2023-11-03 12:46:29 +08:00
jxxghp
9bdc056359 fix time 2023-11-03 12:29:38 +08:00
jxxghp
6a8a1e799d fix 同步用户提权Bug 2023-11-03 12:19:05 +08:00
jxxghp
c3c55f3a13 - 修复插件事件重复执行的问题 2023-11-03 09:13:37 +08:00
jxxghp
6f881a80d6 - 修复插件事件重复执行的问题 2023-11-03 09:13:18 +08:00
jxxghp
a75c4110a8 fix #1047 2023-11-03 07:51:42 +08:00
jxxghp
3e031c6191 fix #1047 2023-11-03 07:28:34 +08:00
jxxghp
a4b7ca824e v1.3.9-1 2023-11-02 17:50:31 +08:00
jxxghp
ecab2b63c9 fix #1044 2023-11-02 17:42:52 +08:00
jxxghp
620e3d55d1 fix 插件更新不立即生效的问题 2023-11-02 13:38:50 +08:00
jxxghp
3716d7fd47 fix bug 2023-11-02 12:35:25 +08:00
jxxghp
60764d198a Merge remote-tracking branch 'origin/main' 2023-11-02 12:27:36 +08:00
jxxghp
3178d9da88 fix update 2023-11-02 12:27:30 +08:00
jxxghp
7264313c9c 更新 README.md 2023-11-02 11:52:51 +08:00
jxxghp
71c36881fb fix update 2023-11-02 11:47:24 +08:00
jxxghp
5a0f7ae838 fix 2023-11-02 11:29:48 +08:00
jxxghp
10fb61bd57 fix repo cache 2023-11-02 11:20:11 +08:00
jxxghp
6d4b4c6ba7 fix bug 2023-11-02 11:06:37 +08:00
jxxghp
798a737f06 fix 在线插件去重(以后面的为准) 2023-11-02 11:00:49 +08:00
jxxghp
0a9e125f89 feat 支持ptvicomo认证 2023-11-02 10:18:32 +08:00
jxxghp
21b3525f23 v1.3.9 2023-11-02 08:29:23 +08:00
jxxghp
8e842c385d fix bug 2023-11-02 08:19:41 +08:00
jxxghp
8e8a587bca fix bug 2023-11-02 08:12:36 +08:00
jxxghp
155aa2580b fix build 2023-11-02 07:51:17 +08:00
jxxghp
01aa381848 fix timeout 2023-11-02 00:06:10 +08:00
jxxghp
6c13fa02c1 fix update 2023-11-02 00:04:46 +08:00
jxxghp
c50576b508 fix update 2023-11-01 22:48:52 +08:00
jxxghp
7bc4a6906a fix README.md 2023-11-01 22:17:58 +08:00
jxxghp
c80318f442 fix py修改不生效问题 2023-11-01 22:08:43 +08:00
jxxghp
cd4229a915 fix bug 2023-11-01 22:05:48 +08:00
jxxghp
fbe306ba90 feat 在线仓库插件安装 2023-11-01 20:56:38 +08:00
jxxghp
0dac3f1b1d fix gitignore 2023-11-01 17:58:48 +08:00
jxxghp
7a90e6c1a7 fix update 2023-11-01 17:49:52 +08:00
jxxghp
42a4a8639d fix README.md 2023-11-01 17:45:46 +08:00
jxxghp
a687642a6a fix update 2023-11-01 17:33:00 +08:00
jxxghp
dbba7fc92a feat 拆分资源包 2023-11-01 17:27:17 +08:00
jxxghp
a0afd86b6a fix update 2023-11-01 16:56:57 +08:00
jxxghp
22b76f9919 feat 拆分插件仓库 2023-11-01 16:50:53 +08:00
jxxghp
c7a869b750 feat 媒体库刷新移植为插件 2023-11-01 16:20:15 +08:00
jxxghp
00052efbbc feat 媒体服务器通知插件 2023-11-01 15:53:45 +08:00
jxxghp
a36332581a add helper 2023-11-01 15:25:30 +08:00
jxxghp
8c81e6ae02 add platform 2023-11-01 14:13:41 +08:00
jxxghp
a7285f2b1a add versions 2023-11-01 12:23:24 +08:00
jxxghp
271b33ecdc Merge pull request #1034 from thsrite/main 2023-11-01 12:08:53 +08:00
thsrite
b4d07cf6ab fix 2023-11-01 12:06:05 +08:00
thsrite
382035768e fix 兼容emby webhook删除season没有tmdbid 2023-11-01 11:51:07 +08:00
jxxghp
fcb825c1e2 Merge pull request #1033 from honue/main 2023-11-01 11:43:09 +08:00
honue
abfeea63f7 fix README.md 2023-11-01 11:39:21 +08:00
jxxghp
b0ce7e6531 fix resources 2023-11-01 11:37:23 +08:00
honue
303aa9b580 fix rss报文设置编码问题 2023-11-01 11:30:26 +08:00
jxxghp
d016d239e3 fix README.md 2023-11-01 10:08:53 +08:00
jxxghp
e35838c326 Merge remote-tracking branch 'origin/main' 2023-11-01 10:08:47 +08:00
jxxghp
de15f9b56e fix README.md 2023-11-01 10:08:40 +08:00
jxxghp
560773a11a Merge pull request #1030 from Shurelol/main 2023-11-01 08:35:37 +08:00
Ma
dd6df471dc fix 2023-11-01 07:43:49 +08:00
Ma
eea5c056f3 fix 2023-11-01 07:34:48 +08:00
Ma
a2d503b2f5 fix #1029 2023-11-01 07:29:53 +08:00
jxxghp
cf13e4a4fa Merge pull request #1028 from thsrite/main 2023-10-31 16:55:53 +08:00
thsrite
45fb8e86bf fix webhook 2023-10-31 16:39:20 +08:00
thsrite
099dcda185 fix webhook 2023-10-31 16:33:48 +08:00
thsrite
c07e12cc5a fix webhook 2023-10-31 15:37:39 +08:00
jxxghp
b23f78e94d v1.3.8 2023-10-31 11:50:18 +08:00
jxxghp
812a9a55d0 fix Plugin Alerts UI 2023-10-31 11:48:36 +08:00
jxxghp
2e289e80d1 fix 2023-10-31 10:37:36 +08:00
jxxghp
0d3dfdcbda feat 服务增加清理缓存 2023-10-31 10:32:56 +08:00
jxxghp
87eae72f51 fix #1024 2023-10-31 07:08:28 +08:00
jxxghp
17fa7101bd fix so 2023-10-30 13:35:19 +08:00
jxxghp
312bd53079 fix #1012 2023-10-29 16:08:34 +08:00
jxxghp
4bc7d47576 Merge pull request #1012 from Shurelol/main 2023-10-29 15:56:34 +08:00
Shurelol
71445b56f1 feat: 名称识别支持tmdbid等标记 2023-10-29 13:49:08 +08:00
jxxghp
9ce9e0a4ef fix #1006 2023-10-28 20:27:48 +08:00
jxxghp
ae196f1aeb Merge pull request #1006 from thsrite/main
fix bug
2023-10-28 20:27:25 +08:00
thsrite
38e09b894d fix bug 2023-10-28 20:24:16 +08:00
jxxghp
247d5ff255 Merge pull request #999 from honue/main
enhance 定期清理插件
2023-10-28 20:20:28 +08:00
jxxghp
0091e462fa Merge pull request #1004 from thsrite/main
fix 下载进度推送username
2023-10-28 20:19:12 +08:00
thsrite
7b314970b5 fix 下载进度推送username 2023-10-28 19:47:27 +08:00
jxxghp
7ac881e3e3 Merge pull request #1003 from thsrite/main 2023-10-28 19:31:29 +08:00
thsrite
8874723632 fix 认证失败后插件站点缺失bug 2023-10-28 19:14:08 +08:00
thsrite
262bda94c4 fix 目录监控入库消息延迟支持自定义 2023-10-28 18:52:46 +08:00
honue
d6e2cab5ef 兼容1.3.7版本清理插件配置 2023-10-28 18:40:27 +08:00
Summer⛱
6d3e33a05d Merge branch 'main' into main 2023-10-28 18:30:01 +08:00
jxxghp
f2d0bec0ac fix README 2023-10-28 17:46:57 +08:00
jxxghp
dea78f4bfd fix 2023-10-28 17:45:28 +08:00
jxxghp
f85f4b1342 Merge pull request #1000 from WithdewHua/qb
feat: qb 支持强制继续
2023-10-28 17:45:03 +08:00
WithdewHua
d03771f8ab feat: qb 支持强制继续 2023-10-28 17:41:31 +08:00
jxxghp
4b655dfac4 fix #957
fix #982
2023-10-28 17:41:22 +08:00
honue
cdfcdd80bf fix 2023-10-28 17:16:59 +08:00
honue
64d3942ba9 enhance 定期清理插件 2023-10-28 17:11:48 +08:00
jxxghp
16cce73f82 Merge pull request #996 from honue/main 2023-10-28 13:15:36 +08:00
honue
846edff84a fix 豆瓣榜单插件 2023-10-28 13:13:06 +08:00
jxxghp
d038bf31d3 Merge pull request #995 from honue/main 2023-10-28 12:41:50 +08:00
honue
376a69af5c fix 豆瓣榜单插件 2023-10-28 12:36:06 +08:00
jxxghp
380bb9bb3d Merge pull request #994 from thsrite/main 2023-10-28 12:26:27 +08:00
thsrite
f59e10ae1d fix qb按顺序下载支持变量配置 2023-10-28 12:24:24 +08:00
jxxghp
c8d2d80cc5 feat 支持配置多个认证站点 2023-10-28 11:50:50 +08:00
jxxghp
f0bb9ddfca Merge pull request #993 from thsrite/main
fix 目录监控消息电影不用等待直接发送入库消息
2023-10-28 11:46:23 +08:00
thsrite
9ab86e4a85 fix 目录监控消息电影不用等待直接发送入库消息 2023-10-28 11:41:44 +08:00
jxxghp
e33f1a3ffc Merge pull request #992 from thsrite/main
fix plugin api
2023-10-28 11:28:36 +08:00
jxxghp
e2213e1ef6 fix 远程搜索选择序号问题 2023-10-28 11:25:24 +08:00
thsrite
bbc4a1bfa5 fix plugin api 2023-10-28 11:22:37 +08:00
jxxghp
61e7ec9a36 Merge pull request #991 from honue/main 2023-10-28 11:06:25 +08:00
jxxghp
534ad0bad6 Merge pull request #987 from thsrite/main 2023-10-28 11:04:21 +08:00
thsrite
db3040a50e fix 2023-10-28 11:02:34 +08:00
honue
8dd74e7dd8 fix 完善页面download传参username 2023-10-28 11:02:26 +08:00
jxxghp
206cdb2663 Merge pull request #988 from khalid586/main 2023-10-28 10:58:24 +08:00
jxxghp
ca334813b7 更新 __init__.py 2023-10-28 10:55:02 +08:00
jxxghp
5fc93ee8e6 Merge pull request #986 from WithdewHua/fix-mediaserver 2023-10-28 10:52:53 +08:00
Khalid Abdullah
9cef7b2615 Update __init__.py(typos fixed) 2023-10-27 23:56:37 +06:00
thsrite
a3916207ae fix division by zero 2023-10-27 23:13:56 +08:00
thsrite
b6e1702051 fix add plugins api 2023-10-27 21:28:37 +08:00
WithdewHua
2cfc8b1ec7 fix: 重连判断 2023-10-27 20:31:52 +08:00
jxxghp
2f7570eec1 Merge pull request #983 from thsrite/main 2023-10-27 17:06:36 +08:00
thsrite
070481cab0 fix 正在下载显示剩余下载时间 2023-10-27 16:52:47 +08:00
jxxghp
26cd2c6cfe Merge pull request #982 from honue/main 2023-10-27 16:25:34 +08:00
honue
1ff571eb46 fix 定时清理媒体库,增加username字段 2023-10-27 15:12:10 +08:00
jxxghp
d8fcb4d240 Merge pull request #980 from thsrite/main 2023-10-27 10:42:45 +08:00
thsrite
778f97c1f3 fix log友好提示 2023-10-27 10:41:31 +08:00
jxxghp
1d6d9aa96d v1.3.7 2023-10-26 17:14:43 +08:00
jxxghp
3bdd96a8ee fix #951 不缓存网络错误导致的TMDB信息None 2023-10-26 17:07:01 +08:00
jxxghp
935ad73d32 fix #955 2023-10-26 16:45:09 +08:00
jxxghp
a85d55f3a8 fix 2023-10-26 16:17:29 +08:00
jxxghp
d7c659b736 fix 2023-10-26 16:15:31 +08:00
jxxghp
e5cedab873 Merge pull request #975 from Shurelol/main 2023-10-26 16:03:22 +08:00
jxxghp
3653d73f4f Merge pull request #974 from thsrite/main 2023-10-26 16:02:21 +08:00
Shurelol
4af57ed861 feat: 增加转移覆盖模式配置 2023-10-26 15:55:09 +08:00
Shurelol
10445c6f56 feat: 增加转移覆盖模式配置 2023-10-26 15:51:17 +08:00
Shurelol
dc6051f0b0 feat: 增加转移覆盖模式配置 2023-10-26 15:49:33 +08:00
Shurelol
2a524eaf22 feat: 增加转移覆盖模式配置 2023-10-26 15:28:41 +08:00
Shurelol
9a810f440d feat: 增加转移覆盖模式配置 2023-10-26 15:24:39 +08:00
thsrite
27ba8db4ea fix images 2023-10-26 15:07:39 +08:00
thsrite
7130194d5f fix 2023-10-26 14:55:08 +08:00
thsrite
d70afc36c9 fix 2023-10-26 14:48:46 +08:00
thsrite
78017b8a0e fix del image 2023-10-26 14:40:57 +08:00
thsrite
e87fdc896c fix tmdbinfo images 2023-10-26 14:34:58 +08:00
thsrite
7bb6d448ed feat 云盘文件删除插件 2023-10-26 13:38:48 +08:00
jxxghp
6415fd9286 Merge pull request #966 from thsrite/main 2023-10-26 13:13:16 +08:00
thsrite
2dd4395698 fix #935 2023-10-26 11:28:07 +08:00
thsrite
68b6e67a93 fix #970 2023-10-26 11:07:11 +08:00
thsrite
71b35e39ab fix 18262f98 2023-10-26 09:10:59 +08:00
thsrite
9ff6015fec fix Cloudflare IP优选插件描述… 2023-10-25 16:53:51 +08:00
thsrite
124817b733 fix 自定义hosts插件描述… 2023-10-25 16:52:19 +08:00
thsrite
8f8f3af7cd fix 药丸签到定时任务描述 2023-10-25 16:37:02 +08:00
thsrite
882fe6cd00 fix 关于路径映射描述…… 2023-10-25 16:33:09 +08:00
thsrite
18262f98f7 fix 同步删除通知图片 2023-10-25 10:52:33 +08:00
jxxghp
fe5a90ac2f Merge pull request #964 from thsrite/main 2023-10-25 10:20:39 +08:00
thsrite
22869b7932 fix #962 2023-10-25 10:10:17 +08:00
jxxghp
e702c16a74 Merge pull request #959 from thsrite/main 2023-10-24 11:32:13 +08:00
thsrite
408690c0ae fix 4aaf5997 2023-10-24 11:22:47 +08:00
thsrite
4aaf5997df fix 登录页海报支持自定义tmdb/bing 2023-10-24 11:17:32 +08:00
jxxghp
f50104bc86 Merge pull request #953 from thsrite/main 2023-10-23 12:01:02 +08:00
thsrite
ee10fc18a7 fix #952 2023-10-23 09:12:53 +08:00
jxxghp
818ef63aec fix #948 2023-10-22 08:23:04 +08:00
jxxghp
4af374f86d Merge remote-tracking branch 'origin/main' 2023-10-22 08:20:10 +08:00
jxxghp
277b252ad8 fix #949 2023-10-22 08:20:00 +08:00
jxxghp
cc7671efd0 Merge pull request #950 from LWLLR/bugfix/rss-proxy 2023-10-22 08:04:10 +08:00
LWLLR
419276eb85 fix: RSS订阅插件下载没有启用代理问题 2023-10-22 06:42:59 +08:00
jxxghp
7d97b9142a Merge pull request #945 from DDS-Derek/main 2023-10-21 15:08:08 +08:00
DDSDerek
c3c041f675 fix: docker buildx cache 2023-10-21 14:40:45 +08:00
jxxghp
d790e6b731 Merge pull request #944 from DDS-Derek/main 2023-10-21 14:33:24 +08:00
DDSRem
8b714a4710 feat: emphasis mark 2023-10-21 14:30:42 +08:00
jxxghp
1f0d01d2ed Merge remote-tracking branch 'origin/main' 2023-10-21 14:23:18 +08:00
jxxghp
7727cd4f58 v1.3.6 2023-10-21 14:22:25 +08:00
jxxghp
bb6b3a57af Merge pull request #943 from DDS-Derek/main
fix: running environment is required
2023-10-21 14:20:37 +08:00
DDSRem
a70a4c272c fix: running environment is required 2023-10-21 14:16:31 +08:00
jxxghp
99bd4da54b Merge pull request #942 from DDS-Derek/main
feat: issue add operating environment
2023-10-21 14:16:06 +08:00
jxxghp
3e09a5e57f fix plugin caption 2023-10-21 14:15:35 +08:00
DDSRem
1375179138 feat: issue add operating environment 2023-10-21 14:15:10 +08:00
jxxghp
a8b1fbbef0 fix #884 2023-10-21 14:04:37 +08:00
jxxghp
d490fcf5af fix init paths 2023-10-21 13:52:06 +08:00
jxxghp
cdbe5b2e2f feat 加大缓存使用 && 订阅搜索休眠 2023-10-21 08:17:10 +08:00
jxxghp
15b1c756a7 fix plugin name 2023-10-20 16:58:18 +08:00
jxxghp
3dfad93977 Merge pull request #932 from thsrite/main 2023-10-20 16:41:20 +08:00
thsrite
3fcd83b0a7 fix 2023-10-20 16:39:22 +08:00
thsrite
03e48881a6 fix 6eb0b4cb 2023-10-20 16:36:31 +08:00
thsrite
6eb0b4cb5b fix 签到bug 2023-10-20 16:29:17 +08:00
jxxghp
556d8586a7 Merge pull request #929 from thsrite/main 2023-10-20 14:33:27 +08:00
thsrite
0ce6e51925 fix 同步删除msg 2023-10-20 14:04:48 +08:00
jxxghp
c2dec7b955 fix download 2023-10-20 13:49:06 +08:00
thsrite
b3733ed9ed feat 站点自动登录插件 2023-10-20 12:54:16 +08:00
jxxghp
be5106c819 fix #927 2023-10-20 12:54:06 +08:00
jxxghp
4875db08e8 fix #927 2023-10-20 12:48:43 +08:00
thsrite
f0593996a1 fix cf优选支持交互命令执行 2023-10-20 12:37:35 +08:00
thsrite
6e113cc9c6 fix 签到失败过多自动触发cf优选 2023-10-20 12:08:07 +08:00
jxxghp
7ffc5e6624 Merge pull request #925 from thsrite/main 2023-10-20 09:58:16 +08:00
thsrite
d1689300b9 fix get_local_version 2023-10-20 09:20:08 +08:00
jxxghp
5fc7a7dd8a - 修复v1.3.5版本的问题 2023-10-20 08:07:38 +08:00
jxxghp
f59609131c fix #921 2023-10-20 07:38:34 +08:00
jxxghp
efd1733b56 Merge pull request #922 from WithdewHua/fix-db 2023-10-20 06:53:04 +08:00
WithdewHua
31289d24e2 fix: 将 detached 对象重新关联到 Session 2023-10-20 02:40:13 +08:00
jxxghp
98be091ca6 Merge pull request #918 from thsrite/main 2023-10-19 20:41:05 +08:00
thsrite
4bfdf1dede fix add 提示 2023-10-19 20:21:40 +08:00
thsrite
c6a43a5dde fix auto restart 2023-10-19 20:20:09 +08:00
thsrite
3a20946f62 fix remove update 2023-10-19 20:11:19 +08:00
thsrite
d892400ca7 Merge remote-tracking branch 'origin/main' into main 2023-10-19 19:23:20 +08:00
thsrite
44b7199087 fix 更新系统 2023-10-19 19:23:05 +08:00
jxxghp
c3fe22a76f Merge pull request #917 from thsrite/main
fix 更新通知
2023-10-19 18:11:06 +08:00
thsrite
7d9a3d39b3 fix 更新通知 2023-10-19 18:09:49 +08:00
jxxghp
c932d2b7f0 fix bug 2023-10-19 18:08:56 +08:00
jxxghp
4739d43c45 v1.3.5 2023-10-19 17:55:40 +08:00
jxxghp
b33e777028 fix bug 2023-10-19 17:39:15 +08:00
jxxghp
e5718a50b2 fix bug 2023-10-19 17:15:46 +08:00
jxxghp
a911bab7b0 fix db session 2023-10-19 16:58:38 +08:00
jxxghp
21908bdc6f fix 优化豆瓣插件媒体识别 2023-10-19 08:13:13 +08:00
jxxghp
573a943467 更新 site.py 2023-10-18 21:34:28 +08:00
jxxghp
bcc29afa2b fix #885 2023-10-18 21:14:17 +08:00
jxxghp
ce693435df fix #900 2023-10-18 20:50:37 +08:00
jxxghp
dad5d76664 fix bug 2023-10-18 20:46:58 +08:00
jxxghp
897369d300 fix dblock 2023-10-18 19:42:46 +08:00
jxxghp
3d34c26731 fix 2023-10-18 19:26:21 +08:00
jxxghp
2e4536edb6 fix db session 2023-10-18 18:30:09 +08:00
jxxghp
68e16d18fe fix #909 2023-10-18 12:36:46 +08:00
jxxghp
0cd071813f fix #908 2023-10-18 12:25:51 +08:00
jxxghp
49f7aa30c8 fix #904 2023-10-18 12:24:16 +08:00
jxxghp
74caf8a482 fix 2023-10-18 08:38:30 +08:00
jxxghp
fb78a07662 fix db session 2023-10-18 08:35:16 +08:00
jxxghp
84f5ce8a0b db lock 2023-10-17 21:07:38 +08:00
jxxghp
3f5f689965 db lock 2023-10-17 20:57:09 +08:00
jxxghp
0591b59837 more log 2023-10-17 20:36:28 +08:00
jxxghp
4cc2551487 fix 消息翻页后数据减少问题 2023-10-17 19:59:31 +08:00
jxxghp
f15ccadc2d fix 优化连接参数 2023-10-17 19:39:19 +08:00
jxxghp
453ef94e4d feat 多线程处理事件 2023-10-17 19:04:42 +08:00
jxxghp
e57b6adba1 Merge pull request #896 from thsrite/main 2023-10-17 18:52:17 +08:00
thsrite
acf8c67681 fix bug 2023-10-17 18:38:23 +08:00
jxxghp
be4df15d01 fix #890 2023-10-17 17:24:25 +08:00
jxxghp
bd2ef934d9 Merge pull request #894 from thsrite/main 2023-10-17 17:04:04 +08:00
thsrite
b90622a88e fix 2023-10-17 16:54:00 +08:00
thsrite
6868712b4e fix 入库失败输入手动处理通知 2023-10-17 16:50:46 +08:00
thsrite
4099c5e1b5 fix 2023-10-17 16:43:29 +08:00
thsrite
e018f77e37 fix #895 2023-10-17 16:27:29 +08:00
thsrite
3aac617f35 feat MoviePilot更新推送插件 2023-10-17 16:16:17 +08:00
thsrite
8a46ebc4a0 fix add update交互命令 2023-10-17 15:44:26 +08:00
thsrite
26f63c4ea7 fix 2023-10-17 15:12:02 +08:00
thsrite
0e92e9fc60 fix 交互重启完发送消息,交互获取当前版本 2023-10-17 15:10:02 +08:00
jxxghp
0fe911b6b4 fix #889 2023-10-17 13:34:49 +08:00
jxxghp
592b9a89c9 fix dict 2023-10-17 08:17:32 +08:00
jxxghp
08e0df1abc fix bug 2023-10-16 18:55:05 +08:00
jxxghp
8f012eee50 Merge pull request #881 from thsrite/main
fix 目录监控消息间隔
2023-10-16 18:23:55 +08:00
thsrite
da766a400d fix 目录监控消息间隔 2023-10-16 17:38:08 +08:00
jxxghp
ec309180da v1.3.4 2023-10-16 17:36:01 +08:00
jxxghp
ab3b674a6e fix 目录监控通知间隔 2023-10-16 17:30:25 +08:00
jxxghp
9231144518 fix 2023-10-16 17:11:45 +08:00
jxxghp
13c04de87c fix 2023-10-16 17:10:22 +08:00
jxxghp
70f533684f fix #607 目录监控全量同步 2023-10-16 17:04:51 +08:00
jxxghp
c94866631b 更新 bug_report.yml 2023-10-16 12:33:51 +08:00
jxxghp
40a77b438e feat 下载中信息增加用户 2023-10-16 08:28:54 +08:00
jxxghp
f5de48ca30 fix #867 2023-10-16 07:16:47 +08:00
jxxghp
89a2c00e64 fix #867 2023-10-16 07:16:02 +08:00
jxxghp
35afb50b26 fix #867 2023-10-16 07:14:36 +08:00
jxxghp
0e3e01bf9c fix #864 2023-10-16 07:06:10 +08:00
jxxghp
6e3ebd73c6 Merge remote-tracking branch 'origin/main' 2023-10-16 07:04:59 +08:00
jxxghp
add9b875aa fix #863 2023-10-16 07:04:31 +08:00
jxxghp
b1790ee730 v1.3.3 2023-10-15 15:00:15 +08:00
jxxghp
47d7800250 fix #788 Rclone远程刮削 2023-10-15 14:56:04 +08:00
jxxghp
4849c281d3 fix #788 2023-10-15 14:37:20 +08:00
jxxghp
c36acd7bb4 fix #830 2023-10-15 14:19:36 +08:00
jxxghp
986e96a88e Merge pull request #847 from thsrite/main 2023-10-14 20:55:00 +08:00
thsrite
493b7c2d24 fix 重启系统发送消息 2023-10-14 20:49:03 +08:00
jxxghp
0539ddab85 fix bug 2023-10-14 20:27:04 +08:00
jxxghp
202fdf8905 fix bug 2023-10-14 20:16:15 +08:00
jxxghp
9191ed0a21 fix bug 2023-10-14 20:12:02 +08:00
jxxghp
9697cf3901 fix icon 2023-10-14 13:31:21 +08:00
jxxghp
e6a11294fd Merge pull request #842 from lightolly/dev/20231014
feat:增加自定义站点配置,仅为统计和签到使用
2023-10-14 13:25:36 +08:00
olly
cd046d8023 feat:增加自定义站点配置,仅为统计和签到使用 2023-10-14 13:22:06 +08:00
jxxghp
4d08928b8c Merge remote-tracking branch 'origin/main' 2023-10-14 13:05:13 +08:00
jxxghp
bc8a243a6d feat 整合历史记录Api 2023-10-14 13:05:00 +08:00
jxxghp
3b804e13a8 Merge pull request #839 from thsrite/main
fix 缺失消息发给交互人
2023-10-14 12:42:26 +08:00
thsrite
f126f927b4 fix 缺失消息发给交互人 2023-10-14 12:35:38 +08:00
jxxghp
d4f202c2b1 fix #838 2023-10-14 11:52:05 +08:00
jxxghp
77a1d56c5b Merge pull request #838 from thsrite/main
fix 演职人员刮削插件增加刮削条件、debug日志
2023-10-14 11:48:17 +08:00
jxxghp
7415f94da2 Merge pull request #834 from DDS-Derek/main
feat: pip add cache
2023-10-14 11:47:26 +08:00
thsrite
fa50d8b884 fix 演职人员刮削插件增加刮削条件、debug日志 2023-10-14 11:40:21 +08:00
DDSDerek
40776c10bc feat: pip add cache 2023-10-14 01:08:13 +08:00
jxxghp
6578a2f977 Merge pull request #832 from DDS-Derek/main
docs fix
2023-10-13 21:52:06 +08:00
DDSRem
e780485fc6 docs: fix 2023-10-13 21:46:55 +08:00
DDSRem
8213cdba63 docs: fix 2023-10-13 21:44:28 +08:00
DDSRem
8d5b0d4035 docs: fix 2023-10-13 21:43:19 +08:00
DDSRem
3eaa22d068 docs: fix 2023-10-13 21:42:27 +08:00
jxxghp
4797983f43 Merge pull request #831 from DDS-Derek/main
fix: rclone version is too low
2023-10-13 21:32:11 +08:00
jxxghp
0e7e2fc44b fix #829 默认过滤规则拆分 2023-10-13 21:31:13 +08:00
DDSRem
9a51286c54 docs: fix 2023-10-13 21:20:42 +08:00
DDSRem
ddbf93f2c5 docs: fix 2023-10-13 21:16:59 +08:00
DDSRem
418411b10d fix: connector error 2023-10-13 21:14:43 +08:00
DDSRem
dceb7340dd fix: rclone version is too low 2023-10-13 21:07:13 +08:00
jxxghp
e7e9ca539d fix #810 2023-10-13 15:32:11 +08:00
jxxghp
333d187615 Merge pull request #821 from thsrite/main
fix 从下载历史获取tmdbid入库刮削
2023-10-13 15:10:33 +08:00
thsrite
761e66b200 fix 从下载历史获取tmdbid入库刮削 2023-10-13 14:29:07 +08:00
thsrite
eec52fa5ba fix 下载用户精简下载进度消息 2023-10-13 13:56:23 +08:00
jxxghp
b6c3c03748 Merge pull request #819 from thsrite/main 2023-10-13 11:52:33 +08:00
thsrite
4eebaa5d75 fix 删种 2023-10-13 11:45:18 +08:00
jxxghp
f6dfe9cb88 fix rules 2023-10-13 11:41:12 +08:00
thsrite
c36c94971e fix 插件记录同步 2023-10-13 11:09:33 +08:00
jxxghp
e83a15ad1f fix plugin log 2023-10-13 11:01:58 +08:00
thsrite
16aa353cf6 Merge remote-tracking branch 'origin/main' 2023-10-13 10:26:49 +08:00
thsrite
5adfa89d10 fix bug 2023-10-13 10:26:40 +08:00
jxxghp
b1805c1a46 add logs 2023-10-13 07:18:12 +08:00
jxxghp
7e51d70cd6 - 修复了搜索页面过滤失效的问题 2023-10-12 22:46:42 +08:00
jxxghp
b5cba64227 fix 2023-10-12 21:30:35 +08:00
jxxghp
f20c81efae fix rclone 2023-10-12 20:47:23 +08:00
jxxghp
bfbd93b912 fix 2023-10-12 20:35:44 +08:00
jxxghp
6be074e647 fix doubaninfo 2023-10-12 20:31:59 +08:00
jxxghp
5f96a562d4 v1.3.2 2023-10-12 20:11:22 +08:00
jxxghp
cefbd70469 fix #807 2023-10-12 20:06:58 +08:00
jxxghp
30c9c66087 fix 豆瓣来源订阅 2023-10-12 19:59:47 +08:00
jxxghp
1ecbc2f0be fix bug 2023-10-12 18:08:09 +08:00
jxxghp
884a0feb62 fix bug 2023-10-12 17:56:15 +08:00
jxxghp
5f44f07515 fixme 2023-10-12 17:51:20 +08:00
jxxghp
a902b79684 fix #800 2023-10-12 17:07:26 +08:00
jxxghp
4e13f59b36 fix #804 2023-10-12 16:03:36 +08:00
jxxghp
cbccac87f0 feat 清理无效的插件事件响应 2023-10-12 12:48:40 +08:00
jxxghp
eb3c09a3d3 fix bug 2023-10-12 11:50:22 +08:00
jxxghp
2a9a36ac88 feat 辅助识别异步接口 && ChatGPT插件支持辅助名称识别 2023-10-12 11:41:57 +08:00
jxxghp
af2f52a050 fix 优先级规则支持杜比全景声 2023-10-12 09:46:48 +08:00
jxxghp
7a61fa1ee2 feat 订阅支持更多过滤规则 2023-10-12 08:58:59 +08:00
jxxghp
ac3009d58f fix bug 2023-10-12 08:40:56 +08:00
jxxghp
e835feb056 更新 douban.py 2023-10-11 18:11:13 +08:00
jxxghp
cd391d14f9 fix plex 2023-10-11 17:27:10 +08:00
jxxghp
d7844968ab v1.3.1 2023-10-11 17:20:11 +08:00
jxxghp
70ea398f14 fix 优化豆瓣匹配 2023-10-11 16:32:34 +08:00
jxxghp
860d55a0e2 feat 热门动漫 2023-10-11 16:13:15 +08:00
jxxghp
0e35cec6e2 fix #743 支持Rclone 2023-10-11 12:16:41 +08:00
jxxghp
5778e86260 fix #775 增加日志打印 2023-10-11 11:01:53 +08:00
jxxghp
967d0b1205 fix #778 2023-10-11 08:32:48 +08:00
jxxghp
0b2d419000 fix spec 2023-10-11 08:19:44 +08:00
jxxghp
149104063c fix #784 PROXY_HOST仅环境变量配置 2023-10-11 07:44:37 +08:00
jxxghp
498168a2d3 fix #783 2023-10-10 22:23:03 +08:00
jxxghp
88e307416d fix Dockerfile 2023-10-10 22:09:16 +08:00
jxxghp
3bb2eedb33 fix icon 2023-10-10 21:26:30 +08:00
jxxghp
36c046ad6a - 优化Windows打包 2023-10-10 20:25:00 +08:00
jxxghp
85396df221 - 优化Windows打包 2023-10-10 20:06:52 +08:00
jxxghp
2f0f58783e fix spec 2023-10-10 19:45:18 +08:00
jxxghp
2d989d4229 更新 main.py 2023-10-10 18:19:46 +08:00
jxxghp
ecc8b6b385 fix spec 2023-10-10 17:35:28 +08:00
jxxghp
aa90c5d5c0 fix build 2023-10-10 16:38:51 +08:00
jxxghp
5f7d93f170 fix startup 2023-10-10 16:23:57 +08:00
jxxghp
0fbe51f257 fix bug 2023-10-10 16:17:22 +08:00
jxxghp
be941ebdd1 fix #770 2023-10-10 16:14:12 +08:00
jxxghp
4d900c2eb0 fix #777 豆瓣流控改为随机休眠3-10秒 2023-10-10 16:02:14 +08:00
jxxghp
93c473afe7 - 优化Windows打包 2023-10-10 15:48:44 +08:00
jxxghp
4c9a66f586 fix trayicon 2023-10-10 14:44:27 +08:00
jxxghp
375e16e0dc fix trayicon 2023-10-10 14:38:50 +08:00
jxxghp
91085d13a3 Merge remote-tracking branch 'origin/main' 2023-10-10 13:58:02 +08:00
jxxghp
3f83894dc6 add trayicon 2023-10-10 13:57:56 +08:00
jxxghp
5946684ee6 Merge pull request #776 from thsrite/main
fix 更新log
2023-10-10 13:44:39 +08:00
thsrite
7e3f25879f fix 更新log 2023-10-10 13:41:39 +08:00
jxxghp
48dcc3ee1b - 优化Windows打包 2023-10-10 13:32:05 +08:00
jxxghp
fca0a4b511 Merge remote-tracking branch 'origin/main' 2023-10-10 13:27:11 +08:00
jxxghp
d6831a8881 - 优化Windows打包 2023-10-10 13:27:00 +08:00
jxxghp
39a646ed92 更新 README.md 2023-10-10 12:50:11 +08:00
jxxghp
595965c5d0 Merge pull request #774 from thsrite/main 2023-10-10 11:47:00 +08:00
thsrite
3bb6f8a0c0 Merge remote-tracking branch 'origin/main' 2023-10-10 11:15:45 +08:00
thsrite
1924a2017e fix #773 2023-10-10 11:15:26 +08:00
jxxghp
60140fd2e6 - 优化Windows打包 2023-10-10 09:50:37 +08:00
jxxghp
65b5219e45 fix TZ 2023-10-10 07:58:26 +08:00
jxxghp
ae2f649aee fix README.md 2023-10-09 21:44:39 +08:00
jxxghp
bf3e860a18 fix README.md 2023-10-09 21:34:08 +08:00
jxxghp
0b44a91493 fix README.md 2023-10-09 21:33:53 +08:00
jxxghp
16077b3341 fix #769 2023-10-09 21:13:36 +08:00
jxxghp
a7cedde721 fix build 2023-10-09 20:53:23 +08:00
jxxghp
ecd53192dc fix build 2023-10-09 20:53:11 +08:00
jxxghp
a03c76e211 更新 build-windows.yml 2023-10-09 20:35:18 +08:00
jxxghp
de427fd7a9 fix 2023-10-09 20:14:54 +08:00
213 changed files with 6694 additions and 24282 deletions

View File

@@ -9,8 +9,9 @@ body:
请确认以下信息:
1. 请按此模板提交issues不按模板提交的问题将直接关闭。
2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。
3. 提交问题务必描述清楚、附上日志,描述不清导致无法理解和分析的问题会被直接关闭。
3. **$\color{red}{提交问题务必描述清楚、附上日志}$**,描述不清导致无法理解和分析的问题会被直接关闭。
4. 此仓库为后端仓库,如果是前端 WebUI 问题请在[前端仓库](https://github.com/jxxghp/MoviePilot-Frontend)提 issue。
5. **$\color{red}{不要通过issues来寻求解决你的环境问题、配置安装类问题、咨询类问题}$**,否则直接关闭并加入用户 $\color{red}{黑名单}$ !实在没有精力陪一波又一波的伸手党玩。
- type: checkboxes
id: ensure
attributes:
@@ -32,6 +33,16 @@ body:
description: 遇到问题时程序所在的版本号
validations:
required: true
- type: dropdown
id: environment
attributes:
label: 运行环境
description: 当前程序运行环境
options:
- Docker
- Windows
validations:
required: true
- type: dropdown
id: type
attributes:
@@ -40,7 +51,6 @@ body:
options:
- 主程序运行问题
- 插件问题
- Docker或运行环境问题
- 其他问题
validations:
required: true

View File

@@ -14,6 +14,16 @@ body:
description: 目前使用的程序版本
validations:
required: true
- type: dropdown
id: environment
attributes:
label: 运行环境
description: 当前程序运行环境
options:
- Docker
- Windows
validations:
required: true
- type: dropdown
id: type
attributes:
@@ -22,7 +32,6 @@ body:
options:
- 主程序
- 插件
- Docker
- 其他
validations:
required: true

View File

@@ -1,65 +0,0 @@
name: MoviePilot Windows Builder
on:
workflow_dispatch:
push:
branches:
- main
paths:
- version.py
jobs:
Windows-build:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Release Version
id: release_version
run: |
$app_version = Select-String -Path "version.py" -Pattern "APP_VERSION\s=\s'v(.*)'" | ForEach-Object { $_.Matches.Groups[1].Value }
$env:GITHUB_ENV += "app_version=$app_version"
- name: Init Python 3.11.4
uses: actions/setup-python@v4
with:
python-version: '3.11.4'
- name: Install Dependent Packages
run: |
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
shell: pwsh
- name: Pyinstaller
run: |
pyinstaller windows.spec
shell: pwsh
- name: Upload Windows File
uses: actions/upload-artifact@v3
with:
name: windows
path: dist/MoviePilot.exe
- name: Generate Release
id: generate_release
uses: actions/create-release@latest
with:
tag_name: v${{ env.app_version }}
release_name: v${{ env.app_version }}
body: ${{ github.event.commits[0].message }}
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload Release Asset
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.generate_release.outputs.id }}
assets_path: |
dist/MoviePilot.exe

View File

@@ -1,4 +1,4 @@
name: MoviePilot Docker Builder
name: MoviePilot Builder
on:
workflow_dispatch:
push:
@@ -21,7 +21,7 @@ jobs:
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
- name: Docker meta
- name: Docker Meta
id: meta
uses: docker/metadata-action@v5
with:
@@ -55,5 +55,154 @@ jobs:
MOVIEPILOT_VERSION=${{ env.app_version }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha, scope=${{ github.workflow }}
cache-to: type=gha, scope=${{ github.workflow }}
cache-from: type=gha, scope=${{ github.workflow }}-docker
cache-to: type=gha, scope=${{ github.workflow }}-docker
Windows-build:
runs-on: windows-latest
name: Build Windows Binary
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Init Python 3.11.4
uses: actions/setup-python@v4
with:
python-version: '3.11.4'
cache: 'pip'
- name: Install Dependent Packages
run: |
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
shell: pwsh
- name: Prepare Frontend
run: |
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
Remove-Item -Path "nginx.zip"
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
Move-Item -Path "dist/dist/*" -Destination "nginx/html" -Force
Remove-Item -Path "dist.zip"
Remove-Item -Path "dist" -Recurse -Force
Move-Item -Path "nginx/html/nginx.conf" -Destination "nginx/conf/nginx.conf" -Force
New-Item -Path "nginx/temp" -ItemType Directory -Force
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
New-Item -Path "nginx/logs" -ItemType Directory -Force
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
Remove-Item -Path "MoviePilot-Resources-main.zip"
Remove-Item -Path "MoviePilot-Resources-main" -Recurse -Force
shell: pwsh
- name: Pyinstaller
run: |
pyinstaller frozen.spec
shell: pwsh
- name: Upload Windows File
uses: actions/upload-artifact@v3
with:
name: windows
path: dist/MoviePilot.exe
Linux-build-amd64:
runs-on: ubuntu-latest
name: Build Linux Amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Init Python 3.11.4
uses: actions/setup-python@v4
with:
python-version: '3.11.4'
cache: 'pip'
- name: Install Dependent Packages
run: |
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
- name: Prepare Frontend
run: |
wget https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip
unzip main.zip
mv MoviePilot-Plugins-main/plugins/* app/plugins/
rm main.zip
rm -rf MoviePilot-Plugins-main
wget https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip
unzip main.zip
mv MoviePilot-Resources-main/resources/* app/helper/
rm main.zip
rm -rf MoviePilot-Resources-main
- name: Pyinstaller
run: |
pyinstaller frozen.spec
mv dist/MoviePilot dist/MoviePilot_Amd64
- name: Upload Linux File
uses: actions/upload-artifact@v3
with:
name: linux-amd64
path: dist/MoviePilot_Amd64
Create-release:
permissions: write-all
runs-on: ubuntu-latest
needs: [ Windows-build, Docker-build, Linux-build-amd64]
steps:
- uses: actions/checkout@v2
- name: Release Version
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
- name: Download Artifact
uses: actions/download-artifact@v3
- name: get release_informations
shell: bash
run: |
mkdir releases
mv ./windows/MoviePilot.exe ./releases/MoviePilot_Win_v${{ env.app_version }}.exe
mv ./linux-amd64/MoviePilot_Amd64 ./releases/MoviePilot_Amd64_v${{ env.app_version }}
- name: Create Release
id: create_release
uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ env.app_version }}
release_name: v${{ env.app_version }}
body: ${{ github.event.commits[0].message }}
draft: false
prerelease: false
- name: Upload Release Asset
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}
assets_path: |
./releases/

8
.gitignore vendored
View File

@@ -1,8 +1,16 @@
.idea/
*.c
build/
dist/
nginx/
test.py
app/helper/sites.py
app/helper/*.so
app/helper/*.pyd
app/helper/*.bin
app/plugins/**
!app/plugins/__init__.py
config/user.db
config/sites/**
*.pyc
*.log

View File

@@ -1,16 +1,19 @@
FROM python:3.11.4-slim-bullseye
ARG MOVIEPILOT_VERSION
ENV LANG="C.UTF-8" \
TZ="Asia/Shanghai" \
HOME="/moviepilot" \
CONFIG_DIR="/config" \
TERM="xterm" \
PUID=0 \
PGID=0 \
UMASK=000 \
PORT=3001 \
NGINX_PORT=3000 \
MOVIEPILOT_AUTO_UPDATE=true \
MOVIEPILOT_AUTO_UPDATE_DEV=false \
CONFIG_DIR="/config"
PROXY_HOST="" \
MOVIEPILOT_AUTO_UPDATE=release \
AUTH_SITE="iyuu" \
IYUU_SIGN=""
WORKDIR "/app"
RUN apt-get update -y \
&& apt-get -y install \
@@ -27,12 +30,16 @@ RUN apt-get update -y \
dumb-init \
jq \
haproxy \
fuse3 \
rsync \
ffmpeg \
&& \
if [ "$(uname -m)" = "x86_64" ]; \
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
elif [ "$(uname -m)" = "aarch64" ]; \
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
fi \
&& curl https://rclone.org/install.sh | bash \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf \
@@ -70,7 +77,12 @@ RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
&& locale-gen zh_CN.UTF-8 \
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
&& mv /dist /public
&& mv /dist /public \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
&& mv -f /tmp/MoviePilot-Plugins-main/plugins/* /app/app/plugins/ \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
&& rm -rf /tmp/*
EXPOSE 3000
VOLUME [ "/config" ]
ENTRYPOINT [ "/entrypoint" ]

255
README.md
View File

@@ -4,17 +4,17 @@
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
Dockerhttps://hub.docker.com/r/jxxghp/moviepilot
发布频道https://t.me/moviepilot_channel
## 主要特性
- 前后端分离基于FastApi + Vue3前端项目地址[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
- 前后端分离基于FastApi + Vue3前端项目地址[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)APIhttp://localhost:3001/docs
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
- 重新设计了用户界面,更加美观易用。
## 安装
### 注意管理员用户不要使用弱密码如非必要不要暴露到公网。如被盗取管理账号权限将会导致站点Cookie等敏感数据泄露
### 1. **安装CookieCloud插件**
站点信息需要通过CookieCloud同步获取因此需要安装CookieCloud插件将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
@@ -33,64 +33,110 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
### 4. **安装MoviePilot**
目前仅提供docker镜像,点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
- Docker镜像
```shell
docker pull jxxghp/moviepilot:latest
```
点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
```shell
docker pull jxxghp/moviepilot:latest
```
- Windows
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases)双击运行后自动生成配置文件目录访问http://localhost:3000
- 群晖套件
添加套件源https://spk7.imnks.com/
- 本地运行
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
3) 执行命令:`pip install -r requirements.txt` 安装依赖
4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
## 配置
项目的所有配置均通过环境变量进行设置,支持两种配置方式:
- 在docker环境变量部分进行参数配置部分环境建立容器后会自动显示待配置项如未自动显示配置项则需要手动增加对应环境变量。
- 下载 [app.env](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env) 文件,修改好配置后放置到配置文件映射路径根目录,配置项可根据说明自主增减。
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**。
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,部分参数如路径映射、站点认证、权限端口等必须通过环境变量进行配置
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值
> $\color{red}{*}$ 号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
### 1. **环境变量**
### 1. **基础设置**
- **❗NGINX_PORT** WEB服务端口默认`3000`可自行修改不能与API服务端口冲突
- **❗PORT** API服务端口默认`3001`可自行修改不能与WEB服务端口冲突
- **PUID**:运行程序用户的`uid`,默认`0`
- **PGID**:运行程序用户的`gid`,默认`0`
- **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`,会依次执行认证操作,直到有一个站点认证成功。
- **NGINX_PORT $\color{red}{*}$ ** WEB服务端口默认`3000`可自行修改不能与API服务端口冲突仅支持环境变量配置
- **PORT $\color{red}{*}$ ** API服务端口默认`3001`可自行修改不能与WEB服务端口冲突仅支持环境变量配置
- **PUID**:运行程序用户的`uid`,默认`0`(仅支持环境变量配置)
- **PGID**:运行程序用户的`gid`,默认`0`(仅支持环境变量配置)
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`(仅支持环境变量配置)
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**(仅支持环境变量配置)
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`(仅支持环境变量配置)
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数,认证资源`v1.1.1`支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
| 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`:密钥 |
### 2. **app.env配置文件**
下载 [app.env 模板](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env)修改后放配置文件目录下app.env 的所有配置项也可以通过环境变量进行配置。
- **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面。**注意1、初始密码为自动生成需要在首次运行时的后台日志中查看成功登录后可以设定中修改2、启动一次后再次修改该值不会生效除非删除数据库文件**
- **❗API_TOKEN** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
- **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
- **GITHUB_TOKEN** Github token提高自动更新、插件安装等请求Github Api的限流阈值格式ghp_****
---
- **SUPERUSER $\color{red}{*}$ ** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
- **SUPERUSER_PASSWORD $\color{red}{*}$ ** 超级管理员初始密码,默认`password`,建议修改为复杂密码
- **API_TOKEN $\color{red}{*}$ ** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
- **PROXY_HOST** 网络代理访问themoviedb或者重启更新需要使用代理访问格式为`http(s)://ip:port``socks5://user:pass@host:port`(可选)
- **TMDB_API_DOMAIN** TMDB API地址默认`api.themoviedb.org`,也可配置为`api.tmdb.org`或其它中转代理服务地址,能连通即可
- **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`
- **SCRAP_FOLLOW_TMDB** 新增已入库媒体是否跟随TMDB信息变化`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
---
- **TRANSFER_TYPE $\color{red}{*}$ ** 整理转移方式,支持`link`/`copy`/`move`/`softlink` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响**
- **LIBRARY_PATH $\color{red}{*}$ ** 媒体库目录,多个目录使用`,`分隔
- **❗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 $\color{red}{*}$ ** CookieCloud服务器地址格式`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
- **COOKIECLOUD_KEY $\color{red}{*}$ ** CookieCloud用户KEY
- **COOKIECLOUD_PASSWORD $\color{red}{*}$ ** CookieCloud端对端加密密码
- **COOKIECLOUD_INTERVAL $\color{red}{*}$ ** CookieCloud同步间隔分钟
- **USER_AGENT $\color{red}{*}$ ** CookieCloud保存Cookie对应的浏览器UA建议配置设置后可增加连接站点的成功率同步站点后可以在管理界面中修改
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
- **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小时对所有订阅进行全量搜索以补齐缺失剧集一般情况下正常订阅即可订阅搜索只做为兜底会增加站点压力不建议开启
- **SEARCH_SOURCE** 媒体信息搜索来源,`themoviedb`/`douban`,默认`themoviedb`
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID消息通知渠道的用户ID多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
---
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID多个用户使用,分割,未设置需要选择资源或者回复`0`
- **MESSAGER $\color{red}{*}$ ** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
- **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`设置项:
@@ -119,17 +165,15 @@ docker pull jxxghp/moviepilot:latest
- **SYNOLOGYCHAT_WEBHOOK** 在Synology Chat中创建机器人获取机器人`传入URL`
- **SYNOLOGYCHAT_TOKEN** SynologyChat机器人`令牌`
---
- **DOWNLOAD_PATH $\color{red}{*}$ ** 下载保存目录,**注意:需要将`moviepilot``下载器`的映射路径保持一致**,否则会导致下载文件无法转移
- **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_MONITOR** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
- **TORRENT_TAG** 下载器种子标签,默认为`MOVIEPILOT`设置后只有MoviePilot添加的下载才会处理留空所有下载器中的任务均会处理
- **DOWNLOADER $\color{red}{*}$ ** 下载器,支持`qbittorrent`/`transmission`QB版本号要求>= 4.3.9TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
---
- **❗DOWNLOADER** 下载器,支持`qbittorrent`/`transmission`QB版本号要求>= 4.3.9TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
- `qbittorrent`设置项:
@@ -137,106 +181,85 @@ docker pull jxxghp/moviepilot:latest
- **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添加的下载才会处理留空所有下载器中的任务均会处理
---
- **REFRESH_MEDIASERVER** 入库后是否刷新媒体服务器,`true`/`false`,默认`true`
- **MEDIASERVER $\color{red}{*}$ ** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
- **❗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`支持的配置项:
### 2. **用户认证**
> `title` TMDB/豆瓣中的标题
> `original_title` TMDB/豆瓣中的原语种标题
> `name` 从文件名中识别的名称(同时存在中英文时,优先使用中文)
> `en_name`:从文件名中识别的英文名称(可能为空)
> `original_name` 原文件名(包括文件外缀)
> `year` 年份
> `resourceType`:资源类型
> `effect`:特效
> `edition` 版本(资源类型+特效)
> `videoFormat` 分辨率
> `releaseGroup` 制作组/字幕组
> `customization` 自定义占位符
> `videoCodec` 视频编码
> `audioCodec` 音频编码
> `tmdbid` TMDB ID非TMDB识别源时为空
> `imdbid` IMDB ID可能为空
> `doubanid`豆瓣ID非豆瓣识别源时为空
> `part`:段/节
> `fileExt`:文件扩展名
> `customization`:自定义占位符
`MOVIE_RENAME_FORMAT`默认配置格式:
```
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
```
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过docker环境变量配置**
- **AUTH_SITE $\color{red}{*}$ ** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
| 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`:密钥 |
| 1ptba | `1PTBA_UID`用户ID<br/>`1PTBA_PASSKEY`:密钥 |
| icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 |
| ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 |
| xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
### 2. **进阶配置**
- **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会占用更多的内存,但响应速度会更快
- **MOVIE_RENAME_FORMAT** 电影重命名格式
`MOVIE_RENAME_FORMAT`支持的配置项:
> `title` 标题
> `original_name` 原文件名
> `original_title` 原语种标题
> `name` 识别名称
> `year` 年份
> `resourceType`:资源类型
> `effect`:特效
> `edition` 版本(资源类型+特效)
> `videoFormat` 分辨率
> `releaseGroup` 制作组/字幕组
> `customization` 自定义占位符
> `videoCodec` 视频编码
> `audioCodec` 音频编码
> `tmdbid` TMDBID
> `imdbid` IMDBID
> `part`:段/节
> `fileExt`:文件扩展名
`MOVIE_RENAME_FORMAT`默认配置格式:
```
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
```
- **TV_RENAME_FORMAT** 电视剧重命名格式
`TV_RENAME_FORMAT`额外支持的配置项:
> `season` 季号
> `episode` 集号
> `season_episode` 季集 SxxExx
> `episode_title` 集标题
`TV_RENAME_FORMAT`默认配置格式:
```
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
```
- **TV_RENAME_FORMAT** 电视剧重命名格式基于jinjia2语法
`TV_RENAME_FORMAT`额外支持的配置项:
> `season` 季号
> `episode` 集号
> `season_episode` 季集 SxxExx
> `episode_title` 集标题
`TV_RENAME_FORMAT`默认配置格式:
```
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
```
### 3. **优先级规则**
@@ -244,6 +267,10 @@ docker pull jxxghp/moviepilot:latest
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
- 不符合过滤规则所有层级规则的资源将不会被选中
### 4. **插件扩展**
- **PLUGIN_MARKET** 插件市场仓库地址仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` ,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork或者查看频道置顶了解更多第三方插件仓库。
## 使用

View File

@@ -1,7 +1,7 @@
from fastapi import APIRouter
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer, mediaserver
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -21,3 +21,4 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
@@ -7,7 +6,7 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.core.config import settings
from app.core.security import verify_token
from app.core.security import verify_token, verify_uri_token
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.scheduler import Scheduler
@@ -17,12 +16,11 @@ router = APIRouter()
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询媒体数量统计信息
"""
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain(db).media_statistic()
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic()
if media_statistics:
# 汇总各媒体库统计信息
ret_statistic = schemas.Statistic()
@@ -36,6 +34,14 @@ def statistic(db: Session = Depends(get_db),
return schemas.Statistic()
@router.get("/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic)
def statistic2(_: str = Depends(verify_uri_token)) -> Any:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
return statistic()
@router.get("/storage", summary="存储空间", response_model=schemas.Storage)
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -48,6 +54,14 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
)
@router.get("/storage2", summary="存储空间API_TOKEN", response_model=schemas.Storage)
def storage2(_: str = Depends(verify_uri_token)) -> Any:
"""
查询存储空间信息 API_TOKEN认证?token=xxx
"""
return storage()
@router.get("/processes", summary="进程信息", response_model=List[schemas.ProcessInfo])
def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -57,13 +71,12 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询下载器信息
"""
transfer_info = DashboardChain(db).downloader_info()
free_space = SystemUtils.free_space(Path(settings.DOWNLOAD_PATH))
transfer_info = DashboardChain().downloader_info()
free_space = SystemUtils.free_space(settings.SAVE_PATH)
if transfer_info:
return schemas.DownloaderInfo(
download_speed=transfer_info.download_speed,
@@ -76,6 +89,14 @@ def downloader(db: Session = Depends(get_db),
return schemas.DownloaderInfo()
@router.get("/downloader2", summary="下载器信息API_TOKEN", response_model=schemas.DownloaderInfo)
def downloader2(_: str = Depends(verify_uri_token)) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
return downloader()
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -84,6 +105,14 @@ def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return Scheduler().list()
@router.get("/schedule2", summary="后台服务API_TOKEN", response_model=List[schemas.ScheduleInfo])
def schedule2(_: str = Depends(verify_uri_token)) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
return schedule()
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
def transfer(days: int = 7, db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@@ -102,9 +131,25 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return SystemUtils.cpu_usage()
@router.get("/cpu2", summary="获取当前CPU使用率API_TOKEN", response_model=int)
def cpu2(_: str = Depends(verify_uri_token)) -> Any:
"""
获取当前CPU使用率 API_TOKEN认证?token=xxx
"""
return cpu()
@router.get("/memory", summary="获取当前内存使用量和使用率", response_model=List[int])
def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取当前内存使用率
"""
return SystemUtils.memory_usage()
@router.get("/memory2", summary="获取当前内存使用量和使用率API_TOKEN", response_model=List[int])
def memory2(_: str = Depends(verify_uri_token)) -> Any:
"""
获取当前内存使用率 API_TOKEN认证?token=xxx
"""
return memory()

View File

@@ -1,14 +1,12 @@
from typing import List, Any
from fastapi import APIRouter, Depends, Response
from sqlalchemy.orm import Session
from app import schemas
from app.chain.douban import DoubanChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.db import get_db
from app.schemas import MediaType
from app.utils.http import RequestUtils
@@ -30,30 +28,14 @@ def douban_img(imgurl: str) -> Any:
return None
@router.get("/recognize/{doubanid}", summary="豆瓣ID识别", response_model=schemas.Context)
def recognize_doubanid(doubanid: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据豆瓣ID识别媒体信息
"""
# 识别媒体信息
context = DoubanChain(db).recognize_by_doubanid(doubanid=doubanid)
if context:
return context.to_dict()
else:
return schemas.Context()
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
def movie_showing(page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣正在热映
"""
movies = DoubanChain(db).movie_showing(page=page, count=count)
movies = DoubanChain().movie_showing(page=page, count=count)
if not movies:
return []
medias = [MediaInfo(douban_info=movie) for movie in movies]
@@ -65,13 +47,12 @@ def douban_movies(sort: str = "R",
tags: str = "",
page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣电影信息
"""
movies = DoubanChain(db).douban_discover(mtype=MediaType.MOVIE,
sort=sort, tags=tags, page=page, count=count)
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
sort=sort, tags=tags, page=page, count=count)
if not movies:
return []
medias = [MediaInfo(douban_info=movie) for movie in movies]
@@ -86,13 +67,12 @@ def douban_tvs(sort: str = "R",
tags: str = "",
page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
tvs = DoubanChain(db).douban_discover(mtype=MediaType.TV,
sort=sort, tags=tags, page=page, count=count)
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
sort=sort, tags=tags, page=page, count=count)
if not tvs:
return []
medias = [MediaInfo(douban_info=tv) for tv in tvs]
@@ -106,47 +86,117 @@ def douban_tvs(sort: str = "R",
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
def movie_top250(page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
movies = DoubanChain(db).movie_top250(page=page, count=count)
movies = DoubanChain().movie_top250(page=page, count=count)
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
def tv_weekly_chinese(page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
中国每周剧集口碑榜
"""
tvs = DoubanChain(db).tv_weekly_chinese(page=page, count=count)
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
def tv_weekly_global(page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
全球每周剧集口碑榜
"""
tvs = DoubanChain(db).tv_weekly_global(page=page, count=count)
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
def tv_animation(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门动画剧集
"""
tvs = DoubanChain().tv_animation(page=page, count=count)
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
def movie_hot(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门电影
"""
movies = DoubanChain().movie_hot(page=page, count=count)
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
def tv_hot(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门电视剧
"""
tvs = DoubanChain().tv_hot(page=page, count=count)
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.DoubanPerson])
def douban_credits(doubanid: str,
type_name: str,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询演员阵容type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
doubaninfos = DoubanChain().movie_credits(doubanid=doubanid, page=page)
elif mediatype == MediaType.TV:
doubaninfos = DoubanChain().tv_credits(doubanid=doubanid, page=page)
else:
return []
if not doubaninfos:
return []
else:
return [schemas.DoubanPerson(**doubaninfo) for doubaninfo in doubaninfos]
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
def douban_recommend(doubanid: str,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据豆瓣ID查询推荐电影/电视剧type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
doubaninfos = DoubanChain().movie_recommend(doubanid=doubanid)
elif mediatype == MediaType.TV:
doubaninfos = DoubanChain().tv_recommend(doubanid=doubanid)
else:
return []
if not doubaninfos:
return []
else:
return [MediaInfo(douban_info=doubaninfo).to_dict() for doubaninfo in doubaninfos]
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
def douban_info(doubanid: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据豆瓣ID查询豆瓣媒体信息
"""
doubaninfo = DoubanChain(db).douban_info(doubanid=doubanid)
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
if doubaninfo:
return MediaInfo(douban_info=doubaninfo).to_dict()
else:

View File

@@ -1,36 +1,32 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.douban import DoubanChain
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.core.context import MediaInfo, Context, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db import get_db
from app.schemas import NotExistMediaInfo, MediaType
from app.db.models.user import User
from app.db.userauth import get_current_active_user
router = APIRouter()
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
def read_downloading(
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询正在下载的任务
"""
return DownloadChain(db).downloading()
return DownloadChain().downloading()
@router.post("/", summary="添加下载", response_model=schemas.Response)
def add_downloading(
media_in: schemas.MediaInfo,
torrent_in: schemas.TorrentInfo,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
添加下载任务
@@ -49,78 +45,40 @@ def add_downloading(
media_info=mediainfo,
torrent_info=torrentinfo
)
did = DownloadChain(db).download_single(context=context)
did = DownloadChain().download_single(context=context, username=current_user.name)
return schemas.Response(success=True if did else False, data={
"download_id": did
})
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo])
def exists(media_in: schemas.MediaInfo,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询缺失媒体信息
"""
# 媒体信息
mediainfo = MediaInfo()
meta = MetaInfo(title=media_in.title)
if media_in.tmdb_id:
mediainfo.from_dict(media_in.dict())
elif media_in.douban_id:
context = DoubanChain(db).recognize_by_doubanid(doubanid=media_in.douban_id)
if context:
mediainfo = context.media_info
meta = context.meta_info
else:
context = MediaChain(db).recognize_by_title(title=f"{media_in.title} {media_in.year}")
if context:
mediainfo = context.media_info
meta = context.meta_info
# 查询缺失信息
if not mediainfo or not mediainfo.tmdb_id:
raise HTTPException(status_code=404, detail="媒体信息不存在")
exist_flag, no_exists = DownloadChain(db).get_no_exists_info(meta=meta, mediainfo=mediainfo)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
elif no_exists and no_exists.get(mediainfo.tmdb_id):
# 电视剧返回缺失的剧集
return list(no_exists.get(mediainfo.tmdb_id).values())
return []
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
def start_downloading(
hashString: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
开如下载任务
"""
ret = DownloadChain(db).set_downloading(hashString, "start")
ret = DownloadChain().set_downloading(hashString, "start")
return schemas.Response(success=True if ret else False)
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
def stop_downloading(
hashString: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
控制下载任务
暂停下载任务
"""
ret = DownloadChain(db).set_downloading(hashString, "stop")
ret = DownloadChain().set_downloading(hashString, "stop")
return schemas.Response(success=True if ret else False)
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
def remove_downloading(
hashString: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
控制下载任务
删除下载任务
"""
ret = DownloadChain(db).remove_downloading(hashString)
ret = DownloadChain().remove_downloading(hashString)
return schemas.Response(success=True if ret else False)

View File

@@ -11,7 +11,6 @@ from app.core.security import verify_token
from app.db import get_db
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
from app.schemas import MediaType
from app.schemas.types import EventType
router = APIRouter()
@@ -43,17 +42,26 @@ def delete_download_history(history_in: schemas.DownloadHistory,
def transfer_history(title: str = None,
page: int = 1,
count: int = 30,
status: bool = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询转移历史记录
"""
if title == "失败":
title = None
status = False
elif title == "成功":
title = None
status = True
if title:
total = TransferHistory.count_by_title(db, title)
result = TransferHistory.list_by_title(db, title, page, count)
total = TransferHistory.count_by_title(db, title=title, status=status)
result = TransferHistory.list_by_title(db, title=title, page=page,
count=count, status=status)
else:
result = TransferHistory.list_by_page(db, page, count)
total = TransferHistory.count(db)
result = TransferHistory.list_by_page(db, page=page, count=count, status=status)
total = TransferHistory.count(db, status=status)
return schemas.Response(success=True,
data={
@@ -76,10 +84,14 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
return schemas.Response(success=False, msg="记录不存在")
# 册除媒体库文件
if deletedest and history.dest:
TransferChain(db).delete_files(Path(history.dest))
state, msg = TransferChain().delete_files(Path(history.dest))
if not state:
return schemas.Response(success=False, msg=msg)
# 删除源文件
if deletesrc and history.src:
TransferChain(db).delete_files(Path(history.src))
state, msg = TransferChain().delete_files(Path(history.src))
if not state:
return schemas.Response(success=False, msg=msg)
# 发送事件
eventmanager.send_event(
EventType.DownloadFileDeleted,
@@ -90,23 +102,3 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
# 删除记录
TransferHistory.delete(db, history_in.id)
return schemas.Response(success=True)
@router.post("/transfer", summary="历史记录重新转移", response_model=schemas.Response)
def redo_transfer_history(history_in: schemas.TransferHistory,
mtype: str = None,
new_tmdbid: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
历史记录重新转移,不输入 mtype 和 new_tmdbid 时,自动使用文件名重新识别
"""
if mtype and new_tmdbid:
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
mtype=MediaType(mtype), tmdbid=new_tmdbid)
else:
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id)
if state:
return schemas.Response(success=True)
else:
return schemas.Response(success=False, message=errmsg)

View File

@@ -34,12 +34,13 @@ async def login_access_token(
)
if not user:
# 请求协助认证
logger.warn("登录用户本地不匹配,尝试辅助认证 ...")
token = UserChain(db).user_authenticate(form_data.username, form_data.password)
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"辅助认证成功,用户信息: {token}")
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
# 加入用户信息表
user = User.get_by_name(db=db, name=form_data.username)
if not user:
@@ -47,12 +48,18 @@ async def login_access_token(
user = User(name=form_data.username, is_active=True,
is_superuser=False, hashed_password=get_password_hash(token))
user.create(db)
else:
# 普通用户权限
user.is_superuser = False
elif not user.is_active:
raise HTTPException(status_code=403, detail="用户未启用")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
logger.info(f"用户 {user.name} 登录成功!")
return schemas.Token(
access_token=security.create_access_token(
user.id, expires_delta=access_token_expires
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
),
token_type="bearer",
super_user=user.is_superuser,
@@ -61,6 +68,18 @@ async def login_access_token(
)
@router.get("/wallpaper", summary="登录页面电影海报", response_model=schemas.Response)
def wallpaper() -> Any:
"""
获取登录页面电影海报
"""
if settings.WALLPAPER == "tmdb":
return tmdb_wallpaper()
elif settings.WALLPAPER == "bing":
return bing_wallpaper()
return schemas.Response(success=False)
@router.get("/bing", summary="Bing每日壁纸", response_model=schemas.Response)
def bing_wallpaper() -> Any:
"""
@@ -68,17 +87,19 @@ def bing_wallpaper() -> Any:
"""
url = WebUtils.get_bing_wallpaper()
if url:
return schemas.Response(success=False,
message=url)
return schemas.Response(
success=True,
message=url
)
return schemas.Response(success=False)
@router.get("/tmdb", summary="TMDB电影海报", response_model=schemas.Response)
def tmdb_wallpaper(db: Session = Depends(get_db)) -> Any:
def tmdb_wallpaper() -> Any:
"""
获取TMDB电影海报
"""
wallpager = TmdbChain(db).get_random_wallpager()
wallpager = TmdbChain().get_random_wallpager()
if wallpager:
return schemas.Response(
success=True,

View File

@@ -1,17 +1,13 @@
from typing import List, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.douban import DoubanChain
from app.chain.media import MediaChain
from app.chain.tmdb import TmdbChain
from app.core.context import MediaInfo
from app.core.config import settings
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.core.security import verify_token, verify_uri_token
from app.schemas import MediaType
router = APIRouter()
@@ -20,91 +16,96 @@ router = APIRouter()
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
def recognize(title: str,
subtitle: str = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据标题、副标题识别媒体信息
"""
# 识别媒体信息
context = MediaChain(db).recognize_by_title(title=title, subtitle=subtitle)
if context:
return context.to_dict()
metainfo = MetaInfo(title, subtitle)
mediainfo = MediaChain().recognize_by_meta(metainfo)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
return schemas.Context()
@router.get("/recognize2", summary="识别种子媒体信息API_TOKEN", response_model=schemas.Context)
def recognize2(title: str,
subtitle: str = None,
_: str = Depends(verify_uri_token)) -> Any:
"""
根据标题、副标题识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return recognize(title, subtitle)
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
def recognize(path: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def recognize_file(path: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据文件路径识别媒体信息
"""
# 识别媒体信息
context = MediaChain(db).recognize_by_path(path)
context = MediaChain().recognize_by_path(path)
if context:
return context.to_dict()
return schemas.Context()
@router.get("/recognize_file2", summary="识别文件媒体信息API_TOKEN", response_model=schemas.Context)
def recognize_file2(path: str,
_: str = Depends(verify_uri_token)) -> Any:
"""
根据文件路径识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return recognize_file(path)
@router.get("/search", summary="搜索媒体信息", response_model=List[schemas.MediaInfo])
def search_by_title(title: str,
page: int = 1,
count: int = 8,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
模糊搜索媒体信息列表
"""
_, medias = MediaChain(db).search(title=title)
_, medias = MediaChain().search(title=title)
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return []
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
def exists(title: str = None,
year: int = None,
mtype: str = None,
tmdbid: int = None,
season: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
判断本地是否存在
"""
meta = MetaInfo(title)
if not season:
season = meta.begin_season
exist = MediaServerOper(db).exists(
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
)
return schemas.Response(success=True if exist else False, data={
"item": exist or {}
})
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
def tmdb_info(mediaid: str, type_name: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def media_info(mediaid: str, type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据媒体ID查询themoviedb或豆瓣媒体信息type_name: 电影/电视剧
"""
mtype = MediaType(type_name)
tmdbid, doubanid = None, None
if mediaid.startswith("tmdb:"):
result = TmdbChain(db).tmdb_info(int(mediaid[5:]), mtype)
return MediaInfo(tmdb_info=result).to_dict()
tmdbid = int(mediaid[5:])
elif mediaid.startswith("douban:"):
# 查询豆瓣信息
doubaninfo = DoubanChain(db).douban_info(doubanid=mediaid[7:])
if not doubaninfo:
return schemas.MediaInfo()
result = DoubanChain(db).recognize_by_doubaninfo(doubaninfo)
if result:
# TMDB
return result.media_info.to_dict()
else:
# 豆瓣
return MediaInfo(douban_info=doubaninfo).to_dict()
else:
doubanid = mediaid[7:]
if not tmdbid and not doubanid:
return schemas.MediaInfo()
if settings.RECOGNIZE_SOURCE == "themoviedb":
if not tmdbid and doubanid:
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
tmdbid = tmdbinfo.get("id")
else:
return schemas.MediaInfo()
else:
if not doubanid and tmdbid:
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
if doubaninfo:
doubanid = doubaninfo.get("id")
else:
return schemas.MediaInfo()
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if mediainfo:
MediaChain().obtain_images(mediainfo)
return mediainfo.to_dict()
return schemas.MediaInfo()

View File

@@ -0,0 +1,146 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import schemas
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain
from app.core.config import settings
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.db.models import MediaServerItem
from app.schemas import MediaType, NotExistMediaInfo
router = APIRouter()
@router.get("/play/{itemid}", summary="在线播放")
def play_item(itemid: str) -> schemas.Response:
"""
获取媒体服务器播放页面地址
"""
if not itemid:
return schemas.Response(success=False, msg="参数错误")
if not settings.MEDIASERVER:
return schemas.Response(success=False, msg="未配置媒体服务器")
mediaserver = settings.MEDIASERVER.split(",")[0]
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
# 重定向到play_url
if not play_url:
return schemas.Response(success=False, msg="未找到播放地址")
return schemas.Response(success=True, data={
"url": play_url
})
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
def exists(title: str = None,
year: int = None,
mtype: str = None,
tmdbid: int = None,
season: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
判断本地是否存在
"""
meta = MetaInfo(title)
if not season:
season = meta.begin_season
# 返回对象
ret_info = {}
# 本地数据库是否存在
exist: MediaServerItem = MediaServerOper(db).exists(
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
)
if exist:
ret_info = {
"id": exist.item_id
}
"""
else:
# 服务器是否存在
mediainfo = MediaInfo()
mediainfo.from_dict({
"title": meta.name,
"year": year or meta.year,
"type": mtype or meta.type,
"tmdb_id": tmdbid,
"season": season
})
exist: schemas.ExistMediaInfo = MediaServerChain().media_exists(
mediainfo=mediainfo
)
if exist:
ret_info = {
"id": exist.itemid
}
"""
return schemas.Response(success=True if exist else False, data={
"item": ret_info
})
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[schemas.NotExistMediaInfo])
def not_exists(media_in: schemas.MediaInfo,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询缺失媒体信息
"""
# 媒体信息
meta = MetaInfo(title=media_in.title)
mtype = MediaType(media_in.type) if media_in.type else None
if mtype:
meta.type = mtype
if media_in.season:
meta.begin_season = media_in.season
meta.type = MediaType.TV
if media_in.year:
meta.year = media_in.year
if media_in.tmdb_id or media_in.douban_id:
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
else:
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
# 查询缺失信息
if not mediainfo:
raise HTTPException(status_code=404, detail="媒体信息不存在")
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
elif no_exists and no_exists.get(mediakey):
# 电视剧返回缺失的剧集
return list(no_exists.get(mediakey).values())
return []
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
def latest(count: int = 18,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器最新入库条目
"""
return MediaServerChain().latest(count=count, username=userinfo.username) or []
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
def playing(count: int = 12,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器正在播放条目
"""
return MediaServerChain().playing(count=count, username=userinfo.username) or []
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
def library(userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器媒体库列表
"""
return MediaServerChain().librarys(username=userinfo.username) or []

View File

@@ -2,14 +2,12 @@ from typing import Union, Any, List
from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import Request
from sqlalchemy.orm import Session
from starlette.responses import PlainTextResponse
from app import schemas
from app.chain.message import MessageChain
from app.core.config import settings
from app.core.security import verify_token
from app.db import get_db
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
@@ -19,23 +17,22 @@ from app.schemas.types import SystemConfigKey, NotificationType
router = APIRouter()
def start_message_chain(db: Session, body: Any, form: Any, args: Any):
def start_message_chain(body: Any, form: Any, args: Any):
"""
启动链式任务
"""
MessageChain(db).process(body=body, form=form, args=args)
MessageChain().process(body=body, form=form, args=args)
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
async def user_message(background_tasks: BackgroundTasks, request: Request,
db: Session = Depends(get_db)):
async def user_message(background_tasks: BackgroundTasks, request: Request):
"""
用户消息响应
"""
body = await request.body()
form = await request.form()
args = request.query_params
background_tasks.add_task(start_message_chain, db, body, form, args)
background_tasks.add_task(start_message_chain, body, form, args)
return schemas.Response(success=True)
@@ -51,7 +48,7 @@ def wechat_verify(echostr: str, msg_signature: str,
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
sReceiveId=settings.WECHAT_CORPID)
except Exception as err:
logger.error(f"微信请求验证失败: {err}")
logger.error(f"微信请求验证失败: {str(err)}")
return str(err)
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
sTimeStamp=timestamp,

View File

@@ -6,6 +6,7 @@ from app import schemas
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.schemas.types import SystemConfigKey
router = APIRouter()
@@ -14,9 +15,33 @@ router = APIRouter()
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询所有插件清单
查询所有插件清单,包括本地插件和在线插件
"""
return PluginManager().get_plugin_apps()
plugins = []
# 本地插件
local_plugins = PluginManager().get_local_plugins()
# 在线插件
online_plugins = PluginManager().get_online_plugins()
if not online_plugins:
return local_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")])
# 未安装的线上插件或者有更新的插件
for plugin in online_plugins:
if plugin["id"] not in installed_ids:
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
@router.get("/installed", summary="已安装插件", response_model=List[str])
@@ -29,19 +54,28 @@ 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:
"""
安装插件
"""
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
# 如果是非本地括件,或者强制安装时,则需要下载安装
if repo_url and (force or plugin_id not in PluginManager().get_plugin_ids()):
# 下载安装
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
if not state:
# 安装失败
return schemas.Response(success=False, message=msg)
# 安装插件
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
# 保存设置
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 重载插件管理器
PluginManager().init_config()
# 重载插件管理器
PluginManager().init_config()
return schemas.Response(success=True)
@@ -61,11 +95,23 @@ def plugin_form(plugin_id: str,
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
"""
根据插件ID获取插件配置信息
根据插件ID获取插件数据页面
"""
return PluginManager().get_plugin_page(plugin_id)
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
"""
根据插件ID重置插件配置
"""
# 删除配置
PluginManager().delete_plugin_config(plugin_id)
# 重新生效插件
PluginManager().reload_plugin(plugin_id, {})
return schemas.Response(success=True)
@router.get("/{plugin_id}", summary="获取插件配置")
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
"""
@@ -78,7 +124,7 @@ def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token
def set_plugin_config(plugin_id: str, conf: dict,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据插件ID获取插件配置信息
更新插件配置
"""
# 保存配置
PluginManager().save_plugin_config(plugin_id, conf)

View File

@@ -1,51 +1,57 @@
from typing import List, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.douban import DoubanChain
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.core.config import settings
from app.core.security import verify_token
from app.db import get_db
from app.schemas.types import MediaType
router = APIRouter()
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
async def search_latest(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询搜索结果
"""
torrents = SearchChain(db).last_search_results()
torrents = SearchChain().last_search_results()
return [torrent.to_dict() for torrent in torrents]
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=List[schemas.Context])
def search_by_tmdbid(mediaid: str,
mtype: str = None,
area: str = "title",
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search_by_id(mediaid: str,
mtype: str = None,
area: str = "title",
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/
"""
torrents = []
if mtype:
mtype = MediaType(mtype)
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if mtype:
mtype = MediaType(mtype)
torrents = SearchChain(db).search_by_tmdbid(tmdbid=tmdbid, mtype=mtype, area=area)
if settings.RECOGNIZE_SOURCE == "douban":
# 通过TMDBID识别豆瓣ID
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
if doubaninfo:
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
mtype=mtype, area=area)
else:
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area)
elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
# 识别豆瓣信息
context = DoubanChain(db).recognize_by_doubanid(doubanid)
if not context or not context.media_info or not context.media_info.tmdb_id:
return []
torrents = SearchChain(db).search_by_tmdbid(tmdbid=context.media_info.tmdb_id,
mtype=context.media_info.type,
area=area)
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过豆瓣ID识别TMDBID
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=mtype, area=area)
else:
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area)
else:
return []
return [torrent.to_dict() for torrent in torrents]
@@ -55,10 +61,9 @@ def search_by_tmdbid(mediaid: str,
async def search_by_title(keyword: str = None,
page: int = 0,
site: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
torrents = SearchChain(db).search_by_title(title=keyword, page=page, site=site)
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
return [torrent.to_dict() for torrent in torrents]

View File

@@ -45,7 +45,7 @@ def add_site(
domain = StringUtils.get_url_domain(site_in.url)
site_info = SitesHelper().get_indexer(domain)
if not site_info:
return schemas.Response(success=False, message="该站点不支持")
return schemas.Response(success=False, message="该站点不支持或用户未通过认证")
if Site.get_by_domain(db, domain):
return schemas.Response(success=False, message=f"{domain} 站点己存在")
# 保存站点信息
@@ -139,9 +139,9 @@ def update_cookie(
detail=f"站点 {site_id} 不存在!",
)
# 更新Cookie
state, message = SiteChain(db).update_cookie(site_info=site_info,
username=username,
password=password)
state, message = SiteChain().update_cookie(site_info=site_info,
username=username,
password=password)
return schemas.Response(success=state, message=message)
@@ -158,7 +158,7 @@ def test_site(site_id: int,
status_code=404,
detail=f"站点 {site_id} 不存在",
)
status, message = SiteChain(db).test(site.domain)
status, message = SiteChain().test(site.domain)
return schemas.Response(success=status, message=message)
@@ -228,10 +228,11 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
"""
# 选中的rss站点
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
# 所有站点
all_site = Site.list_order_by_pri(db)
if not selected_sites or not all_site:
return []
if not selected_sites:
return all_site
# 选中的rss站点
rss_sites = [site for site in all_site if site and site.id in selected_sites]

View File

@@ -7,7 +7,8 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.security import verify_token
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_uri_token
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.db.models.user import User
@@ -18,16 +19,16 @@ from app.schemas.types import MediaType
router = APIRouter()
def start_subscribe_add(db: Session, title: str, year: str,
def start_subscribe_add(title: str, year: str,
mtype: MediaType, tmdbid: int, season: int, username: str):
"""
启动订阅任务
"""
SubscribeChain(db).add(title=title, year=year,
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
SubscribeChain().add(title=title, year=year,
mtype=mtype, tmdbid=tmdbid, season=season, username=username)
@router.get("/", summary="所有订阅", response_model=List[schemas.Subscribe])
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
def read_subscribes(
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@@ -41,11 +42,18 @@ def read_subscribes(
return subscribes
@router.get("/list", summary="查询所有订阅API_TOKEN", response_model=List[schemas.Subscribe])
def list_subscribes(_: str = Depends(verify_uri_token)) -> Any:
"""
查询所有订阅 API_TOKEN认证?token=xxx
"""
return read_subscribes()
@router.post("/", summary="新增订阅", response_model=schemas.Response)
def create_subscribe(
*,
subscribe_in: schemas.Subscribe,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
@@ -56,20 +64,26 @@ def create_subscribe(
mtype = MediaType(subscribe_in.type)
else:
mtype = None
# 豆瓣标理
if subscribe_in.doubanid:
meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name
subscribe_in.season = meta.begin_season
# 标题转换
if subscribe_in.name:
title = subscribe_in.name
else:
title = None
sid, message = SubscribeChain(db).add(mtype=mtype,
title=title,
year=subscribe_in.year,
tmdbid=subscribe_in.tmdbid,
season=subscribe_in.season,
doubanid=subscribe_in.doubanid,
username=current_user.name,
best_version=subscribe_in.best_version,
exist_ok=True)
sid, message = SubscribeChain().add(mtype=mtype,
title=title,
year=subscribe_in.year,
tmdbid=subscribe_in.tmdbid,
season=subscribe_in.season,
doubanid=subscribe_in.doubanid,
username=current_user.name,
best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path,
exist_ok=True)
return schemas.Response(success=True if sid else False, message=message, data={
"id": sid
})
@@ -109,23 +123,30 @@ def update_subscribe(
def subscribe_mediaid(
mediaid: str,
season: int = None,
title: str = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID或豆瓣ID查询订阅 tmdb:/douban:
"""
result = None
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return Subscribe()
result = Subscribe.exists(db, int(tmdbid), season)
result = Subscribe.exists(db, tmdbid=int(tmdbid), season=season)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return Subscribe()
result = Subscribe.get_by_doubanid(db, doubanid)
else:
result = None
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)
@@ -195,8 +216,10 @@ def read_subscribe(
"""
根据订阅编号查询订阅信息
"""
if not subscribe_id:
return Subscribe()
subscribe = Subscribe.get(db, subscribe_id)
if subscribe.sites:
if subscribe and subscribe.sites:
subscribe.sites = json.loads(subscribe.sites)
return subscribe
@@ -240,10 +263,9 @@ def delete_subscribe(
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
authorization: str = Header(None)) -> Any:
"""
Jellyseerr/Overseerr订阅
Jellyseerr/Overseerr网络勾子通知订阅
"""
if not authorization or authorization != settings.API_TOKEN:
raise HTTPException(
@@ -268,7 +290,6 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
# 添加订阅
if media_type == MediaType.MOVIE:
background_tasks.add_task(start_subscribe_add,
db=db,
mtype=media_type,
tmdbid=tmdbId,
title=subject,
@@ -283,7 +304,6 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
break
for season in seasons:
background_tasks.add_task(start_subscribe_add,
db=db,
mtype=media_type,
tmdbid=tmdbId,
title=subject,

View File

@@ -1,21 +1,20 @@
import json
import time
from datetime import datetime
from typing import Union
from typing import Union, Any
import tailer
from fastapi import APIRouter, HTTPException, Depends
from fastapi import APIRouter, HTTPException, Depends, Response
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app import schemas
from app.chain.search import SearchChain
from app.core.config import settings
from app.core.security import verify_token
from app.db import get_db
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils
@@ -25,6 +24,22 @@ from version import APP_VERSION
router = APIRouter()
@router.get("/img/{imgurl:path}/{proxy}", summary="图片代理")
def get_img(imgurl: str, proxy: bool = False) -> Any:
"""
通过图片代理(使用代理服务器)
"""
if not imgurl:
return None
if proxy:
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
else:
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
if response:
return Response(content=response.content, media_type="image/jpeg")
return None
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
"""
@@ -34,7 +49,9 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
)
info.update({
"VERSION": APP_VERSION
"VERSION": APP_VERSION,
"AUTH_VERSION": SitesHelper().auth_version,
"INDEXER_VERSION": SitesHelper().indexer_version,
})
return schemas.Response(success=True,
data=info)
@@ -106,9 +123,11 @@ def get_message(token: str):
@router.get("/logging", summary="实时日志")
def get_logging(token: str):
def get_logging(token: str, length: int = 50):
"""
实时获取系统日志返回格式为SSE
实时获取系统日志
length = -1 时, 返回text/plain
否则 返回格式SSE
"""
if not token or not verify_token(token):
raise HTTPException(
@@ -116,18 +135,26 @@ def get_logging(token: str):
detail="认证失败!",
)
log_path = settings.LOG_PATH / 'moviepilot.log'
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 '')
time.sleep(1)
return StreamingResponse(log_generator(), media_type="text/event-stream")
# 根据length参数返回不同的响应
if length == -1:
# 返回全部日志作为文本响应
with open(log_path, 'r', encoding='utf-8') as f:
text = f.read()
return Response(content=text, media_type="text/plain")
else:
# 返回SSE流响应
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/nettest", summary="测试网络连通性")
@@ -162,7 +189,8 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询Github所有Release版本
"""
version_res = RequestUtils().get_res(f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
if version_res:
ver_json = version_res.json()
if ver_json:
@@ -174,7 +202,6 @@ def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
def ruletest(title: str,
subtitle: str = None,
ruletype: str = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)):
"""
过滤规则测试,规则类型 1-订阅2-洗版3-搜索
@@ -193,8 +220,8 @@ def ruletest(title: str,
return schemas.Response(success=False, message="优先级规则未设置!")
# 过滤
result = SearchChain(db).filter_torrents(rule_string=rule_string,
torrent_list=[torrent])
result = SearchChain().filter_torrents(rule_string=rule_string,
torrent_list=[torrent])
if not result:
return schemas.Response(success=False, message="不符合优先级规则!")
return schemas.Response(success=True, data={
@@ -222,8 +249,5 @@ def execute_command(jobid: str,
"""
if not jobid:
return schemas.Response(success=False, message="命令不能为空!")
if jobid == "subscribe_search":
Scheduler().start(jobid, state = 'R')
else:
Scheduler().start(jobid)
return schemas.Response(success=True)
Scheduler().start(jobid)
return schemas.Response(success=True)

View File

@@ -1,25 +1,22 @@
from typing import List, Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.tmdb import TmdbChain
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.db import get_db
from app.schemas.types import MediaType
router = APIRouter()
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询themoviedb所有季信息
"""
seasons_info = TmdbChain(db).tmdb_seasons(tmdbid=tmdbid)
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
if not seasons_info:
return []
else:
@@ -29,16 +26,15 @@ def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
def tmdb_similar(tmdbid: int,
type_name: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询类似电影/电视剧type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
tmdbinfos = TmdbChain(db).movie_similar(tmdbid=tmdbid)
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
elif mediatype == MediaType.TV:
tmdbinfos = TmdbChain(db).tv_similar(tmdbid=tmdbid)
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
else:
return []
if not tmdbinfos:
@@ -50,16 +46,15 @@ def tmdb_similar(tmdbid: int,
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
def tmdb_recommend(tmdbid: int,
type_name: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询推荐电影/电视剧type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
tmdbinfos = TmdbChain(db).movie_recommend(tmdbid=tmdbid)
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
elif mediatype == MediaType.TV:
tmdbinfos = TmdbChain(db).tv_recommend(tmdbid=tmdbid)
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
else:
return []
if not tmdbinfos:
@@ -72,16 +67,15 @@ def tmdb_recommend(tmdbid: int,
def tmdb_credits(tmdbid: int,
type_name: str,
page: int = 1,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询演员阵容type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
tmdbinfos = TmdbChain(db).movie_credits(tmdbid=tmdbid, page=page)
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
elif mediatype == MediaType.TV:
tmdbinfos = TmdbChain(db).tv_credits(tmdbid=tmdbid, page=page)
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
else:
return []
if not tmdbinfos:
@@ -92,12 +86,11 @@ def tmdb_credits(tmdbid: int,
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
def tmdb_person(person_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
tmdbinfo = TmdbChain(db).person_detail(person_id=person_id)
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
if not tmdbinfo:
return schemas.TmdbPerson()
else:
@@ -107,12 +100,11 @@ def tmdb_person(person_id: int,
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
def tmdb_person_credits(person_id: int,
page: int = 1,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物参演作品
"""
tmdbinfo = TmdbChain(db).person_credits(person_id=person_id, page=page)
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
if not tmdbinfo:
return []
else:
@@ -124,16 +116,15 @@ def tmdb_movies(sort_by: str = "popularity.desc",
with_genres: str = "",
with_original_language: str = "",
page: int = 1,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB电影信息
"""
movies = TmdbChain(db).tmdb_discover(mtype=MediaType.MOVIE,
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
if not movies:
return []
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
@@ -144,16 +135,15 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
with_genres: str = "",
with_original_language: str = "",
page: int = 1,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB剧集信息
"""
tvs = TmdbChain(db).tmdb_discover(mtype=MediaType.TV,
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
if not tvs:
return []
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
@@ -161,12 +151,11 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
def tmdb_trending(page: int = 1,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB剧集信息
"""
infos = TmdbChain(db).tmdb_trending(page=page)
infos = TmdbChain().tmdb_trending(page=page)
if not infos:
return []
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
@@ -174,12 +163,11 @@ def tmdb_trending(page: int = 1,
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
def tmdb_season_episodes(tmdbid: int, season: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询某季的所有信信息
"""
episodes_info = TmdbChain(db).tmdb_episodes(tmdbid=tmdbid, season=season)
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
if not episodes_info:
return []
else:

View File

@@ -8,13 +8,15 @@ from app import schemas
from app.chain.transfer import TransferChain
from app.core.security import verify_token
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.schemas import MediaType
router = APIRouter()
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
def manual_transfer(path: str,
def manual_transfer(path: str = None,
logid: int = None,
target: str = None,
tmdbid: int = None,
type_name: str = None,
@@ -28,13 +30,14 @@ def manual_transfer(path: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
手动转移,支持自定义剧集识别格式
手动转移,文件或历史记录,支持自定义剧集识别格式
:param path: 转移路径或文件
:param logid: 转移历史记录ID
:param target: 目标路径
: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: 剧集识别分集信息
@@ -43,11 +46,35 @@ def manual_transfer(path: str,
:param db: 数据库
:param _: Token校验
"""
in_path = Path(path)
if target:
target = Path(target)
if not target.exists():
return schemas.Response(success=False, message=f"目标路径不存在")
force = False
target = Path(target) if target else None
transfer = TransferChain()
if logid:
# 查询历史记录
history: TransferHistory = TransferHistory.get(db, logid)
if not history:
return schemas.Response(success=False, message=f"历史记录不存在ID{logid}")
# 强制转移
force = True
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:
return schemas.Response(success=False, message=f"缺少参数path/logid")
# 类型
mtype = MediaType(type_name) if type_name else None
# 自定义格式
@@ -60,7 +87,7 @@ def manual_transfer(path: str,
offset=episode_offset,
)
# 开始转移
state, errormsg = TransferChain(db).manual_transfer(
state, errormsg = transfer.manual_transfer(
in_path=in_path,
target=target,
tmdbid=tmdbid,
@@ -68,7 +95,8 @@ def manual_transfer(path: str,
season=season,
transfer_type=transfer_type,
epformat=epformat,
min_filesize=min_filesize
min_filesize=min_filesize,
force=force
)
# 失败
if not state:

View File

@@ -1,4 +1,5 @@
import base64
import re
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
@@ -59,6 +60,10 @@ def update_user(
"""
user_info = user_in.dict()
if user_info.get("password"):
# 正则表达式匹配密码包含字母、数字、特殊字符中的至少两项
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="密码需要同时包含字母、数字、特殊字符中的至少两项且长度大于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

@@ -1,48 +1,43 @@
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Request, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.webhook import WebhookChain
from app.core.config import settings
from app.db import get_db
from app.core.security import verify_uri_token
router = APIRouter()
def start_webhook_chain(db: Session, body: Any, form: Any, args: Any):
def start_webhook_chain(body: Any, form: Any, args: Any):
"""
启动链式任务
"""
WebhookChain(db).message(body=body, form=form, args=args)
WebhookChain().message(body=body, form=form, args=args)
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
token: str, request: Request,
db: Session = Depends(get_db),) -> Any:
request: Request,
_: str = Depends(verify_uri_token)
) -> Any:
"""
Webhook响应
"""
if token != settings.API_TOKEN:
return schemas.Response(success=False, message="token认证不通过")
body = await request.body()
form = await request.form()
args = request.query_params
background_tasks.add_task(start_webhook_chain, db, body, form, args)
background_tasks.add_task(start_webhook_chain, body, form, args)
return schemas.Response(success=True)
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
token: str, request: Request,
db: Session = Depends(get_db)) -> Any:
request: Request, _: str = Depends(verify_uri_token)) -> Any:
"""
Webhook响应
"""
if token != settings.API_TOKEN:
return schemas.Response(success=False, message="token认证不通过")
args = request.query_params
background_tasks.add_task(start_webhook_chain, db, None, None, args)
background_tasks.add_task(start_webhook_chain, None, None, args)
return schemas.Response(success=True)

View File

@@ -8,6 +8,7 @@ from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.metainfo import MetaInfo
from app.core.security import verify_uri_apikey
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.schemas import RadarrMovie, SonarrSeries
@@ -18,15 +19,10 @@ arr_router = APIRouter(tags=['servarr'])
@arr_router.get("/system/status", summary="系统状态")
def arr_system_status(apikey: str) -> Any:
def arr_system_status(_: str = Depends(verify_uri_apikey)) -> Any:
"""
模拟Radarr、Sonarr系统状态
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
return {
"appName": "MoviePilot",
"instanceName": "moviepilot",
@@ -77,15 +73,10 @@ def arr_system_status(apikey: str) -> Any:
@arr_router.get("/qualityProfile", summary="质量配置")
def arr_qualityProfile(apikey: str) -> Any:
def arr_qualityProfile(_: str = Depends(verify_uri_apikey)) -> Any:
"""
模拟Radarr、Sonarr质量配置
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
return [
{
"id": 1,
@@ -123,15 +114,10 @@ def arr_qualityProfile(apikey: str) -> Any:
@arr_router.get("/rootfolder", summary="根目录")
def arr_rootfolder(apikey: str) -> Any:
def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
"""
模拟Radarr、Sonarr根目录
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
return [
{
"id": 1,
@@ -144,15 +130,10 @@ def arr_rootfolder(apikey: str) -> Any:
@arr_router.get("/tag", summary="标签")
def arr_tag(apikey: str) -> Any:
def arr_tag(_: str = Depends(verify_uri_apikey)) -> Any:
"""
模拟Radarr、Sonarr标签
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
return [
{
"id": 1,
@@ -162,15 +143,10 @@ def arr_tag(apikey: str) -> Any:
@arr_router.get("/languageprofile", summary="语言")
def arr_languageprofile(apikey: str) -> Any:
def arr_languageprofile(_: str = Depends(verify_uri_apikey)) -> Any:
"""
模拟Radarr、Sonarr语言
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
return [{
"id": 1,
"name": "默认",
@@ -193,7 +169,7 @@ def arr_languageprofile(apikey: str) -> Any:
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
def arr_movies(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
"""
查询Rardar电影
"""
@@ -262,11 +238,6 @@ def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
}
]
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
# 查询所有电影订阅
result = []
subscribes = Subscribe.list(db)
@@ -289,23 +260,18 @@ def arr_movies(apikey: str, db: Session = Depends(get_db)) -> Any:
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
def arr_movie_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> Any:
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
"""
查询Rardar电影 term: `tmdb:${id}`
存在和不存在均不能返回错误
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
tmdbid = term.replace("tmdb:", "")
# 查询媒体信息
mediainfo = MediaChain(db).recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
if not mediainfo:
return [RadarrMovie()]
# 查询是否已存在
exists = MediaChain(db).media_exists(mediainfo=mediainfo)
exists = MediaChain().media_exists(mediainfo=mediainfo)
if not exists:
# 文件不存在
hasfile = False
@@ -340,15 +306,10 @@ def arr_movie_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> A
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
def arr_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
"""
查询Rardar电影订阅
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
subscribe = Subscribe.get(db, mid)
if subscribe:
return RadarrMovie(
@@ -371,18 +332,13 @@ def arr_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
@arr_router.post("/movie", summary="新增电影订阅")
def arr_add_movie(apikey: str,
movie: RadarrMovie,
def arr_add_movie(movie: RadarrMovie,
db: Session = Depends(get_db),
_: str = Depends(verify_uri_apikey)
) -> Any:
"""
新增Rardar电影订阅
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
# 检查订阅是否已存在
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
if subscribe:
@@ -390,11 +346,11 @@ def arr_add_movie(apikey: str,
"id": subscribe.id
}
# 添加订阅
sid, message = SubscribeChain(db).add(title=movie.title,
year=movie.year,
mtype=MediaType.MOVIE,
tmdbid=movie.tmdbId,
userid="Seerr")
sid, message = SubscribeChain().add(title=movie.title,
year=movie.year,
mtype=MediaType.MOVIE,
tmdbid=movie.tmdbId,
username="Seerr")
if sid:
return {
"id": sid
@@ -407,15 +363,10 @@ def arr_add_movie(apikey: str,
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
def arr_remove_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> Any:
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
"""
删除Rardar电影订阅
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
subscribe = Subscribe.get(db, mid)
if subscribe:
subscribe.delete(db, mid)
@@ -428,7 +379,7 @@ def arr_remove_movie(apikey: str, mid: int, db: Session = Depends(get_db)) -> An
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
def arr_series(_: str = Depends(verify_uri_apikey), db: Session = Depends(get_db)) -> Any:
"""
查询Sonarr剧集
"""
@@ -534,11 +485,6 @@ def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
}
]
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
# 查询所有电视剧订阅
result = []
subscribes = Subscribe.list(db)
@@ -569,20 +515,14 @@ def arr_series(apikey: str, db: Session = Depends(get_db)) -> Any:
@arr_router.get("/series/lookup", summary="查询剧集")
def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> Any:
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
"""
查询Sonarr剧集 term: `tvdb:${id}` title
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
# 获取TVDBID
if not term.startswith("tvdb:"):
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(term),
mtype=MediaType.TV)
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
mtype=MediaType.TV)
if not mediainfo:
return [SonarrSeries()]
tvdbid = mediainfo.tvdb_id
@@ -593,7 +533,7 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
tvdbid = int(term.replace("tvdb:", ""))
# 查询TVDB信息
tvdbinfo = MediaChain(db).tvdb_info(tvdbid=tvdbid)
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
if not tvdbinfo:
return [SonarrSeries()]
@@ -605,11 +545,11 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
# 根据TVDB查询媒体信息
if not mediainfo:
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
mtype=MediaType.TV)
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
mtype=MediaType.TV)
# 查询是否存在
exists = MediaChain(db).media_exists(mediainfo)
exists = MediaChain().media_exists(mediainfo)
if exists:
hasfile = True
else:
@@ -664,15 +604,10 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
@arr_router.get("/series/{tid}", summary="剧集详情")
def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
"""
查询Sonarr剧集
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
subscribe = Subscribe.get(db, tid)
if subscribe:
return SonarrSeries(
@@ -703,16 +638,12 @@ def arr_serie(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
@arr_router.post("/series", summary="新增剧集订阅")
def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
db: Session = Depends(get_db)) -> Any:
def arr_add_series(tv: schemas.SonarrSeries,
db: Session = Depends(get_db),
_: str = Depends(verify_uri_apikey)) -> Any:
"""
新增Sonarr剧集订阅
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
# 检查订阅是否存在
left_seasons = []
for season in tv.seasons:
@@ -732,12 +663,12 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
for season in left_seasons:
if not season.get("monitored"):
continue
sid, message = SubscribeChain(db).add(title=tv.title,
year=tv.year,
season=season.get("seasonNumber"),
tmdbid=tv.tmdbId,
mtype=MediaType.TV,
userid="Seerr")
sid, message = SubscribeChain().add(title=tv.title,
year=tv.year,
season=season.get("seasonNumber"),
tmdbid=tv.tmdbId,
mtype=MediaType.TV,
username="Seerr")
if sid:
return {
@@ -751,15 +682,10 @@ def arr_add_series(apikey: str, tv: schemas.SonarrSeries,
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
def arr_remove_series(apikey: str, tid: int, db: Session = Depends(get_db)) -> Any:
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_uri_apikey)) -> Any:
"""
删除Sonarr剧集订阅
"""
if not apikey or apikey != settings.API_TOKEN:
raise HTTPException(
status_code=403,
detail="认证失败!",
)
subscribe = Subscribe.get(db, tid)
if subscribe:
subscribe.delete(db, tid)

View File

@@ -7,7 +7,6 @@ from typing import Optional, Any, Tuple, List, Set, Union, Dict
from qbittorrentapi import TorrentFilesList
from ruamel.yaml import CommentedMap
from sqlalchemy.orm import Session
from transmission_rpc import File
from app.core.config import settings
@@ -28,11 +27,10 @@ class ChainBase(metaclass=ABCMeta):
处理链基类
"""
def __init__(self, db: Session = None):
def __init__(self):
"""
公共初始化
"""
self._db = db
self.modulemanager = ModuleManager()
self.eventmanager = EventManager()
@@ -47,7 +45,7 @@ class ChainBase(metaclass=ABCMeta):
with open(cache_path, 'rb') as f:
return pickle.load(f)
except Exception as err:
logger.error(f"加载缓存 {filename} 出错:{err}")
logger.error(f"加载缓存 {filename} 出错:{str(err)}")
return None
@staticmethod
@@ -59,12 +57,21 @@ class ChainBase(metaclass=ABCMeta):
with open(settings.TEMP_PATH / filename, 'wb') as f:
pickle.dump(cache, f)
except Exception as err:
logger.error(f"保存缓存 {filename} 出错:{err}")
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
finally:
# 主动资源回收
del cache
gc.collect()
@staticmethod
def remove_cache(filename: str) -> None:
"""
删除本地缓存
"""
cache_path = settings.TEMP_PATH / filename
if cache_path.exists():
Path(cache_path).unlink()
def run_module(self, method: str, *args, **kwargs) -> Any:
"""
运行包含该方法的所有模块,然后返回结果
@@ -100,31 +107,56 @@ class ChainBase(metaclass=ABCMeta):
# 中止继续执行
break
except Exception as err:
logger.error(f"运行模块 {method} 出错:{module.__class__.__name__} - {err}\n{traceback.print_exc()}")
logger.error(
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) -> Optional[MediaInfo]:
tmdbid: int = None,
doubanid: str = None) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:return: 识别的媒体信息,包括剧集信息
"""
return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid)
# 识别用名中含指定信息情形
if not mtype and meta and meta.type in [MediaType.TV, MediaType.MOVIE]:
mtype = meta.type
if not tmdbid and hasattr(meta, "tmdbid"):
tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid
return self.run_module("recognize_media", meta=meta, mtype=mtype,
tmdbid=tmdbid, doubanid=doubanid)
def match_doubaninfo(self, name: str, mtype: str = None,
year: str = None, season: int = None) -> Optional[dict]:
def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
"""
搜索和匹配豆瓣信息
:param name: 标题
:param imdbid: imdbid
:param mtype: 类型
:param year: 年份
:param season: 季
"""
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
mtype=mtype, year=year, season=season)
def match_tmdbinfo(self, name: str, mtype: MediaType = None,
year: str = None, season: int = None) -> Optional[dict]:
"""
搜索和匹配TMDB信息
:param name: 标题
:param mtype: 类型
:param year: 年份
:param season: 季
"""
return self.run_module("match_doubaninfo", name=name, mtype=mtype, year=year, season=season)
return self.run_module("match_tmdbinfo", name=name,
mtype=mtype, year=year, season=season)
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
@@ -150,13 +182,14 @@ class ChainBase(metaclass=ABCMeta):
image_prefix=image_prefix, image_type=image_type,
season=season, episode=episode)
def douban_info(self, doubanid: str) -> Optional[dict]:
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
"""
获取豆瓣信息
:param doubanid: 豆瓣ID
:param mtype: 媒体类型
:return: 豆瓣信息
"""
return self.run_module("douban_info", doubanid=doubanid)
return self.run_module("douban_info", doubanid=doubanid, mtype=mtype)
def tvdb_info(self, tvdbid: int) -> Optional[dict]:
"""
@@ -348,15 +381,6 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("media_exists", mediainfo=mediainfo, itemid=itemid)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
self.run_module("refresh_mediaserver", mediainfo=mediainfo, file_path=file_path)
def post_message(self, message: Notification) -> None:
"""
发送消息
@@ -367,6 +391,7 @@ class ChainBase(metaclass=ABCMeta):
self.eventmanager.send_event(etype=EventType.NoticeMessage,
data={
"channel": message.channel,
"type": message.mtype,
"title": message.title,
"text": message.text,
"image": message.image,
@@ -396,14 +421,19 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("post_torrents_message", message=message, torrents=torrents)
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
force_nfo: bool = False, force_img: bool = False) -> None:
"""
刮削元数据
:param path: 媒体文件路径
:param mediainfo: 识别的媒体信息
:param transfer_type: 转移模式
:param force_nfo: 强制刮削nfo
:param force_img: 强制刮削图片
:return: 成功或失败
"""
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo,
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
def register_commands(self, commands: Dict[str, dict]) -> None:
"""

View File

@@ -3,7 +3,6 @@ from typing import Tuple, Optional
from urllib.parse import urljoin
from lxml import etree
from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.chain.site import SiteChain
@@ -25,13 +24,13 @@ class CookieCloudChain(ChainBase):
CookieCloud处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
self.siteoper = SiteOper(self._db)
self.siteiconoper = SiteIconOper(self._db)
def __init__(self):
super().__init__()
self.siteoper = SiteOper()
self.siteiconoper = SiteIconOper()
self.siteshelper = SitesHelper()
self.rsshelper = RssHelper()
self.sitechain = SiteChain(self._db)
self.sitechain = SiteChain()
self.message = MessageHelper()
self.cookiecloud = CookieCloudHelper(
server=settings.COOKIECLOUD_HOST,

View File

@@ -2,9 +2,10 @@ from typing import Optional, List
from app import schemas
from app.chain import ChainBase
from app.utils.singleton import Singleton
class DashboardChain(ChainBase):
class DashboardChain(ChainBase, metaclass=Singleton):
"""
各类仪表板统计处理链
"""

View File

@@ -1,51 +1,17 @@
from typing import Optional, List
from app.chain import ChainBase
from app.core.context import Context
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.log import logger
from app.core.config import settings
from app.schemas import MediaType
from app.utils.singleton import Singleton
class DoubanChain(ChainBase):
class DoubanChain(ChainBase, metaclass=Singleton):
"""
豆瓣处理链
豆瓣处理链,单例运行
"""
def recognize_by_doubanid(self, doubanid: str) -> Optional[Context]:
"""
根据豆瓣ID识别媒体信息
"""
logger.info(f'开始识别媒体信息豆瓣ID{doubanid} ...')
# 查询豆瓣信息
doubaninfo = self.douban_info(doubanid=doubanid)
if not doubaninfo:
logger.warn(f'未查询到豆瓣信息豆瓣ID{doubanid}')
return None
return self.recognize_by_doubaninfo(doubaninfo)
def recognize_by_doubaninfo(self, doubaninfo: dict) -> Optional[Context]:
"""
根据豆瓣信息识别媒体信息
"""
# 使用原标题匹配
meta = MetaInfo(title=doubaninfo.get("original_title") or doubaninfo.get("title"))
# 处理类型
if isinstance(doubaninfo.get('media_type'), MediaType):
meta.type = doubaninfo.get('media_type')
else:
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type)
if not mediainfo:
logger.warn(f'{meta.name} 未识别到TMDB媒体信息')
return Context(meta_info=meta, media_info=MediaInfo(douban_info=doubaninfo))
logger.info(f'识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year} {meta.season}')
mediainfo.set_douban_info(doubaninfo)
return Context(meta_info=meta, media_info=mediainfo)
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取豆瓣电影TOP250
:param page: 页码
@@ -53,19 +19,19 @@ class DoubanChain(ChainBase):
"""
return self.run_module("movie_top250", page=page, count=count)
def movie_showing(self, page: int = 1, count: int = 30) -> List[dict]:
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取正在上映的电影
"""
return self.run_module("movie_showing", page=page, count=count)
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[dict]:
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取本周中国剧集榜
"""
return self.run_module("tv_weekly_chinese", page=page, count=count)
def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[dict]:
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取本周全球剧集榜
"""
@@ -84,3 +50,55 @@ class DoubanChain(ChainBase):
"""
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
page=page, count=count)
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取动画剧集
"""
return self.run_module("tv_animation", page=page, count=count)
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取热门电影
"""
if settings.RECOGNIZE_SOURCE != "douban":
return None
return self.run_module("movie_hot", page=page, count=count)
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取热门剧集
"""
if settings.RECOGNIZE_SOURCE != "douban":
return None
return self.run_module("tv_hot", page=page, count=count)
def movie_credits(self, doubanid: str, page: int = 1) -> List[dict]:
"""
根据TMDBID查询电影演职人员
:param doubanid: 豆瓣ID
:param page: 页码
"""
return self.run_module("douban_movie_credits", doubanid=doubanid, page=page)
def tv_credits(self, doubanid: str, page: int = 1) -> List[dict]:
"""
根据TMDBID查询电视剧演职人员
:param doubanid: 豆瓣ID
:param page: 页码
"""
return self.run_module("douban_tv_credits", doubanid=doubanid, page=page)
def movie_recommend(self, doubanid: str) -> List[dict]:
"""
根据豆瓣ID查询推荐电影
:param doubanid: 豆瓣ID
"""
return self.run_module("douban_movie_recommend", doubanid=doubanid)
def tv_recommend(self, doubanid: str) -> List[dict]:
"""
根据豆瓣ID查询推荐电视剧
:param doubanid: 豆瓣ID
"""
return self.run_module("douban_tv_recommend", doubanid=doubanid)

View File

@@ -1,12 +1,11 @@
import base64
import copy
import json
import re
import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context
@@ -27,11 +26,11 @@ class DownloadChain(ChainBase):
下载处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
def __init__(self):
super().__init__()
self.torrent = TorrentHelper()
self.downloadhis = DownloadHistoryOper(self._db)
self.mediaserver = MediaServerOper(self._db)
self.downloadhis = DownloadHistoryOper()
self.mediaserver = MediaServerOper()
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
channel: MessageChannel = None,
@@ -56,6 +55,8 @@ class DownloadChain(ChainBase):
msg_text = f"{msg_text}\n种子:{torrent.title}"
if torrent.pubdate:
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
if torrent.freedate:
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
if torrent.seeders:
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
@@ -171,7 +172,8 @@ class DownloadChain(ChainBase):
episodes: Set[int] = None,
channel: MessageChannel = None,
save_path: str = None,
userid: Union[str, int] = None) -> Optional[str]:
userid: Union[str, int] = None,
username: str = None) -> Optional[str]:
"""
下载及发送通知
:param context: 资源上下文
@@ -180,6 +182,7 @@ class DownloadChain(ChainBase):
:param channel: 通知渠道
:param save_path: 保存路径
:param userid: 用户ID
:param username: 调用下载的用户名/插件名
"""
_torrent = context.torrent_info
_media = context.media_info
@@ -191,7 +194,7 @@ class DownloadChain(ChainBase):
channel=channel,
userid=userid)
if not content:
return
return None
else:
content = torrent_file
# 获取种子文件的文件夹名和文件清单
@@ -203,33 +206,31 @@ class DownloadChain(ChainBase):
# 开启下载二级目录
if _media.type == MediaType.MOVIE:
# 电影
download_dir = Path(settings.DOWNLOAD_MOVIE_PATH or settings.DOWNLOAD_PATH) / _media.category
download_dir = settings.SAVE_MOVIE_PATH / _media.category
else:
if settings.DOWNLOAD_ANIME_PATH \
and _media.genre_ids \
if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
download_dir = Path(settings.DOWNLOAD_ANIME_PATH)
download_dir = settings.SAVE_ANIME_PATH
else:
# 电视剧
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH) / _media.category
download_dir = settings.SAVE_TV_PATH / _media.category
elif _media:
# 未开启下载二级目录
if _media.type == MediaType.MOVIE:
# 电影
download_dir = Path(settings.DOWNLOAD_MOVIE_PATH or settings.DOWNLOAD_PATH)
download_dir = settings.SAVE_MOVIE_PATH
else:
if settings.DOWNLOAD_ANIME_PATH \
and _media.genre_ids \
if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
download_dir = Path(settings.DOWNLOAD_ANIME_PATH)
download_dir = settings.SAVE_ANIME_PATH
else:
# 电视剧
download_dir = Path(settings.DOWNLOAD_TV_PATH or settings.DOWNLOAD_PATH)
download_dir = settings.SAVE_TV_PATH
else:
# 未识别
download_dir = Path(settings.DOWNLOAD_PATH)
download_dir = settings.SAVE_PATH
else:
# 自定义下载目录
download_dir = Path(save_path)
@@ -270,6 +271,7 @@ class DownloadChain(ChainBase):
torrent_description=_torrent.description,
torrent_site=_torrent.site_name,
userid=userid,
username=username,
channel=channel.value if channel else None,
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
)
@@ -283,6 +285,10 @@ class DownloadChain(ChainBase):
if not file_meta.begin_episode \
or file_meta.begin_episode not in episodes:
continue
# 只处理视频格式
if not Path(file).suffix \
or Path(file).suffix not in settings.RMT_MEDIAEXT:
continue
files_to_add.append({
"download_hash": _hash,
"downloader": settings.DOWNLOADER,
@@ -321,10 +327,12 @@ class DownloadChain(ChainBase):
def batch_download(self,
contexts: List[Context],
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
save_path: str = None,
channel: MessageChannel = None,
userid: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
userid: str = None,
username: str = None
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
"""
根据缺失数据,自动种子列表中组合择优下载
:param contexts: 资源上下文列表
@@ -332,32 +340,36 @@ class DownloadChain(ChainBase):
:param save_path: 保存路径
:param channel: 通知渠道
:param userid: 用户ID
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id] = {season: NotExistMediaInfo}
:param username: 调用下载的用户名/插件名
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
"""
# 已下载的项目
downloaded_list: List[Context] = []
def __update_seasons(_tmdbid: int, _need: list, _current: list) -> list:
def __update_seasons(_mid: Union[int, str], _need: list, _current: list) -> list:
"""
更新need_tvs季数返回剩余季数
:param _tmdbid: TMDBID
:param _mid: TMDBID
:param _need: 需要下载的季数
:param _current: 已经下载的季数
"""
# 剩余季数
need = list(set(_need).difference(set(_current)))
# 清除已下载的季信息
for _sea in list(no_exists.get(_tmdbid)):
if _sea not in need:
no_exists[_tmdbid].pop(_sea)
if not no_exists.get(_tmdbid) and no_exists.get(_tmdbid) is not None:
no_exists.pop(_tmdbid)
seas = copy.deepcopy(no_exists.get(_mid))
if seas:
for _sea in list(seas):
if _sea not in need:
no_exists[_mid].pop(_sea)
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
no_exists.pop(_mid)
break
return need
def __update_episodes(_tmdbid: int, _sea: int, _need: list, _current: set) -> list:
def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:
"""
更新need_tvs集数返回剩余集数
:param _tmdbid: TMDBID
:param _mid: TMDBID
:param _sea: 季数
:param _need: 需要下载的集数
:param _current: 已经下载的集数
@@ -365,26 +377,26 @@ class DownloadChain(ChainBase):
# 剩余集数
need = list(set(_need).difference(set(_current)))
if need:
not_exist = no_exists[_tmdbid][_sea]
no_exists[_tmdbid][_sea] = NotExistMediaInfo(
not_exist = no_exists[_mid][_sea]
no_exists[_mid][_sea] = NotExistMediaInfo(
season=not_exist.season,
episodes=need,
total_episode=not_exist.total_episode,
start_episode=not_exist.start_episode
)
else:
no_exists[_tmdbid].pop(_sea)
if not no_exists.get(_tmdbid) and no_exists.get(_tmdbid) is not None:
no_exists.pop(_tmdbid)
no_exists[_mid].pop(_sea)
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
no_exists.pop(_mid)
return need
def __get_season_episodes(tmdbid: int, season: int) -> int:
def __get_season_episodes(_mid: Union[int, str], season: int) -> int:
"""
获取需要的季的集数
"""
if not no_exists.get(tmdbid):
if not no_exists.get(_mid):
return 9999
no_exist = no_exists.get(tmdbid)
no_exist = no_exists.get(_mid)
if not no_exist.get(season):
return 9999
return no_exist[season].total_episode
@@ -396,7 +408,7 @@ class DownloadChain(ChainBase):
for context in contexts:
if context.media_info.type == MediaType.MOVIE:
if self.download_single(context, save_path=save_path,
channel=channel, userid=userid):
channel=channel, userid=userid, username=username):
# 下载成功
downloaded_list.append(context)
@@ -404,17 +416,17 @@ class DownloadChain(ChainBase):
if no_exists:
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
need_seasons: Dict[int, list] = {}
for need_tmdbid, need_tv in no_exists.items():
for need_mid, need_tv in no_exists.items():
for tv in need_tv.values():
if not tv:
continue
# 季列表为空的,代表全季缺失
if not tv.episodes:
if not need_seasons.get(need_tmdbid):
need_seasons[need_tmdbid] = []
need_seasons[need_tmdbid].append(tv.season or 1)
if not need_seasons.get(need_mid):
need_seasons[need_mid] = []
need_seasons[need_mid].append(tv.season or 1)
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
for need_tmdbid, need_season in need_seasons.items():
for need_mid, need_season in need_seasons.items():
# 循环种子
for context in contexts:
# 媒体信息
@@ -432,7 +444,7 @@ class DownloadChain(ChainBase):
if meta.episode_list:
continue
# 匹配TMDBID
if need_tmdbid == media.tmdb_id:
if need_mid == media.tmdb_id or need_mid == media.douban_id:
# 种子季是需要季或者子集
if set(torrent_season).issubset(set(need_season)):
if len(torrent_season) == 1:
@@ -447,13 +459,13 @@ class DownloadChain(ChainBase):
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
if not torrent_episodes:
continue
# 总集数
need_total = __get_season_episodes(need_tmdbid, torrent_season[0])
# 更新集数范围
begin_ep = min(torrent_episodes)
end_ep = max(torrent_episodes)
meta.set_episodes(begin=begin_ep, end=end_ep)
# 需要总集数
need_total = __get_season_episodes(need_mid, torrent_season[0])
if len(torrent_episodes) < need_total:
# 更新集数范围
begin_ep = min(torrent_episodes)
end_ep = max(torrent_episodes)
meta.set_episodes(begin=begin_ep, end=end_ep)
logger.info(
f"{meta.org_string} 解析文件集数发现不是完整合集")
continue
@@ -464,31 +476,36 @@ class DownloadChain(ChainBase):
torrent_file=content if isinstance(content, Path) else None,
save_path=save_path,
channel=channel,
userid=userid
userid=userid,
username=username
)
else:
# 下载
download_id = self.download_single(context, save_path=save_path,
channel=channel, userid=userid)
channel=channel, userid=userid, username=username)
if download_id:
# 下载成功
downloaded_list.append(context)
# 更新仍需季集
need_season = __update_seasons(_tmdbid=need_tmdbid,
need_season = __update_seasons(_mid=need_mid,
_need=need_season,
_current=torrent_season)
if not need_season:
# 全部下载完成
break
# 电视剧季内的集匹配
if no_exists:
# TMDBID列表
need_tv_list = list(no_exists)
for need_tmdbid in need_tv_list:
for need_mid in need_tv_list:
# dict[season, [NotExistMediaInfo]]
need_tv = no_exists.get(need_tmdbid)
need_tv = no_exists.get(need_mid)
if not need_tv:
continue
need_tv_copy = copy.deepcopy(no_exists.get(need_mid))
# 循环每一季
for sea, tv in need_tv.items():
for sea, tv in need_tv_copy.items():
# 当前需要季
need_season = sea
# 当前需要集
@@ -499,7 +516,7 @@ class DownloadChain(ChainBase):
start_episode = tv.start_episode or 1
# 缺失整季的转化为缺失集进行比较
if not need_episodes:
need_episodes = list(range(start_episode, total_episode))
need_episodes = list(range(start_episode, total_episode + 1))
# 循环种子
for context in contexts:
# 媒体信息
@@ -510,7 +527,7 @@ class DownloadChain(ChainBase):
if media.type != MediaType.TV:
continue
# 匹配TMDB
if media.tmdb_id == need_tmdbid:
if media.tmdb_id == need_mid or media.douban_id == need_mid:
# 不重复添加
if context in downloaded_list:
continue
@@ -528,12 +545,12 @@ class DownloadChain(ChainBase):
if torrent_episodes.issubset(set(need_episodes)):
# 下载
download_id = self.download_single(context, save_path=save_path,
channel=channel, userid=userid)
channel=channel, userid=userid, username=username)
if download_id:
# 下载成功
downloaded_list.append(context)
# 更新仍需集数
need_episodes = __update_episodes(_tmdbid=need_tmdbid,
need_episodes = __update_episodes(_mid=need_mid,
_need=need_episodes,
_sea=need_season,
_current=torrent_episodes)
@@ -542,9 +559,9 @@ class DownloadChain(ChainBase):
if no_exists:
# TMDBID列表
no_exists_list = list(no_exists)
for need_tmdbid in no_exists_list:
for need_mid in no_exists_list:
# dict[season, [NotExistMediaInfo]]
need_tv = no_exists.get(need_tmdbid)
need_tv = no_exists.get(need_mid)
if not need_tv:
continue
# 需要季列表
@@ -578,7 +595,7 @@ class DownloadChain(ChainBase):
if not need_episodes:
break
# 选中一个单季整季的或单季包括需要的所有集的
if media.tmdb_id == need_tmdbid \
if (media.tmdb_id == need_mid or media.douban_id == need_mid) \
and (not meta.episode_list
or set(meta.episode_list).intersection(set(need_episodes))) \
and len(meta.season_list) == 1 \
@@ -606,17 +623,19 @@ class DownloadChain(ChainBase):
episodes=selected_episodes,
save_path=save_path,
channel=channel,
userid=userid
userid=userid,
username=username
)
if not download_id:
continue
# 把识别的集更新到上下文
context.meta_info.begin_episode = min(selected_episodes)
context.meta_info.end_episode = max(selected_episodes)
# 下载成功
downloaded_list.append(context)
# 更新种子集数范围
begin_ep = min(torrent_episodes)
end_ep = max(torrent_episodes)
meta.set_episodes(begin=begin_ep, end=end_ep)
# 更新仍需集数
need_episodes = __update_episodes(_tmdbid=need_tmdbid,
need_episodes = __update_episodes(_mid=need_mid,
_need=need_episodes,
_sea=need_season,
_current=selected_episodes)
@@ -628,7 +647,7 @@ class DownloadChain(ChainBase):
mediainfo: MediaInfo,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
totals: Dict[int, int] = None
) -> Tuple[bool, Dict[int, Dict[int, NotExistMediaInfo]]]:
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
"""
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
:param meta: 元数据
@@ -648,8 +667,9 @@ class DownloadChain(ChainBase):
"start_episode": int
]}
"""
if not no_exists.get(mediainfo.tmdb_id):
no_exists[mediainfo.tmdb_id] = {
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
if not no_exists.get(mediakey):
no_exists[mediakey] = {
_season: NotExistMediaInfo(
season=_season,
episodes=_episodes,
@@ -658,7 +678,7 @@ class DownloadChain(ChainBase):
)
}
else:
no_exists[mediainfo.tmdb_id][_season] = NotExistMediaInfo(
no_exists[mediakey][_season] = NotExistMediaInfo(
season=_season,
episodes=_episodes,
total_episode=_total,
@@ -674,6 +694,7 @@ class DownloadChain(ChainBase):
if mediainfo.type == MediaType.MOVIE:
# 电影
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
title=mediainfo.title,
tmdbid=mediainfo.tmdb_id)
exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
if exists_movies:
@@ -684,7 +705,8 @@ class DownloadChain(ChainBase):
if not mediainfo.seasons:
# 补充媒体信息
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id)
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
return False, {}
@@ -693,6 +715,7 @@ class DownloadChain(ChainBase):
return False, {}
# 电视剧
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
title=mediainfo.title,
tmdbid=mediainfo.tmdb_id,
season=mediainfo.season)
# 媒体库已存在的剧集
@@ -703,7 +726,7 @@ class DownloadChain(ChainBase):
if not episodes:
continue
# 全季不存在
if meta.season_list \
if meta.sea \
and season not in meta.season_list:
continue
# 总集数
@@ -714,7 +737,7 @@ class DownloadChain(ChainBase):
else:
# 存在一些,检查每季缺失的季集
for season, episodes in mediainfo.seasons.items():
if meta.begin_season \
if meta.sea \
and season not in meta.season_list:
continue
if not episodes:
@@ -786,6 +809,7 @@ class DownloadChain(ChainBase):
for torrent in torrents:
history = self.downloadhis.get_by_hash(torrent.hash)
if history:
# 媒体信息
torrent.media = {
"tmdbid": history.tmdbid,
"type": history.type,
@@ -794,6 +818,8 @@ class DownloadChain(ChainBase):
"episode": history.episodes,
"image": history.image,
}
# 下载用户
torrent.userid = history.userid
ret_torrents.append(torrent)
return ret_torrents

View File

@@ -1,36 +1,136 @@
import copy
import time
from pathlib import Path
from threading import Lock
from typing import Optional, List, Tuple
from app.chain import ChainBase
from app.core.context import Context, MediaInfo
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.log import logger
from app.schemas.types import EventType, MediaType
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
recognize_lock = Lock()
class MediaChain(ChainBase):
"""
媒体信息处理链
"""
def recognize_by_title(self, title: str, subtitle: str = None) -> Optional[Context]:
class MediaChain(ChainBase, metaclass=Singleton):
"""
媒体信息处理链,单例运行
"""
# 临时识别标题
recognize_title: Optional[str] = None
# 临时识别结果 {title, name, year, season, episode}
recognize_temp: Optional[dict] = None
def recognize_by_meta(self, metainfo: MetaBase) -> Optional[MediaInfo]:
"""
根据主副标题识别媒体信息
"""
logger.info(f'开始识别媒体信息,标题:{title},副标题:{subtitle} ...')
# 识别元数据
metainfo = MetaInfo(title, subtitle)
title = metainfo.title
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
if not mediainfo:
logger.warn(f'{title} 未识别到媒体信息')
return Context(meta_info=metainfo)
# 偿试使用辅助识别,如果有注册响应事件的话
if eventmanager.check(EventType.NameRecognize):
logger.info(f'请求辅助识别,标题:{title} ...')
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
if not mediainfo:
logger.warn(f'{title} 未识别到媒体信息')
return None
# 识别成功
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
# 返回上下文
return Context(meta_info=metainfo, media_info=mediainfo)
return mediainfo
def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:
"""
请求辅助识别,返回媒体信息
:param title: 标题
:param org_meta: 原始元数据
"""
with recognize_lock:
self.recognize_temp = None
self.recognize_title = title
# 发送请求事件
eventmanager.send_event(
EventType.NameRecognize,
{
'title': title,
}
)
# 每0.5秒循环一次等待结果直到10秒后超时
for i in range(20):
if self.recognize_temp is not None:
break
time.sleep(0.5)
# 加锁
with recognize_lock:
mediainfo = None
if not self.recognize_temp or self.recognize_title != title:
# 没有识别结果或者识别标题已改变
return None
# 有识别结果
meta_dict = copy.deepcopy(self.recognize_temp)
logger.info(f'获取到辅助识别结果:{meta_dict}')
if meta_dict.get("name") == org_meta.name and meta_dict.get("year") == org_meta.year:
logger.info(f'辅助识别结果与原始识别结果一致')
else:
logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...')
org_meta.name = meta_dict.get("name")
org_meta.year = meta_dict.get("year")
org_meta.begin_season = meta_dict.get("season")
org_meta.begin_episode = meta_dict.get("episode")
if org_meta.begin_season or org_meta.begin_episode:
org_meta.type = MediaType.TV
# 重新识别
mediainfo = self.recognize_media(meta=org_meta)
return mediainfo
@eventmanager.register(EventType.NameRecognizeResult)
def recognize_result(self, event: Event):
"""
监控识别结果事件,获取辅助识别结果,结果格式:{title, name, year, season, episode}
"""
if not event:
return
event_data = event.event_data or {}
# 加锁
with recognize_lock:
# 不是原标题的结果不要
if event_data.get("title") != self.recognize_title:
return
# 标志收到返回
self.recognize_temp = {}
# 处理数据格式
file_title, file_year, season_number, episode_number = None, None, None, None
if event_data.get("name"):
file_title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
if event_data.get("year"):
file_year = str(event_data["year"]).split("/")[0].strip()
if event_data.get("season") and str(event_data["season"]).isdigit():
season_number = int(event_data["season"])
if event_data.get("episode") and str(event_data["episode"]).isdigit():
episode_number = int(event_data["episode"])
if not file_title:
return
if file_title == 'Unknown':
return
if not str(file_year).isdigit():
file_year = None
# 结果赋值
self.recognize_temp = {
"name": file_title,
"year": file_year,
"season": season_number,
"episode": episode_number
}
def recognize_by_path(self, path: str) -> Optional[Context]:
"""
@@ -43,8 +143,13 @@ class MediaChain(ChainBase):
# 识别媒体信息
mediainfo = self.recognize_media(meta=file_meta)
if not mediainfo:
logger.warn(f'{path} 未识别到媒体信息')
return Context(meta_info=file_meta)
# 偿试使用辅助识别,如果有注册响应事件的话
if eventmanager.check(EventType.NameRecognize):
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
if not mediainfo:
logger.warn(f'{path} 未识别到媒体信息')
return Context(meta_info=file_meta)
logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
@@ -62,8 +167,7 @@ class MediaChain(ChainBase):
# 识别
meta = MetaInfo(content)
if not meta.name:
logger.warn(f'{title} 未识别到元数据!')
return meta, []
meta.cn_name = content
# 合并信息
if mtype:
meta.type = mtype
@@ -82,3 +186,78 @@ class MediaChain(ChainBase):
logger.info(f"{content} 搜索到 {len(medias)} 条相关媒体信息")
# 识别的元数据,媒体信息列表
return meta, medias
def get_tmdbinfo_by_doubanid(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
"""
根据豆瓣ID获取TMDB信息
"""
tmdbinfo = None
doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)
if doubaninfo:
# 优先使用原标题匹配
season_meta = None
if doubaninfo.get("original_title"):
meta = MetaInfo(title=doubaninfo.get("original_title"))
season_meta = MetaInfo(title=doubaninfo.get("title"))
# 合并季
meta.begin_season = season_meta.begin_season
else:
meta = MetaInfo(title=doubaninfo.get("title"))
# 年份
if doubaninfo.get("year"):
meta.year = doubaninfo.get("year")
# 处理类型
if isinstance(doubaninfo.get('media_type'), MediaType):
meta.type = doubaninfo.get('media_type')
else:
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
# 使用原标题识别TMDB媒体信息
tmdbinfo = self.match_tmdbinfo(
name=meta.name,
year=meta.year,
mtype=mtype or meta.type,
season=meta.begin_season
)
if not tmdbinfo:
if season_meta and season_meta.name != meta.name:
# 使用主标题识别媒体信息
tmdbinfo = self.match_tmdbinfo(
name=season_meta.name,
year=meta.year,
mtype=mtype or meta.type,
season=meta.begin_season
)
return tmdbinfo
def get_doubaninfo_by_tmdbid(self, tmdbid: int,
mtype: MediaType = None, season: int = None) -> Optional[dict]:
"""
根据TMDBID获取豆瓣信息
"""
tmdbinfo = self.tmdb_info(tmdbid=tmdbid, mtype=mtype)
if tmdbinfo:
# 名称
name = tmdbinfo.get("title") or tmdbinfo.get("name")
# 年份
year = None
if tmdbinfo.get('release_date'):
year = tmdbinfo['release_date'][:4]
elif tmdbinfo.get('seasons') and season:
for seainfo in tmdbinfo['seasons']:
# 季
season_number = seainfo.get("season_number")
if not season_number:
continue
air_date = seainfo.get("air_date")
if air_date and season_number == season:
year = air_date[:4]
break
# IMDBID
imdbid = tmdbinfo.get("external_ids", {}).get("imdb_id")
return self.match_doubaninfo(
name=name,
year=year,
mtype=mtype,
imdbid=imdbid
)
return None

View File

@@ -1,13 +1,10 @@
import json
import threading
from typing import List, Union, Generator
from sqlalchemy.orm import Session
from typing import List, Union, Optional
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.db import SessionFactory
from app.db.mediaserver_oper import MediaServerOper
from app.log import logger
@@ -19,14 +16,15 @@ class MediaServerChain(ChainBase):
媒体服务器处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
def __init__(self):
super().__init__()
self.dboper = MediaServerOper()
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库
"""
return self.run_module("mediaserver_librarys", server=server)
return self.run_module("mediaserver_librarys", server=server, username=username)
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
"""
@@ -46,18 +44,33 @@ class MediaServerChain(ChainBase):
"""
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
def latest(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取播放地址
"""
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
def sync(self):
"""
同步媒体库所有数据到本地数据库
"""
with lock:
# 媒体服务器同步使用独立的会话
_db = SessionFactory()
_dbOper = MediaServerOper(_db)
# 汇总统计
total_count = 0
# 清空登记薄
_dbOper.empty(server=settings.MEDIASERVER)
self.dboper.empty()
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
@@ -79,6 +92,7 @@ class MediaServerChain(ChainBase):
continue
if not item.item_id:
continue
logger.debug(f"正在同步 {item.title} ...")
# 计数
library_count += 1
seasoninfo = {}
@@ -93,11 +107,8 @@ class MediaServerChain(ChainBase):
item_dict = item.dict()
item_dict['seasoninfo'] = json.dumps(seasoninfo)
item_dict['item_type'] = item_type
_dbOper.add(**item_dict)
self.dboper.add(**item_dict)
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
# 总数累加
total_count += library_count
# 关闭数据库连接
if _db:
_db.close()
logger.info("【MediaServer】媒体库数据同步完成同步数量%s" % total_count)

View File

@@ -1,14 +1,22 @@
from typing import Any
import copy
import json
import re
from typing import Any, Optional, Dict
from app.chain.download import *
from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.chain.subscribe import SubscribeChain
from app.core.context import MediaInfo
from app.core.config import settings
from app.core.context import MediaInfo, Context
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.types import EventType, MessageChannel
from app.schemas.types import EventType, MessageChannel, MediaType
from app.utils.string import StringUtils
# 当前页面
_current_page: int = 0
@@ -27,13 +35,12 @@ class MessageChain(ChainBase):
# 每页数据量
_page_size: int = 8
def __init__(self, db: Session = None):
super().__init__(db)
self.downloadchain = DownloadChain(self._db)
self.subscribechain = SubscribeChain(self._db)
self.searchchain = SearchChain(self._db)
self.medtachain = MediaChain(self._db)
self.torrent = TorrentHelper()
def __init__(self):
super().__init__()
self.downloadchain = DownloadChain()
self.subscribechain = SubscribeChain()
self.searchchain = SearchChain()
self.medtachain = MediaChain()
self.eventmanager = EventManager()
self.torrenthelper = TorrentHelper()
@@ -86,13 +93,15 @@ class MessageChain(ChainBase):
# 发送消息
self.post_message(Notification(channel=channel, title="输入有误!", userid=userid))
return
# 选择的序号
_choice = int(text) + _current_page * self._page_size - 1
# 缓存类型
cache_type: str = cache_data.get('type')
# 缓存列表
cache_list: list = cache_data.get('items')
cache_list: list = copy.deepcopy(cache_data.get('items'))
# 选择
if cache_type == "Search":
mediainfo: MediaInfo = cache_list[int(text) + _current_page * self._page_size - 1]
mediainfo: MediaInfo = cache_list[_choice]
_current_media = mediainfo
# 查询缺失的媒体信息
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
@@ -107,11 +116,13 @@ class MessageChain(ChainBase):
# 发送缺失的媒体信息
if no_exists:
# 发送消息
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(mediainfo.tmdb_id).items()]
for sea, no_exist in no_exists.get(mediakey).items()]
self.post_message(Notification(channel=channel,
title=f"{mediainfo.title_year}\n" + "\n".join(messages)))
title=f"{mediainfo.title_year}\n" + "\n".join(messages),
userid=userid))
# 搜索种子,过滤掉不需要的剧集,以便选择
logger.info(f"{mediainfo.title_year} 媒体库中不存在,开始搜索 ...")
self.post_message(
@@ -133,7 +144,7 @@ class MessageChain(ChainBase):
# 判断是否设置自动下载
auto_download_user = settings.AUTO_DOWNLOAD_USER
# 匹配到自动下载用户
if auto_download_user and any(userid == user for user in auto_download_user.split(",")):
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,
@@ -156,7 +167,7 @@ class MessageChain(ChainBase):
elif cache_type == "Subscribe":
# 订阅媒体
mediainfo: MediaInfo = cache_list[int(text) - 1]
mediainfo: MediaInfo = cache_list[_choice]
# 查询缺失的媒体信息
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
mediainfo=mediainfo)
@@ -185,9 +196,9 @@ class MessageChain(ChainBase):
username=username)
else:
# 下载种子
context: Context = cache_list[int(text) - 1]
context: Context = cache_list[_choice]
# 下载
self.downloadchain.download_single(context, userid=userid, channel=channel)
self.downloadchain.download_single(context, userid=userid, channel=channel, username=username)
elif text.lower() == "p":
# 上一页
@@ -203,10 +214,11 @@ class MessageChain(ChainBase):
self.post_message(Notification(
channel=channel, title="已经是第一页了!", userid=userid))
return
cache_type: str = cache_data.get('type')
cache_list: list = cache_data.get('items')
# 减一页
_current_page -= 1
cache_type: str = cache_data.get('type')
# 产生副本,避免修改原值
cache_list: list = copy.deepcopy(cache_data.get('items'))
if _current_page == 0:
start = 0
end = self._page_size
@@ -214,11 +226,6 @@ class MessageChain(ChainBase):
start = _current_page * self._page_size
end = start + self._page_size
if cache_type == "Torrent":
# 更新缓存
user_cache[userid] = {
"type": "Torrent",
"items": cache_list[start:end]
}
# 发送种子数据
self.__post_torrents_message(channel=channel,
title=_current_media.title,
@@ -242,7 +249,8 @@ class MessageChain(ChainBase):
channel=channel, title="输入有误!", userid=userid))
return
cache_type: str = cache_data.get('type')
cache_list: list = cache_data.get('items')
# 产生副本,避免修改原值
cache_list: list = copy.deepcopy(cache_data.get('items'))
total = len(cache_list)
# 加一页
cache_list = cache_list[
@@ -256,11 +264,6 @@ class MessageChain(ChainBase):
# 加一页
_current_page += 1
if cache_type == "Torrent":
# 更新缓存
user_cache[userid] = {
"type": "Torrent",
"items": cache_list
}
# 发送种子数据
self.__post_torrents_message(channel=channel,
title=_current_media.title,
@@ -349,13 +352,21 @@ class MessageChain(ChainBase):
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
no_exists=no_exists,
channel=channel,
userid=userid)
userid=userid,
username=username)
if downloads and not lefts:
# 全部下载完成
logger.info(f'{_current_media.title_year} 下载完成')
else:
# 未完成下载
logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...')
if downloads and _current_media.type == MediaType.TV:
# 获取已下载剧集
downloaded = [download.meta_info.begin_episode for download in downloads
if download.meta_info.begin_episode]
note = json.dumps(downloaded)
else:
note = None
# 添加订阅状态为R
self.subscribechain.add(title=_current_media.title,
year=_current_media.year,
@@ -365,7 +376,8 @@ class MessageChain(ChainBase):
channel=channel,
userid=userid,
username=username,
state="R")
state="R",
note=note)
def __post_medias_message(self, channel: MessageChannel,
title: str, items: list, userid: str, total: int):

View File

@@ -1,12 +1,11 @@
import pickle
import re
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Dict
from typing import Dict, Tuple
from typing import List, Optional
from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.core.context import Context
from app.core.context import MediaInfo, TorrentInfo
@@ -26,21 +25,23 @@ class SearchChain(ChainBase):
站点资源搜索处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
def __init__(self):
super().__init__()
self.siteshelper = SitesHelper()
self.progress = ProgressHelper()
self.systemconfig = SystemConfigOper()
self.torrenthelper = TorrentHelper()
def search_by_tmdbid(self, tmdbid: int, mtype: MediaType = None, area: str = "title") -> List[Context]:
def search_by_id(self, tmdbid: int = None, doubanid: str = None,
mtype: MediaType = None, area: str = "title") -> List[Context]:
"""
根据TMDB ID搜索资源精确匹配但不不过滤本地存在的资源
根据TMDBID/豆瓣ID搜索资源精确匹配但不不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param mtype: 媒体,电影 or 电视剧
:param area: 搜索范围title or imdbid
"""
mediainfo = self.recognize_media(tmdbid=tmdbid, mtype=mtype)
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
return []
@@ -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,
@@ -94,19 +95,29 @@ class SearchChain(ChainBase):
:param filter_rule: 过滤规则,为空是使用默认过滤规则
:param area: 搜索范围title or imdbid
"""
# 豆瓣标题处理
if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title)
mediainfo.title = meta.name
mediainfo.season = meta.begin_season
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id)
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
# 缺失的季集
if no_exists and no_exists.get(mediainfo.tmdb_id):
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
if no_exists and no_exists.get(mediakey):
# 过滤剧集
season_episodes = {sea: info.episodes
for sea, info in no_exists[mediainfo.tmdb_id].items()}
elif mediainfo.season:
# 豆瓣只搜索当前季
season_episodes = {mediainfo.season: []}
else:
season_episodes = None
# 搜索关键词
@@ -141,8 +152,9 @@ class SearchChain(ChainBase):
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} 没有符合过滤规则的资源')
@@ -156,6 +168,7 @@ class SearchChain(ChainBase):
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
@@ -202,7 +215,7 @@ class SearchChain(ChainBase):
continue
# 在副标题中判断是否存在标题与原语种标题
if torrent.description:
subtitle = torrent.description.split()
subtitle = re.split(r'[\s/|]+', torrent.description)
if (StringUtils.is_chinese(mediainfo.title)
and str(mediainfo.title) in subtitle) \
or (StringUtils.is_chinese(mediainfo.original_title)
@@ -325,23 +338,51 @@ class SearchChain(ChainBase):
def filter_torrents_by_rule(self,
torrents: List[TorrentInfo],
filter_rule: Dict[str, str] = None
mediainfo: MediaInfo,
filter_rule: Dict[str, str] = None,
) -> List[TorrentInfo]:
"""
使用过滤规则过滤种子
:param torrents: 种子列表
:param filter_rule: 过滤规则
:param mediainfo: 媒体信息
"""
# 取默认过滤规则
if not filter_rule:
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
# 没有则取搜索默认过滤规则
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:
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
return 0, 0
def __filter_torrent(t: TorrentInfo) -> bool:
"""
@@ -359,6 +400,54 @@ class SearchChain(ChainBase):
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
# 使用默认过滤规则再次过滤

View File

@@ -1,8 +1,6 @@
import re
from typing import Union, Tuple
from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.core.config import settings
from app.db.models.site import Site
@@ -23,9 +21,9 @@ class SiteChain(ChainBase):
站点管理处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
self.siteoper = SiteOper(self._db)
def __init__(self):
super().__init__()
self.siteoper = SiteOper()
self.cookiehelper = CookieHelper()
self.message = MessageHelper()

View File

@@ -1,14 +1,16 @@
import json
import random
import re
import time
from datetime import datetime
from typing import Dict, List, Optional, Union, Tuple
from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.chain.torrents import TorrentsChain
from app.core.config import settings
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
@@ -26,12 +28,13 @@ class SubscribeChain(ChainBase):
订阅管理处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
self.downloadchain = DownloadChain(self._db)
self.searchchain = SearchChain(self._db)
self.subscribeoper = SubscribeOper(self._db)
def __init__(self):
super().__init__()
self.downloadchain = DownloadChain()
self.searchchain = SearchChain()
self.subscribeoper = SubscribeOper()
self.torrentschain = TorrentsChain()
self.mediachain = MediaChain()
self.message = MessageHelper()
self.systemconfig = SystemConfigOper()
@@ -50,7 +53,7 @@ class SubscribeChain(ChainBase):
识别媒体信息并添加订阅
"""
logger.info(f'开始添加订阅,标题:{title} ...')
# 识别元数据
mediainfo = None
metainfo = MetaInfo(title)
if year:
metainfo.year = year
@@ -60,12 +63,29 @@ class SubscribeChain(ChainBase):
metainfo.type = MediaType.TV
metainfo.begin_season = season
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
if settings.RECOGNIZE_SOURCE == "themoviedb":
# TMDB识别模式
if not tmdbid and doubanid:
# 将豆瓣信息转换为TMDB信息
tmdbinfo = self.mediachain.get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
else:
# 识别TMDB信息
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
else:
# 豆瓣识别模式
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid)
if mediainfo:
# 豆瓣标题处理
meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name
if not season:
season = meta.begin_season
# 识别失败
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{title}tmdbid{tmdbid}')
logger.warn(f'未识别到媒体信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}')
return None, "未识别到媒体信息"
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
# 总集数
if mediainfo.type == MediaType.TV:
if not season:
@@ -74,18 +94,19 @@ class SubscribeChain(ChainBase):
if not kwargs.get('total_episode'):
if not mediainfo.seasons:
# 补充媒体信息
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id)
mediainfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
return None, "媒体信息识别失败"
if not mediainfo.seasons:
logger.error(f"媒体信息中没有季集信息,标题:{title}tmdbid{tmdbid}")
logger.error(f"媒体信息中没有季集信息,标题:{title}tmdbid{tmdbid}doubanid{doubanid}")
return None, "媒体信息中没有季集信息"
total_episode = len(mediainfo.seasons.get(season) or [])
if not total_episode:
logger.error(f'未获取到总集数,标题:{title}tmdbid{tmdbid}')
return None, "未获取到总集数"
logger.error(f'未获取到总集数,标题:{title}tmdbid{tmdbid}, doubanid{doubanid}')
return None, f"未获取到{season} 季的总集数"
kwargs.update({
'total_episode': total_episode
})
@@ -94,9 +115,13 @@ class SubscribeChain(ChainBase):
kwargs.update({
'lack_episode': kwargs.get('total_episode')
})
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
# 合并信息
if doubanid:
mediainfo.douban_id = doubanid
# 添加订阅
sid, err_msg = self.subscribeoper.add(mediainfo, doubanid=doubanid,
season=season, username=username, **kwargs)
sid, err_msg = self.subscribeoper.add(mediainfo, season=season, username=username, **kwargs)
if not sid:
logger.error(f'{mediainfo.title_year} {err_msg}')
if not exist_ok and message:
@@ -128,6 +153,7 @@ class SubscribeChain(ChainBase):
判断订阅是否已存在
"""
if self.subscribeoper.exists(tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=meta.begin_season if meta else None):
return True
return False
@@ -146,6 +172,7 @@ class SubscribeChain(ChainBase):
subscribes = self.subscribeoper.list(state)
# 遍历订阅
for subscribe in subscribes:
mediakey = subscribe.tmdbid or subscribe.doubanid
# 校验当前时间减订阅创建时间是否大于1分钟否则跳过先留出编辑订阅的时间
if subscribe.date:
now = datetime.now()
@@ -153,6 +180,11 @@ class SubscribeChain(ChainBase):
if (now - subscribe_time).total_seconds() < 60:
logger.debug(f"订阅标题:{subscribe.name} 新增小于1分钟暂不搜索...")
continue
# 随机休眠1-5分钟
if not sid and state == 'R':
sleep_time = random.randint(60, 300)
logger.info(f'订阅搜索随机休眠 {sleep_time} 秒 ...')
time.sleep(sleep_time)
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
# 如果状态为N则更新为R
if subscribe.state == 'N':
@@ -163,9 +195,12 @@ class SubscribeChain(ChainBase):
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}')
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue
# 非洗版状态
@@ -176,66 +211,66 @@ class SubscribeChain(ChainBase):
totals = {
subscribe.season: subscribe.total_episode
}
# 查询缺失的媒体信息
# 查询媒体库缺失的媒体信息
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
meta=meta,
mediainfo=mediainfo,
totals=totals
)
if exist_flag:
logger.info(f'{mediainfo.title_year} 媒体库中已存在,完成订阅')
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
image=mediainfo.get_message_image()))
continue
# 电视剧订阅
if meta.type == MediaType.TV:
# 使用订阅的总集数和开始集数替换no_exists
no_exists = self.__get_subscribe_no_exits(
no_exists=no_exists,
tmdb_id=mediainfo.tmdb_id,
begin_season=meta.begin_season,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode,
)
# 打印缺失集信息
if no_exists and no_exists.get(subscribe.tmdbid):
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
if no_exists_info:
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
else:
# 洗版状态
exist_flag = False
if meta.type == MediaType.TV:
no_exists = {
subscribe.season: NotExistMediaInfo(
season=subscribe.season,
episodes=[],
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode or 1)
mediakey: {
subscribe.season: NotExistMediaInfo(
season=subscribe.season,
episodes=[],
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode or 1)
}
}
else:
no_exists = {}
# 已存在
if exist_flag:
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
continue
# 电视剧订阅处理缺失集
if meta.type == MediaType.TV:
# 实际缺失集与订阅开始结束集范围进行整合
no_exists = self.__get_subscribe_no_exits(
no_exists=no_exists,
mediakey=mediakey,
begin_season=meta.begin_season,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode,
)
# 打印汇总缺失集信息
if no_exists and no_exists.get(mediakey):
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
# 站点范围
if subscribe.sites:
sites = json.loads(subscribe.sites)
else:
sites = None
# 优先级过滤规则
if subscribe.best_version:
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
else:
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
# 默认过滤规则
if subscribe.include or subscribe.exclude:
filter_rule = {
"include": subscribe.include,
"exclude": subscribe.exclude
}
else:
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
# 过滤规则
filter_rule = self.get_filter_rule(subscribe)
# 搜索,同时电视剧会过滤掉不需要的剧集
contexts = self.searchchain.process(mediainfo=mediainfo,
keyword=subscribe.keyword,
@@ -245,11 +280,11 @@ class SubscribeChain(ChainBase):
filter_rule=filter_rule)
if not contexts:
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
if meta.type == MediaType.TV:
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, lefts=no_exists)
continue
# 过滤
# 过滤搜索结果
matched_contexts = []
for context in contexts:
torrent_meta = context.meta_info
@@ -268,39 +303,31 @@ class SubscribeChain(ChainBase):
if torrent_meta.episode_list:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 优先级小于已下载优先级的不要
# 洗版时,优先级小于已下载优先级的不要
if subscribe.current_priority \
and torrent_info.pri_order < subscribe.current_priority:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
continue
matched_contexts.append(context)
if not matched_contexts:
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
# 非洗版未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
if meta.type == MediaType.TV and not subscribe.best_version:
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
continue
# 自动下载
downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts,
no_exists=no_exists)
# 更新已经下载的集数
if downloads \
and meta.type == MediaType.TV \
and not subscribe.best_version:
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
if downloads and not lefts:
# 判断是否应完成订阅
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, downloads=downloads)
else:
# 未完成下载
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
if meta.type == MediaType.TV and not subscribe.best_version:
# 更新订阅剩余集数和时间
update_date = True if downloads else False
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
mediainfo=mediainfo, update_date=update_date)
mediainfo=mediainfo, lefts=no_exists)
continue
# 自动下载
downloads, lefts = self.downloadchain.batch_download(
contexts=matched_contexts,
no_exists=no_exists,
username=subscribe.username,
save_path=subscribe.save_path
)
# 判断是否应完成订阅
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
downloads=downloads, lefts=lefts)
# 手动触发时发送系统消息
if manual:
if sid:
@@ -308,35 +335,74 @@ class SubscribeChain(ChainBase):
else:
self.message.put('所有订阅搜索完成!')
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo,
mediainfo: MediaInfo, downloads: List[Context]):
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
mediainfo: MediaInfo, downloads: List[Context]):
"""
判断是否应完成订阅
更新订阅已下载资源的优先级
"""
if not downloads:
return
if not subscribe.best_version:
# 全部下载完成
logger.info(f'{mediainfo.title_year} 下载完成,完成订阅')
return
# 当前下载资源的优先级
priority = max([item.torrent_info.pri_order for item in downloads])
if priority == 100:
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
title=f'{mediainfo.title_year} {meta.season}洗版完成',
image=mediainfo.get_message_image()))
else:
# 当前下载资源优先级
priority = max([item.torrent_info.pri_order for item in downloads])
if priority == 100:
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
# 正在洗版,更新资源优先级
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
self.subscribeoper.update(subscribe.id, {
"current_priority": priority
})
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo, mediainfo: MediaInfo,
downloads: List[Context] = None,
lefts: Dict[Union[int | str], Dict[int, NotExistMediaInfo]] = None,
force: bool = False):
"""
判断是否应完成订阅
"""
mediakey = subscribe.tmdbid or subscribe.doubanid
# 是否有剩余集
no_lefts = not lefts or not lefts.get(mediakey)
# 是否完成订阅
if not subscribe.best_version:
# 非洗板
if ((no_lefts and meta.type == MediaType.TV)
or (downloads and meta.type == MediaType.MOVIE)
or force):
# 全部下载完成
logger.info(f'{mediainfo.title_year} 完成订阅')
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season}洗版完成',
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
image=mediainfo.get_message_image()))
elif downloads and meta.type == MediaType.TV:
# 电视剧更新已下载集数
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
# 更新订阅剩余集数和时间
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
mediainfo=mediainfo, update_date=True)
else:
# 正在洗版,更新资源优先级
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级')
self.subscribeoper.update(subscribe.id, {
"current_priority": priority
})
# 未下载到内容且不完整
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
if meta.type == MediaType.TV:
# 更新订阅剩余集数
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
mediainfo=mediainfo, update_date=False)
elif downloads:
# 洗板,下载到了内容,更新资源优先级
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, downloads=downloads)
else:
# 洗版,未下载到内容
logger.info(f'{mediainfo.title_year} 继续洗版 ...')
def refresh(self):
"""
@@ -375,6 +441,67 @@ class SubscribeChain(ChainBase):
return ret_sites
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
def match(self, torrents: Dict[str, List[Context]]):
"""
从缓存中匹配订阅,并自动下载
@@ -387,15 +514,19 @@ class SubscribeChain(ChainBase):
# 遍历订阅
for subscribe in subscribes:
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
mediakey = subscribe.tmdbid or subscribe.doubanid
# 生成元数据
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}')
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue
# 非洗版
if not subscribe.best_version:
@@ -411,46 +542,48 @@ class SubscribeChain(ChainBase):
mediainfo=mediainfo,
totals=totals
)
if exist_flag:
logger.info(f'{mediainfo.title_year} 媒体库中已存在,完成订阅')
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
image=mediainfo.get_message_image()))
continue
# 电视剧订阅
if meta.type == MediaType.TV:
# 使用订阅的总集数和开始集数替换no_exists
no_exists = self.__get_subscribe_no_exits(
no_exists=no_exists,
tmdb_id=mediainfo.tmdb_id,
begin_season=meta.begin_season,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode,
)
# 打印缺失集信息
if no_exists and no_exists.get(subscribe.tmdbid):
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
if no_exists_info:
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
else:
# 洗版
exist_flag = False
if meta.type == MediaType.TV:
no_exists = {
subscribe.season: NotExistMediaInfo(
season=subscribe.season,
episodes=[],
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode or 1)
mediakey: {
subscribe.season: NotExistMediaInfo(
season=subscribe.season,
episodes=[],
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode or 1)
}
}
else:
no_exists = {}
# 默认过滤规则
default_filter = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
include = subscribe.include or default_filter.get("include")
exclude = subscribe.exclude or default_filter.get("exclude")
# 已存在
if exist_flag:
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
continue
# 电视剧订阅
if meta.type == MediaType.TV:
# 整合实际缺失集与订阅开始集结束集
no_exists = self.__get_subscribe_no_exits(
no_exists=no_exists,
mediakey=mediakey,
begin_season=meta.begin_season,
total_episode=subscribe.total_episode,
start_episode=subscribe.start_episode,
)
# 打印汇总缺失集信息
if no_exists and no_exists.get(mediakey):
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
# 过滤规则
filter_rule = self.get_filter_rule(subscribe)
# 遍历缓存种子
_match_context = []
for domain, contexts in torrents.items():
@@ -465,11 +598,11 @@ class SubscribeChain(ChainBase):
continue
# 优先级过滤规则
if subscribe.best_version:
filter_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
else:
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
result: List[TorrentInfo] = self.filter_torrents(
rule_string=filter_rule,
rule_string=priority_rule,
torrent_list=[torrent_info],
mediainfo=torrent_mediainfo)
if result is not None and not result:
@@ -499,9 +632,9 @@ class SubscribeChain(ChainBase):
# 非洗版
if not subscribe.best_version:
# 不是缺失的剧集不要
if no_exists and no_exists.get(subscribe.tmdbid):
if no_exists and no_exists.get(mediakey):
# 缺失集
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
if no_exists_info:
# 是否有交集
if no_exists_info.episodes and \
@@ -510,7 +643,8 @@ class SubscribeChain(ChainBase):
set(torrent_meta.episode_list)
):
logger.info(
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集')
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
)
continue
# 过滤掉已经下载的集数
if self.__check_subscribe_note(subscribe, torrent_meta.episode_list):
@@ -522,44 +656,39 @@ class SubscribeChain(ChainBase):
if torrent_meta.episode_list:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 包含
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}")
continue
# 排除
if exclude:
if re.search(r"%s" % exclude,
f"{torrent_info.title} {torrent_info.description}", re.I):
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
# 过滤规则
if not self.check_filter_rule(torrent_info=torrent_info,
filter_rule=filter_rule):
continue
# 洗版时,优先级小于已下载优先级的不要
if subscribe.best_version:
if subscribe.current_priority \
and torrent_info.pri_order < subscribe.current_priority:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
continue
# 匹配成功
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
_match_context.append(context)
# 开始下载
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
if _match_context:
# 批量择优下载
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context, no_exists=no_exists)
# 更新已经下载的集数
if downloads and meta.type == MediaType.TV:
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
if downloads and not lefts:
# 判断是否要完成订阅
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, downloads=downloads)
else:
if meta.type == MediaType.TV and not subscribe.best_version:
update_date = True if downloads else False
# 未完成下载,计算剩余集数
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
mediainfo=mediainfo, update_date=update_date)
else:
if meta.type == MediaType.TV:
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
if not _match_context:
# 未匹配到资源
logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源')
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
mediainfo=mediainfo, lefts=no_exists)
continue
# 开始批量择优下载
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context,
no_exists=no_exists,
username=subscribe.username,
save_path=subscribe.save_path)
# 判断是否要完成订阅
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
downloads=downloads, lefts=lefts)
def check(self):
"""
@@ -579,9 +708,11 @@ class SubscribeChain(ChainBase):
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, tmdbid=subscribe.tmdbid)
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}')
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue
# 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or []
@@ -623,7 +754,11 @@ class SubscribeChain(ChainBase):
mediainfo = context.media_info
if mediainfo.type != MediaType.TV:
continue
if mediainfo.tmdb_id != subscribe.tmdbid:
if subscribe.tmdbid and mediainfo.tmdb_id \
and mediainfo.tmdb_id != subscribe.tmdbid:
continue
if subscribe.doubanid and mediainfo.douban_id \
and mediainfo.douban_id != subscribe.doubanid:
continue
episodes = meta.episode_list
if not episodes:
@@ -649,33 +784,37 @@ class SubscribeChain(ChainBase):
return True
return False
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
subscribe: Subscribe,
mediainfo: MediaInfo,
update_date: bool = False):
"""
更新订阅剩余集数
"""
left_seasons = lefts.get(mediainfo.tmdb_id) or {}
for season_info in left_seasons.values():
season = season_info.season
if season == subscribe.season:
left_episodes = season_info.episodes
if not left_episodes:
lack_episode = season_info.total_episode
else:
lack_episode = len(left_episodes)
logger.info(f'{mediainfo.title_year}{season} 更新缺失集数为{lack_episode} ...')
if update_date:
# 同时更新最后时间
self.subscribeoper.update(subscribe.id, {
"lack_episode": lack_episode,
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
else:
self.subscribeoper.update(subscribe.id, {
"lack_episode": lack_episode
})
if not lefts:
return
mediakey = subscribe.tmdbid or subscribe.doubanid
left_seasons = lefts.get(mediakey)
if left_seasons:
for season_info in left_seasons.values():
season = season_info.season
if season == subscribe.season:
left_episodes = season_info.episodes
if not left_episodes:
lack_episode = season_info.total_episode
else:
lack_episode = len(left_episodes)
logger.info(f'{mediainfo.title_year}{season} 更新缺失集数为{lack_episode} ...')
if update_date:
# 同时更新最后时间
self.subscribeoper.update(subscribe.id, {
"lack_episode": lack_episode,
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
})
else:
self.subscribeoper.update(subscribe.id, {
"lack_episode": lack_episode
})
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
@@ -693,11 +832,17 @@ class SubscribeChain(ChainBase):
messages = []
for subscribe in subscribes:
if subscribe.type == MediaType.MOVIE.value:
tmdb_link = f"https://www.themoviedb.org/movie/{subscribe.tmdbid}"
messages.append(f"{subscribe.id}. [{subscribe.name}{subscribe.year}]({tmdb_link})")
if subscribe.tmdbid:
link = f"https://www.themoviedb.org/movie/{subscribe.tmdbid}"
else:
link = f"https://movie.douban.com/subject/{subscribe.doubanid}"
messages.append(f"{subscribe.id}. [{subscribe.name}{subscribe.year}]({link})")
else:
tmdb_link = f"https://www.themoviedb.org/tv/{subscribe.tmdbid}"
messages.append(f"{subscribe.id}. [{subscribe.name}{subscribe.year}]({tmdb_link}) "
if subscribe.tmdbid:
link = f"https://www.themoviedb.org/tv/{subscribe.tmdbid}"
else:
link = f"https://movie.douban.com/subject/{subscribe.doubanid}"
messages.append(f"{subscribe.id}. [{subscribe.name}{subscribe.year}]({link}) "
f"{subscribe.season}"
f"_{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
f"/{subscribe.total_episode}_")
@@ -731,25 +876,25 @@ class SubscribeChain(ChainBase):
self.remote_list(channel, userid)
@staticmethod
def __get_subscribe_no_exits(no_exists: Dict[int, Dict[int, NotExistMediaInfo]],
tmdb_id: int,
def __get_subscribe_no_exits(no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
mediakey: Union[str, int],
begin_season: int,
total_episode: int,
start_episode: int):
"""
根据订阅开始集数和总集数结合TMDB信息计算当前订阅的缺失集数
:param no_exists: 缺失季集列表
:param tmdb_id: TMDB ID
:param mediakey: TMDB ID或豆瓣ID
:param begin_season: 开始季
:param total_episode: 订阅设定总集数
:param start_episode: 订阅设定开始集数
"""
# 使用订阅的总集数和开始集数替换no_exists
if no_exists \
and no_exists.get(tmdb_id) \
and no_exists.get(mediakey) \
and (total_episode or start_episode):
# 该季原缺失信息
no_exist_season = no_exists.get(tmdb_id).get(begin_season)
no_exist_season = no_exists.get(mediakey).get(begin_season)
if no_exist_season:
# 原集列表
episode_list = no_exist_season.episodes
@@ -781,7 +926,7 @@ class SubscribeChain(ChainBase):
# 与原集列表取交集
episodes = list(set(episode_list).intersection(set(new_episodes)))
# 更新集合
no_exists[tmdb_id][begin_season] = NotExistMediaInfo(
no_exists[mediakey][begin_season] = NotExistMediaInfo(
season=begin_season,
episodes=episodes,
total_episode=total_episode,

View File

@@ -1,13 +1,23 @@
import json
import re
from typing import Union
from app.chain import ChainBase
from app.core.config import settings
from app.log import logger
from app.schemas import Notification, MessageChannel
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.system import SystemUtils
class SystemChain(ChainBase):
class SystemChain(ChainBase, metaclass=Singleton):
"""
系统级处理链
"""
_restart_file = "__system_restart__"
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str]):
"""
清理系统缓存
@@ -15,3 +25,95 @@ class SystemChain(ChainBase):
self.clear_cache()
self.post_message(Notification(channel=channel,
title=f"缓存清理完成!", userid=userid))
def restart(self, channel: MessageChannel, userid: Union[int, str]):
"""
重启系统
"""
if channel and userid:
self.post_message(Notification(channel=channel,
title="系统正在重启,请耐心等候!", userid=userid))
# 保存重启信息
self.save_cache({
"channel": channel.value,
"userid": userid
}, self._restart_file)
SystemUtils.restart()
def version(self, channel: MessageChannel, userid: Union[int, str]):
"""
查看当前版本、远程版本
"""
release_version = self.__get_release_version()
local_version = self.get_local_version()
if release_version == local_version:
title = f"当前版本:{local_version},已是最新版本"
else:
title = f"当前版本:{local_version},远程版本:{release_version}"
self.post_message(Notification(channel=channel,
title=title, userid=userid))
def restart_finish(self):
"""
如通过交互命令重启,
重启完发送msg
"""
# 重启消息
restart_channel = self.load_cache(self._restart_file)
if restart_channel:
# 发送重启完成msg
if not isinstance(restart_channel, dict):
restart_channel = json.loads(restart_channel)
channel = next(
(channel for channel in MessageChannel.__members__.values() if
channel.value == restart_channel.get('channel')), None)
userid = restart_channel.get('userid')
# 版本号
release_version = self.__get_release_version()
local_version = self.get_local_version()
if release_version == local_version:
title = f"当前版本:{local_version}"
else:
title = f"当前版本:{local_version},远程版本:{release_version}"
self.post_message(Notification(channel=channel,
title=f"系统已重启完成!{title}",
userid=userid))
self.remove_cache(self._restart_file)
@staticmethod
def __get_release_version():
"""
获取最新版本
"""
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
@staticmethod
def get_local_version():
"""
查看当前版本
"""
version_file = settings.ROOT_PATH / "version.py"
if version_file.exists():
try:
with open(version_file, 'rb') as f:
version = f.read()
pattern = r"'([^']*)'"
match = re.search(pattern, str(version))
if match:
version = match.group(1)
return version
else:
logger.warn("未找到版本号")
return None
except Exception as err:
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")

View File

@@ -12,7 +12,7 @@ from app.utils.singleton import Singleton
class TmdbChain(ChainBase, metaclass=Singleton):
"""
TheMovieDB处理链
TheMovieDB处理链,单例运行
"""
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
@@ -25,17 +25,21 @@ class TmdbChain(ChainBase, metaclass=Singleton):
:param page: 页码
:return: 媒体信息列表
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
return None
return self.run_module("tmdb_discover", mtype=mtype,
sort_by=sort_by, with_genres=with_genres,
with_original_language=with_original_language,
page=page)
def tmdb_trending(self, page: int = 1) -> List[dict]:
def tmdb_trending(self, page: int = 1) -> Optional[List[dict]]:
"""
TMDB流行趋势
:param page: 第几页
:return: TMDB信息列表
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
return None
return self.run_module("tmdb_trending", page=page)
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
@@ -58,28 +62,28 @@ class TmdbChain(ChainBase, metaclass=Singleton):
根据TMDBID查询类似电影
:param tmdbid: TMDBID
"""
return self.run_module("movie_similar", tmdbid=tmdbid)
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
def tv_similar(self, tmdbid: int) -> List[dict]:
"""
根据TMDBID查询类似电视剧
:param tmdbid: TMDBID
"""
return self.run_module("tv_similar", tmdbid=tmdbid)
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
def movie_recommend(self, tmdbid: int) -> List[dict]:
"""
根据TMDBID查询推荐电影
:param tmdbid: TMDBID
"""
return self.run_module("movie_recommend", tmdbid=tmdbid)
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
def tv_recommend(self, tmdbid: int) -> List[dict]:
"""
根据TMDBID查询推荐电视剧
:param tmdbid: TMDBID
"""
return self.run_module("tv_recommend", tmdbid=tmdbid)
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
"""
@@ -87,7 +91,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
:param tmdbid: TMDBID
:param page: 页码
"""
return self.run_module("movie_credits", tmdbid=tmdbid, page=page)
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
"""
@@ -95,14 +99,14 @@ class TmdbChain(ChainBase, metaclass=Singleton):
:param tmdbid: TMDBID
:param page: 页码
"""
return self.run_module("tv_credits", tmdbid=tmdbid, page=page)
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
def person_detail(self, person_id: int) -> dict:
"""
根据TMDBID查询演职员详情
:param person_id: 人物ID
"""
return self.run_module("person_detail", person_id=person_id)
return self.run_module("tmdb_person_detail", person_id=person_id)
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
"""
@@ -110,7 +114,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
:param person_id: 人物ID
:param page: 页码
"""
return self.run_module("person_credits", person_id=person_id, page=page)
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
@cached(cache=TTLCache(maxsize=1, ttl=3600))
def get_random_wallpager(self):

View File

@@ -1,17 +1,19 @@
import re
import traceback
from typing import Dict, List, Union
from cachetools import cached, TTLCache
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.core.config import settings
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.metainfo import MetaInfo
from app.db import SessionFactory
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.rss import RssHelper
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
@@ -28,12 +30,13 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
_rss_file = "__rss_cache__"
def __init__(self):
self._db = SessionFactory()
super().__init__(self._db)
super().__init__()
self.siteshelper = SitesHelper()
self.siteoper = SiteOper(self._db)
self.siteoper = SiteOper()
self.rsshelper = RssHelper()
self.systemconfig = SystemConfigOper()
self.mediachain = MediaChain()
self.torrenthelper = TorrentHelper()
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
@@ -60,7 +63,16 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
return self.load_cache(self._rss_file) or {}
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=600))
def clear_torrents(self):
"""
清理种子缓存数据
"""
logger.info(f'开始清理种子缓存数据 ...')
self.remove_cache(self._spider_file)
self.remove_cache(self._rss_file)
logger.info(f'种子缓存数据清理完成')
@cached(cache=TTLCache(maxsize=128, ttl=595))
def browse(self, domain: str) -> List[TorrentInfo]:
"""
浏览站点首页内容返回种子清单TTL缓存10分钟
@@ -73,7 +85,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
return []
return self.refresh_torrents(site=site)
@cached(cache=TTLCache(maxsize=128 if settings.BIG_MEMORY_MODE else 1, ttl=300))
@cached(cache=TTLCache(maxsize=128, ttl=295))
def rss(self, domain: str) -> List[TorrentInfo]:
"""
获取站点RSS内容返回种子清单TTL缓存5分钟
@@ -134,6 +146,11 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 读取缓存
torrents_cache = self.get_torrents()
# 缓存过滤掉无效种子
for _domain, _torrents in torrents_cache.items():
torrents_cache[_domain] = [_torrent for _torrent in _torrents
if not self.torrenthelper.is_invalid(_torrent.torrent_info.enclosure)]
# 所有站点索引
indexers = self.siteshelper.get_indexers()
# 遍历站点缓存资源
@@ -168,7 +185,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 识别
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta)
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{torrent.title}')
# 存储空的媒体信息
@@ -230,5 +247,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

@@ -1,12 +1,9 @@
import glob
import re
import shutil
import threading
from pathlib import Path
from typing import List, Optional, Tuple, Union, Dict
from sqlalchemy.orm import Session
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.chain.tmdb import TmdbChain
@@ -36,13 +33,13 @@ class TransferChain(ChainBase):
文件转移处理链
"""
def __init__(self, db: Session = None):
super().__init__(db)
self.downloadhis = DownloadHistoryOper(self._db)
self.transferhis = TransferHistoryOper(self._db)
def __init__(self):
super().__init__()
self.downloadhis = DownloadHistoryOper()
self.transferhis = TransferHistoryOper()
self.progress = ProgressHelper()
self.mediachain = MediaChain(self._db)
self.tmdbchain = TmdbChain(self._db)
self.mediachain = MediaChain()
self.tmdbchain = TmdbChain()
self.systemconfig = SystemConfigOper()
def process(self) -> bool:
@@ -69,7 +66,8 @@ class TransferChain(ChainBase):
mtype = MediaType(downloadhis.type)
# 按TMDBID识别
mediainfo = self.recognize_media(mtype=mtype,
tmdbid=downloadhis.tmdbid)
tmdbid=downloadhis.tmdbid,
doubanid=downloadhis.doubanid)
else:
# 非MoviePilot下载的任务按文件识别
mediainfo = None
@@ -237,7 +235,7 @@ class TransferChain(ChainBase):
# 自定义识别
if formaterHandler:
# 开始集、结束集、PART
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.stem)
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.name)
if begin_ep is not None:
file_meta.begin_episode = begin_ep
file_meta.part = part
@@ -246,7 +244,7 @@ class TransferChain(ChainBase):
if not mediainfo:
# 识别媒体信息
file_mediainfo = self.recognize_media(meta=file_meta)
file_mediainfo = self.mediachain.recognize_by_meta(file_meta)
else:
file_mediainfo = mediainfo
@@ -261,8 +259,8 @@ class TransferChain(ChainBase):
)
self.post_message(Notification(
mtype=NotificationType.Manual,
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
title=f"{file_path.name} 未识别到媒体信息,无法入库!",
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
))
# 计数
processed_num += 1
@@ -278,9 +276,6 @@ class TransferChain(ChainBase):
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
# 更新媒体图片
self.obtain_images(mediainfo=file_mediainfo)
# 获取集数据
if file_mediainfo.type == MediaType.TV:
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
@@ -358,7 +353,9 @@ class TransferChain(ChainBase):
)
# 刮削单个文件
if settings.SCRAP_METADATA:
self.scrape_metadata(path=transferinfo.target_path, mediainfo=file_mediainfo)
self.scrape_metadata(path=transferinfo.target_path,
mediainfo=file_mediainfo,
transfer_type=transfer_type)
# 更新进度
processed_num += 1
self.progress.update(value=processed_num / total_num * 100,
@@ -376,9 +373,6 @@ class TransferChain(ChainBase):
# 媒体目录
if transfer_info.target_path.is_file():
transfer_info.target_path = transfer_info.target_path.parent
# 刷新媒体库,根目录或季目录
if settings.REFRESH_MEDIASERVER:
self.refresh_mediaserver(mediainfo=media, file_path=transfer_info.target_path)
# 发送通知
se_str = None
if media.type == MediaType.TV:
@@ -453,7 +447,7 @@ class TransferChain(ChainBase):
def args_error():
self.post_message(Notification(channel=channel,
title="请输入正确的命令格式:/redo [id] [tmdbid]|[类型]"
title="请输入正确的命令格式:/redo [id] [tmdbid/豆瓣id]|[类型]"
"[id]历史记录编号", userid=userid))
if not arg_str:
@@ -468,54 +462,74 @@ class TransferChain(ChainBase):
if not logid.isdigit():
args_error()
return
# TMDB ID
tmdb_strs = arg_strs[1].split('|')
tmdbid = tmdb_strs[0]
# TMDBID/豆瓣ID
id_strs = arg_strs[1].split('|')
media_id = id_strs[0]
if not logid.isdigit():
args_error()
return
# 类型
type_str = tmdb_strs[1] if len(tmdb_strs) > 1 else None
type_str = id_strs[1] if len(id_strs) > 1 else None
if not type_str or type_str not in [MediaType.MOVIE.value, MediaType.TV.value]:
args_error()
return
state, errmsg = self.re_transfer(logid=int(logid),
mtype=MediaType(type_str), tmdbid=int(tmdbid))
mtype=MediaType(type_str),
mediaid=media_id)
if not state:
self.post_message(Notification(channel=channel, title="手动整理失败",
text=errmsg, userid=userid))
return
def re_transfer(self, logid: int,
mtype: MediaType = None, tmdbid: int = None) -> Tuple[bool, str]:
@staticmethod
def get_root_path(path: str, type_name: str, category: str) -> Path:
"""
根据历史记录重新识别转移只处理对应的src目录
计算媒体库目录的根路径
"""
if not path or path == "None":
return None
index = -2
if type_name != '电影':
index = -3
if category:
index -= 1
if '/' in path:
retpath = '/'.join(path.split('/')[:index])
else:
retpath = '\\'.join(path.split('\\')[:index])
return Path(retpath)
def re_transfer(self, logid: int, mtype: MediaType = None,
mediaid: str = None) -> Tuple[bool, str]:
"""
根据历史记录,重新识别转移,只支持简单条件
:param logid: 历史记录ID
:param mtype: 媒体类型
:param tmdbid: TMDB ID
:param mediaid: TMDB ID/豆瓣ID
"""
# 查询历史记录
history: TransferHistory = self.transferhis.get(logid)
if not history:
logger.error(f"历史记录不存在ID{logid}")
return False, "历史记录不存在"
# 没有下载记录,按源目录路径重新转移
# 按源目录路径重新转移
src_path = Path(history.src)
if not src_path.exists():
return False, f"源目录不存在:{src_path}"
dest_path = Path(history.dest) if history.dest else None
dest_path = self.get_root_path(path=history.dest, type_name=history.type, category=history.category)
# 查询媒体信息
if mtype and tmdbid:
mediainfo = self.recognize_media(mtype=mtype, tmdbid=tmdbid)
if mtype and mediaid:
mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
doubanid=mediaid)
if mediainfo:
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
else:
meta = MetaInfoPath(src_path)
mediainfo = self.recognize_media(meta=meta)
mediainfo = self.mediachain.recognize_by_path(str(src_path))
if not mediainfo:
return False, f"未识别到媒体信息,类型:{mtype.value}tmdbid{tmdbid}"
return False, f"未识别到媒体信息,类型:{mtype.value}id{mediaid}"
# 重新执行转移
logger.info(f"{src_path.name} 识别为:{mediainfo.title_year}")
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
# 删除旧的已整理文件
if history.dest:
@@ -539,9 +553,10 @@ class TransferChain(ChainBase):
season: int = None,
transfer_type: str = None,
epformat: EpisodeFormat = None,
min_filesize: int = 0) -> Tuple[bool, Union[str, list]]:
min_filesize: int = 0,
force: bool = False) -> Tuple[bool, Union[str, list]]:
"""
手动转移
手动转移,支持复杂条件,带进度显示
:param in_path: 源文件路径
:param target: 目标路径
:param tmdbid: TMDB ID
@@ -550,6 +565,7 @@ class TransferChain(ChainBase):
:param transfer_type: 转移类型
:param epformat: 剧集格式
:param min_filesize: 最小文件大小(MB)
:param force: 是否强制转移
"""
logger.info(f"手动转移:{in_path} ...")
@@ -569,9 +585,11 @@ class TransferChain(ChainBase):
path=in_path,
mediainfo=mediainfo,
target=target,
transfer_type=transfer_type,
season=season,
epformat=epformat,
min_filesize=min_filesize
min_filesize=min_filesize,
force=force
)
if not state:
return False, errmsg
@@ -586,7 +604,8 @@ class TransferChain(ChainBase):
transfer_type=transfer_type,
season=season,
epformat=epformat,
min_filesize=min_filesize)
min_filesize=min_filesize,
force=force)
return state, errmsg
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
@@ -613,14 +632,15 @@ class TransferChain(ChainBase):
title=msg_title, text=msg_str, image=mediainfo.get_message_image()))
@staticmethod
def delete_files(path: Path):
def delete_files(path: Path) -> Tuple[bool, str]:
"""
删除转移后的文件以及空目录
:param path: 文件路径
:return: 成功标识,错误信息
"""
logger.info(f"开始删除文件以及空目录:{path} ...")
if not path.exists():
return
return True, f"文件或目录不存在:{path}"
if path.is_file():
# 删除文件、nfo、jpg等同名文件
pattern = path.stem.replace('[', '?').replace(']', '?')
@@ -632,7 +652,7 @@ class TransferChain(ChainBase):
elif str(path.parent) == str(path.root):
# 根目录,不删除
logger.warn(f"根目录 {path} 不能删除!")
return
return False, f"根目录 {path} 不能删除!"
else:
# 非根目录,才删除目录
shutil.rmtree(path)
@@ -658,5 +678,10 @@ class TransferChain(ChainBase):
# 父目录非根目录,才删除父目录
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
# 当前路径下没有媒体文件则删除
shutil.rmtree(parent_path)
try:
shutil.rmtree(parent_path)
except Exception as e:
logger.error(f"删除目录 {parent_path} 失败:{str(e)}")
return False, f"删除目录 {parent_path} 失败:{str(e)}"
logger.warn(f"目录 {parent_path} 已删除")
return True, ""

View File

@@ -1,20 +1,18 @@
import time
from typing import Any
from app.chain import ChainBase
from app.schemas import Notification
from app.schemas.types import EventType, MediaImageType, MediaType, NotificationType
from app.utils.web import WebUtils
from app.schemas.types import EventType
from app.utils.singleton import Singleton
class WebhookChain(ChainBase):
class WebhookChain(ChainBase, metaclass=Singleton):
"""
Webhook处理链
"""
def message(self, body: Any, form: Any, args: Any) -> None:
"""
处理Webhook报文并发送消息
处理Webhook报文并发送事件
"""
# 获取主体内容
event_info = self.webhook_parser(body=body, form=form, args=args)
@@ -22,76 +20,3 @@ class WebhookChain(ChainBase):
return
# 广播事件
self.eventmanager.send_event(EventType.WebhookMessage, event_info)
# 拼装消息内容
_webhook_actions = {
"library.new": "新入库",
"system.webhooktest": "测试",
"playback.start": "开始播放",
"playback.stop": "停止播放",
"user.authenticated": "登录成功",
"user.authenticationfailed": "登录失败",
"media.play": "开始播放",
"media.stop": "停止播放",
"PlaybackStart": "开始播放",
"PlaybackStop": "停止播放",
"item.rate": "标记了"
}
_webhook_images = {
"emby": "https://emby.media/notificationicon.png",
"plex": "https://www.plex.tv/wp-content/uploads/2022/04/new-logo-process-lines-gray.png",
"jellyfin": "https://play-lh.googleusercontent.com/SCsUK3hCCRqkJbmLDctNYCfehLxsS4ggD1ZPHIFrrAN1Tn9yhjmGMPep2D9lMaaa9eQi"
}
if not _webhook_actions.get(event_info.event):
return
# 消息标题
if event_info.item_type in ["TV", "SHOW"]:
message_title = f"{_webhook_actions.get(event_info.event)}剧集 {event_info.item_name}"
elif event_info.item_type == "MOV":
message_title = f"{_webhook_actions.get(event_info.event)}电影 {event_info.item_name}"
elif event_info.item_type == "AUD":
message_title = f"{_webhook_actions.get(event_info.event)}有声书 {event_info.item_name}"
else:
message_title = f"{_webhook_actions.get(event_info.event)}"
# 消息内容
message_texts = []
if event_info.user_name:
message_texts.append(f"用户:{event_info.user_name}")
if event_info.device_name:
message_texts.append(f"设备:{event_info.client} {event_info.device_name}")
if event_info.ip:
message_texts.append(f"IP地址{event_info.ip} {WebUtils.get_location(event_info.ip)}")
if event_info.percentage:
percentage = round(float(event_info.percentage), 2)
message_texts.append(f"进度:{percentage}%")
if event_info.overview:
message_texts.append(f"剧情:{event_info.overview}")
message_texts.append(f"时间:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}")
# 消息内容
message_content = "\n".join(message_texts)
# 消息图片
image_url = event_info.image_url
# 查询剧集图片
if (event_info.tmdb_id
and event_info.season_id
and event_info.episode_id):
specific_image = self.obtain_specific_image(
mediaid=event_info.tmdb_id,
mtype=MediaType.TV,
image_type=MediaImageType.Backdrop,
season=event_info.season_id,
episode=event_info.episode_id
)
if specific_image:
image_url = specific_image
# 使用默认图片
if not image_url:
image_url = _webhook_images.get(event_info.channel)
# 发送消息
self.post_message(Notification(mtype=NotificationType.MediaServer,
title=message_title, text=message_content, image=image_url))

View File

@@ -1,5 +1,7 @@
import importlib
import threading
import traceback
from threading import Thread, Event
from threading import Thread
from typing import Any, Union, Dict
from app.chain import ChainBase
@@ -11,14 +13,13 @@ from app.chain.transfer import TransferChain
from app.core.event import Event as ManagerEvent
from app.core.event import eventmanager, EventManager
from app.core.plugin import PluginManager
from app.db import SessionFactory
from app.helper.thread import ThreadHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas import Notification
from app.schemas.types import EventType, MessageChannel
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
from app.utils.system import SystemUtils
class CommandChian(ChainBase):
@@ -38,19 +39,19 @@ class Command(metaclass=Singleton):
_commands = {}
# 退出事件
_event = Event()
_event = threading.Event()
def __init__(self):
# 数据库连接
self._db = SessionFactory()
# 事件管理器
self.eventmanager = EventManager()
# 插件管理器
self.pluginmanager = PluginManager()
# 处理链
self.chain = CommandChian(self._db)
self.chain = CommandChian()
# 定时服务管理
self.scheduler = Scheduler()
# 线程管理器
self.threader = ThreadHelper()
# 内置命令
self._commands = {
"/cookiecloud": {
@@ -60,23 +61,23 @@ class Command(metaclass=Singleton):
"category": "站点"
},
"/sites": {
"func": SiteChain(self._db).remote_list,
"func": SiteChain().remote_list,
"description": "查询站点",
"category": "站点",
"data": {}
},
"/site_cookie": {
"func": SiteChain(self._db).remote_cookie,
"func": SiteChain().remote_cookie,
"description": "更新站点Cookie",
"data": {}
},
"/site_enable": {
"func": SiteChain(self._db).remote_enable,
"func": SiteChain().remote_enable,
"description": "启用站点",
"data": {}
},
"/site_disable": {
"func": SiteChain(self._db).remote_disable,
"func": SiteChain().remote_disable,
"description": "禁用站点",
"data": {}
},
@@ -87,7 +88,7 @@ class Command(metaclass=Singleton):
"category": "管理"
},
"/subscribes": {
"func": SubscribeChain(self._db).remote_list,
"func": SubscribeChain().remote_list,
"description": "查询订阅",
"category": "订阅",
"data": {}
@@ -105,7 +106,7 @@ class Command(metaclass=Singleton):
"category": "订阅"
},
"/subscribe_delete": {
"func": SubscribeChain(self._db).remote_delete,
"func": SubscribeChain().remote_delete,
"description": "删除订阅",
"data": {}
},
@@ -115,7 +116,7 @@ class Command(metaclass=Singleton):
"description": "订阅元数据更新"
},
"/downloading": {
"func": DownloadChain(self._db).remote_downloading,
"func": DownloadChain().remote_downloading,
"description": "正在下载",
"category": "管理",
"data": {}
@@ -127,21 +128,27 @@ class Command(metaclass=Singleton):
"category": "管理"
},
"/redo": {
"func": TransferChain(self._db).remote_transfer,
"func": TransferChain().remote_transfer,
"description": "手动整理",
"data": {}
},
"/clear_cache": {
"func": SystemChain(self._db).remote_clear_cache,
"func": SystemChain().remote_clear_cache,
"description": "清理缓存",
"category": "管理",
"data": {}
},
"/restart": {
"func": SystemUtils.restart,
"func": SystemChain().restart,
"description": "重启系统",
"category": "管理",
"data": {}
},
"/version": {
"func": SystemChain().version,
"description": "当前版本",
"category": "管理",
"data": {}
}
}
# 汇总插件命令
@@ -163,6 +170,8 @@ class Command(metaclass=Singleton):
self._thread = Thread(target=self.__run)
# 启动事件处理线程
self._thread.start()
# 重启msg
SystemChain().restart_finish()
def __run(self):
"""
@@ -175,10 +184,31 @@ class Command(metaclass=Singleton):
for handler in handlers:
try:
names = handler.__qualname__.split(".")
if names[0] == "Command":
self.command_event(event)
[class_name, method_name] = names
if class_name in self.pluginmanager.get_plugin_ids():
# 插件事件
self.threader.submit(
self.pluginmanager.run_plugin_method,
class_name, method_name, event
)
else:
self.pluginmanager.run_plugin_method(names[0], names[1], event)
# 检查全局变量中是否存在
if class_name not in globals():
# 导入模块除了插件和Command本身只有chain能响应事件
module = importlib.import_module(
f"app.chain.{class_name[:-5].lower()}"
)
class_obj = getattr(module, class_name)()
else:
# 通过类名创建类实例
class_obj = globals()[class_name]()
# 检查类是否存在并调用方法
if hasattr(class_obj, method_name):
self.threader.submit(
getattr(class_obj, method_name),
event
)
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
@@ -238,8 +268,6 @@ class Command(metaclass=Singleton):
"""
self._event.set()
self._thread.join()
if self._db:
self._db.close()
def get_commands(self):
"""
@@ -287,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

@@ -1,8 +1,7 @@
import os
import secrets
import sys
from pathlib import Path
from typing import List
from typing import List, Optional
from pydantic import BaseSettings
@@ -26,6 +25,8 @@ class Settings(BaseSettings):
HOST: str = "0.0.0.0"
# API监听端口
PORT: int = 3001
# 前端监听端口
NGINX_PORT: int = 3000
# 是否调试模式
DEBUG: bool = False
# 是否开发模式
@@ -34,20 +35,20 @@ class Settings(BaseSettings):
CONFIG_DIR: str = None
# 超级管理员
SUPERUSER: str = "admin"
# 超级管理员初始密码
SUPERUSER_PASSWORD: str = "password"
# API密钥需要更换
API_TOKEN: str = "moviepilot"
# 登录页面电影海报,tmdb/bing
WALLPAPER: str = "tmdb"
# 网络代理 IP:PORT
PROXY_HOST: str = None
# 媒体信息搜索来源
SEARCH_SOURCE: str = "themoviedb"
# 媒体识别来源 themoviedb/douban
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
SCRAP_SOURCE: str = "themoviedb"
# 刮削入库的媒体文件
SCRAP_METADATA: bool = True
# 新增已入库媒体是否跟随TMDB信息变化
SCRAP_FOLLOW_TMDB: bool = True
# 刮削来源
SCRAP_SOURCE: str = "themoviedb"
# TMDB图片地址
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
# TMDB API地址
@@ -56,6 +57,8 @@ class Settings(BaseSettings):
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
# TVDB API Key
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
# Fanart开关
FANART_ENABLE: bool = True
# Fanart API Key
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
# 支持的后缀格式
@@ -126,6 +129,10 @@ class Settings(BaseSettings):
QB_PASSWORD: str = None
# Qbittorrent分类自动管理
QB_CATEGORY: bool = False
# Qbittorrent按顺序下载
QB_SEQUENTIAL: bool = True
# Qbittorrent忽略队列限制强制继续
QB_FORCE_RESUME: bool = False
# Transmission地址IP:PORT
TR_HOST: str = None
# Transmission用户名
@@ -135,7 +142,7 @@ class Settings(BaseSettings):
# 种子标签
TORRENT_TAG: str = "MOVIEPILOT"
# 下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: str = "/downloads"
DOWNLOAD_PATH: str = None
# 电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: str = None
# 电视剧下载保存目录,容器内映射路径需要一致
@@ -148,22 +155,26 @@ class Settings(BaseSettings):
DOWNLOAD_SUBTITLE: bool = True
# 媒体服务器 emby/jellyfin/plex多个媒体服务器,分割
MEDIASERVER: str = "emby"
# 入库刷新媒体库
REFRESH_MEDIASERVER: bool = True
# 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL: int = 6
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
# 媒体服务器同步黑名单,多个媒体库名称,分割
MEDIASERVER_SYNC_BLACKLIST: str = None
# EMBY服务器地址IP:PORT
EMBY_HOST: str = None
# EMBY外网地址http(s)://DOMAIN:PORT未设置时使用EMBY_HOST
EMBY_PLAY_HOST: str = None
# EMBY Api Key
EMBY_API_KEY: str = None
# Jellyfin服务器地址IP:PORT
JELLYFIN_HOST: str = None
# Jellyfin外网地址http(s)://DOMAIN:PORT未设置时使用JELLYFIN_HOST
JELLYFIN_PLAY_HOST: str = None
# Jellyfin Api Key
JELLYFIN_API_KEY: str = None
# Plex服务器地址IP:PORT
PLEX_HOST: str = None
# Plex外网地址http(s)://DOMAIN:PORT未设置时使用PLEX_HOST
PLEX_PLAY_HOST: str = None
# Plex Token
PLEX_TOKEN: str = None
# 转移方式 link/copy/move/softlink
@@ -175,18 +186,18 @@ class Settings(BaseSettings):
# CookieCloud端对端加密密码
COOKIECLOUD_PASSWORD: str = None
# CookieCloud同步间隔分钟
COOKIECLOUD_INTERVAL: int = 60 * 24
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
# OCR服务器地址
OCR_HOST: str = "https://movie-pilot.org"
# 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_MOVIE_NAME: str = None
# 电视剧媒体库目录名,默认"电视剧"
LIBRARY_TV_NAME: str = None
# 动漫媒体库目录名,默认"电视剧/动漫"
# 电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影"
# 电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧"
# 动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: str = None
# 二级分类
LIBRARY_CATEGORY: bool = True
@@ -201,8 +212,16 @@ class Settings(BaseSettings):
"/Season {{season}}" \
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" \
"{{fileExt}}"
# 转移时覆盖模式
OVERWRITE_MODE: str = "size"
# 大内存模式
BIG_MEMORY_MODE: bool = False
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
# Github token提高请求api限流阈值 ghp_****
GITHUB_TOKEN: str = None
# 自动检查和更新站点资源包(站点索引、认证等)
AUTO_UPDATE_RESOURCE: bool = True
@property
def INNER_CONFIG_PATH(self):
@@ -274,7 +293,54 @@ class Settings(BaseSettings):
def LIBRARY_PATHS(self) -> List[Path]:
if self.LIBRARY_PATH:
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
return []
return [self.CONFIG_PATH / "library"]
@property
def SAVE_PATH(self) -> Path:
"""
获取下载保存目录
"""
if self.DOWNLOAD_PATH:
return Path(self.DOWNLOAD_PATH)
return self.CONFIG_PATH / "downloads"
@property
def SAVE_MOVIE_PATH(self) -> Path:
"""
获取电影下载保存目录
"""
if self.DOWNLOAD_MOVIE_PATH:
return Path(self.DOWNLOAD_MOVIE_PATH)
return self.SAVE_PATH
@property
def SAVE_TV_PATH(self) -> Path:
"""
获取电视剧下载保存目录
"""
if self.DOWNLOAD_TV_PATH:
return Path(self.DOWNLOAD_TV_PATH)
return self.SAVE_PATH
@property
def SAVE_ANIME_PATH(self) -> Path:
"""
获取动漫下载保存目录
"""
if self.DOWNLOAD_ANIME_PATH:
return Path(self.DOWNLOAD_ANIME_PATH)
return self.SAVE_TV_PATH
@property
def GITHUB_HEADERS(self):
"""
Github请求头
"""
if self.GITHUB_TOKEN:
return {
"Authorization": f"Bearer {self.GITHUB_TOKEN}"
}
return {}
def __init__(self, **kwargs):
super().__init__(**kwargs)

View File

@@ -6,6 +6,7 @@ from app.core.config import settings
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.schemas.types import MediaType
from app.utils.string import StringUtils
@dataclass
@@ -44,6 +45,8 @@ class TorrentInfo:
pubdate: str = None
# 已过时间
date_elapsed: str = None
# 免费截止时间
freedate: str = None
# 上传因子
uploadvolumefactor: float = None
# 下载因子
@@ -90,7 +93,9 @@ class TorrentInfo:
"1.0 1.0": "普通",
"1.0 0.0": "免费",
"2.0 1.0": "2X",
"4.0 1.0": "4X",
"2.0 0.0": "2X免费",
"4.0 0.0": "4X免费",
"1.0 0.5": "50%",
"2.0 0.5": "2X 50%",
"1.0 0.7": "70%",
@@ -105,12 +110,22 @@ class TorrentInfo:
"""
return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)
@property
def freedate_diff(self):
"""
返回免费剩余时间
"""
if not self.freedate:
return ""
return StringUtils.diff_time_str(self.freedate)
def to_dict(self):
"""
返回字典
"""
dicts = asdict(self)
dicts["volume_factor"] = self.volume_factor
dicts["freedate_diff"] = self.freedate_diff
return dicts
@@ -414,24 +429,31 @@ class MediaInfo:
# 豆瓣ID
self.douban_id = str(info.get("id"))
# 类型
if not self.type:
if isinstance(info.get('media_type'), MediaType):
self.type = info.get('media_type')
else:
elif info.get("type"):
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
elif info.get("type_name"):
self.type = MediaType(info.get("type_name"))
# 标题
if not self.title:
self.title = info.get("title")
# 识别标题中的季
meta = MetaInfo(self.title)
self.season = meta.begin_season
# 原语种标题
if not self.original_title:
self.original_title = info.get("original_title")
# 年份
if not self.year:
self.year = info.get("year")[:4] if info.get("year") else None
# 识别标题中的季
meta = MetaInfo(info.get("title"))
# 季
if not self.season:
self.season = meta.begin_season
if self.season:
self.type = MediaType.TV
elif not self.type:
self.type = MediaType.MOVIE
# 评分
if not self.vote_average:
rating = info.get("rating")
@@ -472,14 +494,34 @@ class MediaInfo:
self.actors = info.get("actors") or []
# 别名
if not self.names:
self.names = info.get("aka") or []
akas = info.get("aka")
if akas:
self.names = [re.sub(r'\([港台豆友译名]+\)', "", aka) for aka in akas]
# 剧集
if self.type == MediaType.TV and not self.seasons:
meta = MetaInfo(info.get("title"))
if meta.begin_season:
episodes_count = info.get("episodes_count")
if episodes_count:
self.seasons[meta.begin_season] = list(range(1, episodes_count + 1))
season = meta.begin_season or 1
episodes_count = info.get("episodes_count")
if episodes_count:
self.seasons[season] = list(range(1, episodes_count + 1))
# 季年份
if self.type == MediaType.TV and not self.season_years:
season = self.season or 1
self.season_years = {
season: self.year
}
# 风格
if not self.genres:
self.genres = [{"id": genre, "name": genre} for genre in info.get("genres") or []]
# 时长
if not self.runtime and info.get("durations"):
# 查找数字
match = re.search(r'\d+', info.get("durations")[0])
if match:
self.runtime = int(match.group())
# 国家
if not self.production_countries:
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
# 剩余属性赋值
for key, value in info.items():
if not hasattr(self, key):

View File

@@ -1,4 +1,5 @@
from queue import Queue, Empty
from typing import Dict, Any
from app.log import logger
from app.utils.singleton import Singleton
@@ -10,16 +11,13 @@ class EventManager(metaclass=Singleton):
事件管理器
"""
# 事件队列
_eventQueue: Queue = None
# 事件响应函数字典
_handlers: dict = {}
def __init__(self):
# 事件队列
self._eventQueue = Queue()
# 事件响应函数字典
self._handlers = {}
self._handlers: Dict[str, Dict[str, Any]] = {}
# 已禁用的事件响应
self._disabled_handlers = []
def get_event(self):
"""
@@ -27,36 +25,52 @@ class EventManager(metaclass=Singleton):
"""
try:
event = self._eventQueue.get(block=True, timeout=1)
handlerList = self._handlers.get(event.event_type)
return event, handlerList or []
handlers = self._handlers.get(event.event_type) or {}
if handlers:
# 去除掉被禁用的事件响应
handlerList = [handler for handler in handlers.values()
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
return event, handlerList
return event, []
except Empty:
return None, []
def check(self, etype: EventType):
"""
检查事件是否存在响应
"""
return etype.value in self._handlers
def add_event_listener(self, etype: EventType, handler: type):
"""
注册事件处理
"""
try:
handlerList = self._handlers[etype.value]
handlers = self._handlers[etype.value]
except KeyError:
handlerList = []
self._handlers[etype.value] = handlerList
if handler not in handlerList:
handlerList.append(handler)
logger.debug(f"Event Registed{etype.value} - {handler}")
handlers = {}
self._handlers[etype.value] = handlers
if handler.__qualname__ in handlers:
handlers.pop(handler.__qualname__)
else:
logger.debug(f"Event Registed{etype.value} - {handler.__qualname__}")
handlers[handler.__qualname__] = handler
def remove_event_listener(self, etype: EventType, handler: type):
def disable_events_hander(self, class_name: str):
"""
移除监听器的处理函数
标记对应类事件处理为不可用
"""
try:
handlerList = self._handlers[etype.value]
if handler in handlerList[:]:
handlerList.remove(handler)
if not handlerList:
del self._handlers[etype.value]
except KeyError:
pass
if class_name not in self._disabled_handlers:
self._disabled_handlers.append(class_name)
logger.debug(f"Event Disabled{class_name}")
def enable_events_hander(self, class_name: str):
"""
标记对应类事件处理为可用
"""
if class_name in self._disabled_handlers:
self._disabled_handlers.remove(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
@@ -59,6 +61,9 @@ class MetaBase(object):
audio_encode: Optional[str] = None
# 应用的识别词信息
apply_words: Optional[List[str]] = None
# 附加信息
tmdbid: int = None
doubanid: str = None
# 副标题解析
_subtitle_flag = False
@@ -87,6 +92,17 @@ class MetaBase(object):
return self.cn_name
return ""
@name.setter
def name(self, name: str):
"""
设置名称
"""
if StringUtils.is_all_chinese(name):
self.cn_name = name
else:
self.en_name = name
self.cn_name = None
def init_subtitle(self, title_text: str):
"""
副标题识别
@@ -113,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
@@ -144,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
@@ -167,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
@@ -183,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

@@ -34,6 +34,7 @@ class MetaVideo(MetaBase):
_name_no_begin_re = r"^\[.+?]"
_name_no_chinese_re = r".*版|.*字幕"
_name_se_words = ['', '', '', '', '', '', '']
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
_name_nostring_re = r"^PTS|^JADE|^AOD|^CHC|^[A-Z]{1,4}TV[\-0-9UVHDK]*" \
r"|HBO$|\s+HBO|\d{1,2}th|\d{1,2}bit|NETFLIX|AMAZON|IMAX|^3D|\s+3D|^BBC\s+|\s+BBC|BBC$|DISNEY\+?|XXX|\s+DC$" \
r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
@@ -182,8 +183,9 @@ class MetaVideo(MetaBase):
if not self.cn_name:
self.cn_name = token
elif not self._stop_cnname_flag:
if not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE) \
and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE):
if re.search("%s" % self._name_movie_words, token, flags=re.IGNORECASE) \
or (not re.search("%s" % self._name_no_chinese_re, token, flags=re.IGNORECASE)
and not re.search("%s" % self._name_se_words, token, flags=re.IGNORECASE)):
self.cn_name = "%s %s" % (self.cn_name, token)
self._stop_cnname_flag = True
else:
@@ -268,7 +270,7 @@ class MetaVideo(MetaBase):
self.tokens.get_next()
self._last_token_type = "part"
self._continue_flag = False
self._stop_name_flag = False
# self._stop_name_flag = False
def __init_year(self, token: str):
if not self.name:

View File

@@ -1,3 +1,4 @@
import traceback
from typing import List, Tuple
import cn2an
@@ -61,10 +62,9 @@ class WordsMatcher(metaclass=Singleton):
if state:
appley_words.append(word)
else:
logger.debug(f"自定义识别词替换失败:{message}")
except Exception as err:
print(str(err))
logger.error(f"自定义识别词预处理标题失败:{str(err)} - {traceback.format_exc()}")
return title, appley_words
@@ -79,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
@@ -131,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,10 +1,12 @@
from pathlib import Path
from typing import Tuple
import regex as re
from app.core.config import settings
from app.core.meta import MetaAnime, MetaVideo, MetaBase
from app.core.meta.words import WordsMatcher
from app.schemas.types import MediaType
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
@@ -18,6 +20,8 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
org_title = title
# 预处理标题
title, apply_words = WordsMatcher().prepare(title)
# 获取标题中媒体信息
title, metainfo = find_metainfo(title)
# 判断是否处理文件
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
isfile = True
@@ -29,7 +33,25 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
meta.title = org_title
# 记录使用的识别词
meta.apply_words = apply_words or []
# 修正媒体信息
if metainfo.get('tmdbid'):
meta.tmdbid = metainfo['tmdbid']
if metainfo.get('doubanid'):
meta.tmdbid = metainfo['doubanid']
if metainfo.get('type'):
meta.type = metainfo['type']
if metainfo.get('begin_season'):
meta.begin_season = metainfo['begin_season']
if metainfo.get('end_season'):
meta.end_season = metainfo['end_season']
if metainfo.get('total_season'):
meta.total_season = metainfo['total_season']
if metainfo.get('begin_episode'):
meta.begin_episode = metainfo['begin_episode']
if metainfo.get('end_episode'):
meta.end_episode = metainfo['end_episode']
if metainfo.get('total_episode'):
meta.total_episode = metainfo['total_episode']
return meta
@@ -38,12 +60,16 @@ def MetaInfoPath(path: Path) -> MetaBase:
根据路径识别元数据
:param path: 路径
"""
# 上级目录元数据
dir_meta = MetaInfo(title=path.parent.name)
# 文件元数据,不包含后缀
file_meta = MetaInfo(title=path.stem)
# 上级目录元数据
dir_meta = MetaInfo(title=path.parent.name)
# 合并元数据
file_meta.merge(dir_meta)
# 上上级目录元数据
root_meta = MetaInfo(title=path.parent.parent.name)
# 合并元数据
file_meta.merge(root_meta)
return file_meta
@@ -65,3 +91,76 @@ def is_anime(name: str) -> bool:
if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE):
return True
return False
def find_metainfo(title: str) -> Tuple[str, dict]:
"""
从标题中提取媒体信息
"""
metainfo = {
'tmdbid': None,
'doubanid': None,
'type': None,
'begin_season': None,
'end_season': None,
'total_season': None,
'begin_episode': None,
'end_episode': None,
'total_episode': None,
}
if not title:
return title, metainfo
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title)
if not results:
return title, metainfo
for result in results:
# 查找tmdbid信息
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
if tmdbid and tmdbid[0].isdigit():
metainfo['tmdbid'] = tmdbid[0]
# 查找豆瓣id信息
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
if doubanid and doubanid[0].isdigit():
metainfo['doubanid'] = doubanid[0]
# 查找媒体类型
mtype = re.findall(r'(?<=type=)\w+', result)
if mtype:
match mtype[0]:
case "movie":
metainfo['type'] = MediaType.MOVIE
case "tv":
metainfo['type'] = MediaType.TV
case _:
pass
# 查找季信息
begin_season = re.findall(r'(?<=s=)\d+', result)
if begin_season and begin_season[0].isdigit():
metainfo['begin_season'] = int(begin_season[0])
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
if end_season and end_season[0].isdigit():
metainfo['end_season'] = int(end_season[0])
# 查找集信息
begin_episode = re.findall(r'(?<=e=)\d+', result)
if begin_episode and begin_episode[0].isdigit():
metainfo['begin_episode'] = int(begin_episode[0])
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
if end_episode and end_episode[0].isdigit():
metainfo['end_episode'] = int(end_episode[0])
# 去除title中该部分
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
title = title.replace(f"{{[{result}]}}", '')
# 计算季集总数
if metainfo.get('begin_season') and metainfo.get('end_season'):
if metainfo['begin_season'] > metainfo['end_season']:
metainfo['begin_season'], metainfo['end_season'] = metainfo['end_season'], metainfo['begin_season']
metainfo['total_season'] = metainfo['end_season'] - metainfo['begin_season'] + 1
elif metainfo.get('begin_season') and not metainfo.get('end_season'):
metainfo['total_season'] = 1
if metainfo.get('begin_episode') and metainfo.get('end_episode'):
if metainfo['begin_episode'] > metainfo['end_episode']:
metainfo['begin_episode'], metainfo['end_episode'] = metainfo['end_episode'], metainfo['begin_episode']
metainfo['total_episode'] = metainfo['end_episode'] - metainfo['begin_episode'] + 1
elif metainfo.get('begin_episode') and not metainfo.get('end_episode'):
metainfo['total_episode'] = 1
return title, metainfo

View File

@@ -1,13 +1,18 @@
import traceback
from typing import List, Any, Dict, Tuple
from app.core.config import settings
from app.core.event import eventmanager
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.module import ModuleHelper
from app.helper.plugin import PluginHelper
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
class PluginManager(metaclass=Singleton):
@@ -25,11 +30,12 @@ class PluginManager(metaclass=Singleton):
def __init__(self):
self.siteshelper = SitesHelper()
self.pluginhelper = PluginHelper()
self.systemconfig = SystemConfigOper()
self.install_online_plugin()
self.init_config()
def init_config(self):
# 配置管理
self.systemconfig = SystemConfigOper()
# 停止已有插件
self.stop()
# 启动插件
@@ -39,7 +45,6 @@ class PluginManager(metaclass=Singleton):
"""
启动加载插件
"""
# 扫描插件目录
plugins = ModuleHelper.load(
"app.plugins",
@@ -58,6 +63,8 @@ class PluginManager(metaclass=Singleton):
self._plugins[plugin_id] = plugin
# 未安装的不加载
if plugin_id not in installed_plugins:
# 设置事件状态为不可用
eventmanager.disable_events_hander(plugin_id)
continue
# 生成实例
plugin_obj = plugin()
@@ -66,8 +73,10 @@ class PluginManager(metaclass=Singleton):
# 存储运行实例
self._running_plugins[plugin_id] = plugin_obj
logger.info(f"Plugin Loaded{plugin_id}")
# 设置事件注册状态可用
eventmanager.enable_events_hander(plugin_id)
except Exception as err:
logger.error(f"加载插件 {plugin_id} 出错:{err} - {traceback.format_exc()}")
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
def reload_plugin(self, plugin_id: str, conf: dict):
"""
@@ -93,13 +102,46 @@ class PluginManager(metaclass=Singleton):
self._plugins = {}
self._running_plugins = {}
def install_online_plugin(self):
"""
安装本地不存在的在线插件
"""
if SystemUtils.is_frozen():
return
logger.info("开始安装在线插件...")
# 已安装插件
install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 在线插件
online_plugins = self.get_online_plugins()
if not online_plugins:
logger.error("未获取到在线插件")
return
# 支持更新的插件自动更新
for plugin in online_plugins:
# 只处理已安装的插件
if plugin.get("id") in install_plugins and not self.is_plugin_exists(plugin.get("id")):
# 下载安装
state, msg = self.pluginhelper.install(pid=plugin.get("id"),
repo_url=plugin.get("repo_url"))
# 安装失败
if not state:
logger.error(
f"插件 {plugin.get('plugin_name')} v{plugin.get('plugin_version')} 安装失败:{msg}")
continue
logger.info(f"插件 {plugin.get('plugin_name')} 安装成功,版本:{plugin.get('plugin_version')}")
logger.info("在线插件安装完成")
def get_plugin_config(self, pid: str) -> dict:
"""
获取插件配置
"""
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:
"""
@@ -109,6 +151,14 @@ class PluginManager(metaclass=Singleton):
return False
return self.systemconfig.set(self._config_key % pid, conf)
def delete_plugin_config(self, pid: str) -> bool:
"""
删除插件配置
"""
if not self._plugins.get(pid):
return False
return self.systemconfig.delete(self._config_key % pid)
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
"""
获取插件表单
@@ -167,6 +217,26 @@ class PluginManager(metaclass=Singleton):
ret_apis.extend(apis)
return ret_apis
def get_plugin_services(self) -> List[Dict[str, Any]]:
"""
获取插件服务
[{
"id": "服务ID",
"name": "服务名称",
"trigger": "触发器cron、interval、date、CronTrigger.from_crontab()",
"func": self.xxx,
"kwagrs": {} # 定时器参数
}]
"""
ret_services = []
for pid, plugin in self._running_plugins.items():
if hasattr(plugin, "get_service") \
and ObjectUtils.check_method(plugin.get_service):
services = plugin.get_service()
if services:
ret_services.extend(services)
return ret_services
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
"""
运行插件方法
@@ -177,9 +247,98 @@ class PluginManager(metaclass=Singleton):
return None
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
def get_plugin_apps(self) -> List[dict]:
def get_plugin_ids(self) -> List[str]:
"""
获取所有插件信息
获取所有插件ID
"""
return list(self._plugins.keys())
def get_online_plugins(self) -> List[dict]:
"""
获取所有在线插件信息
"""
# 返回值
all_confs = []
if not settings.PLUGIN_MARKET:
return all_confs
# 已安装插件
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 线上插件列表
markets = settings.PLUGIN_MARKET.split(",")
for market in markets:
online_plugins = self.pluginhelper.get_plugins(market) or {}
if not online_plugins:
logger.warn(f"获取插件库失败 {market}")
for pid, plugin in online_plugins.items():
# 运行状插件
plugin_obj = self._running_plugins.get(pid)
# 非运行态插件
plugin_static = self._plugins.get(pid)
# 基本属性
conf = {}
# ID
conf.update({"id": pid})
# 安装状态
if pid in installed_apps and plugin_static:
conf.update({"installed": True})
else:
conf.update({"installed": False})
# 是否有新版本
conf.update({"has_update": False})
if plugin_static:
installed_version = getattr(plugin_static, "plugin_version")
if StringUtils.compare_version(installed_version, plugin.get("version")) < 0:
# 需要更新
conf.update({"has_update": True})
# 运行状态
if plugin_obj and hasattr(plugin_obj, "get_state"):
try:
state = plugin_obj.get_state()
except Exception as e:
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
state = False
conf.update({"state": state})
else:
conf.update({"state": False})
# 是否有详情页面
conf.update({"has_page": False})
if plugin_obj and hasattr(plugin_obj, "get_page"):
if ObjectUtils.check_method(plugin_obj.get_page):
conf.update({"has_page": True})
# 权限
if plugin.get("level"):
conf.update({"auth_level": plugin.get("level")})
if self.siteshelper.auth_level < plugin.get("level"):
continue
# 名称
if plugin.get("name"):
conf.update({"plugin_name": plugin.get("name")})
# 描述
if plugin.get("description"):
conf.update({"plugin_desc": plugin.get("description")})
# 版本
if plugin.get("version"):
conf.update({"plugin_version": plugin.get("version")})
# 图标
if plugin.get("icon"):
conf.update({"plugin_icon": plugin.get("icon")})
# 作者
if plugin.get("author"):
conf.update({"plugin_author": plugin.get("author")})
# 仓库链接
conf.update({"repo_url": market})
# 本地标志
conf.update({"is_local": False})
# 汇总
all_confs.append(conf)
# 按插件ID去重
if all_confs:
all_confs = list({v["id"]: v for v in all_confs}.values())
return all_confs
def get_local_plugins(self) -> List[dict]:
"""
获取所有本地已下载的插件信息
"""
# 返回值
all_confs = []
@@ -198,8 +357,13 @@ class PluginManager(metaclass=Singleton):
else:
conf.update({"installed": False})
# 运行状态
if plugin_obj and hasattr(plugin, "get_state"):
conf.update({"state": plugin_obj.get_state()})
if plugin_obj and hasattr(plugin_obj, "get_state"):
try:
state = plugin_obj.get_state()
except Exception as e:
logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
state = False
conf.update({"state": state})
else:
conf.update({"state": False})
# 是否有详情页面
@@ -210,6 +374,7 @@ class PluginManager(metaclass=Singleton):
conf.update({"has_page": False})
# 权限
if hasattr(plugin, "auth_level"):
conf.update({"auth_level": plugin.auth_level})
if self.siteshelper.auth_level < plugin.auth_level:
continue
# 名称
@@ -224,15 +389,26 @@ class PluginManager(metaclass=Singleton):
# 图标
if hasattr(plugin, "plugin_icon"):
conf.update({"plugin_icon": plugin.plugin_icon})
# 主题色
if hasattr(plugin, "plugin_color"):
conf.update({"plugin_color": plugin.plugin_color})
# 作者
if hasattr(plugin, "plugin_author"):
conf.update({"plugin_author": plugin.plugin_author})
# 作者链接
if hasattr(plugin, "author_url"):
conf.update({"author_url": plugin.author_url})
# 是否需要更新
conf.update({"has_update": False})
# 本地标志
conf.update({"is_local": True})
# 汇总
all_confs.append(conf)
return all_confs
@staticmethod
def is_plugin_exists(pid: str) -> bool:
"""
判断插件是否存在
"""
if not pid:
return False
plugin_dir = settings.ROOT_PATH / "app" / "plugins" / pid.lower()
return plugin_dir.exists()

View File

@@ -3,6 +3,7 @@ import hashlib
import hmac
import json
import os
import traceback
from datetime import datetime, timedelta
from typing import Any, Union, Optional
import jwt
@@ -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"
@@ -26,7 +29,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
def create_access_token(
subject: Union[str, Any], expires_delta: timedelta = None
userid: Union[str, Any], username: str, super_user: bool = False,
expires_delta: timedelta = None
) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
@@ -34,7 +38,12 @@ def create_access_token(
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
to_encode = {
"exp": expire,
"sub": str(userid),
"username": username,
"super_user": super_user
}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
@@ -52,6 +61,44 @@ def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload:
)
def get_token(token: str = None) -> str:
"""
从请求URL中获取token
"""
return token
def get_apikey(apikey: str = None) -> str:
"""
从请求URL中获取apikey
"""
return apikey
def verify_uri_token(token: str = Depends(get_token)) -> str:
"""
通过依赖项使用token进行身份认证
"""
if token != settings.API_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token校验不通过"
)
return token
def verify_uri_apikey(apikey: str = Depends(get_apikey)) -> str:
"""
通过依赖项使用apikey进行身份认证
"""
if apikey != settings.API_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="apikey校验不通过"
)
return apikey
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
@@ -68,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,5 +1,10 @@
from typing import Any, Self, List
from typing import Tuple, Optional, Generator
from sqlalchemy import create_engine, QueuePool
from sqlalchemy.orm import sessionmaker, Session, scoped_session
from sqlalchemy import inspect
from sqlalchemy.orm import declared_attr
from sqlalchemy.orm import sessionmaker, Session, scoped_session, as_declarative
from app.core.config import settings
@@ -9,20 +14,20 @@ Engine = create_engine(f"sqlite:///{settings.CONFIG_PATH}/user.db",
echo=False,
poolclass=QueuePool,
pool_size=1024,
pool_recycle=600,
pool_recycle=3600,
pool_timeout=180,
max_overflow=0,
max_overflow=10,
connect_args={"timeout": 60})
# 会话工厂
SessionFactory = sessionmaker(autocommit=False, autoflush=False, bind=Engine)
SessionFactory = sessionmaker(bind=Engine)
# 多线程全局使用的数据库会话
ScopedSession = scoped_session(SessionFactory)
def get_db():
def get_db() -> Generator:
"""
获取数据库会话
获取数据库会话用于WEB请求
:return: Session
"""
db = None
@@ -34,11 +39,156 @@ def get_db():
db.close()
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
"""
从参数中获取数据库Session对象
"""
db = None
if args:
for arg in args:
if isinstance(arg, Session):
db = arg
break
if kwargs:
for key, value in kwargs.items():
if isinstance(value, Session):
db = value
break
return db
def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
"""
更新参数中的数据库Session对象关键字传参时更新db的值否则更新第1或第2个参数
"""
if kwargs and 'db' in kwargs:
kwargs['db'] = db
elif args:
if args[0] is None:
args = (db, *args[1:])
else:
args = (args[0], db, *args[2:])
return args, kwargs
def db_update(func):
"""
数据库更新类操作装饰器第一个参数必须是数据库会话或存在db参数
"""
def wrapper(*args, **kwargs):
# 是否关闭数据库会话
_close_db = False
# 从参数中获取数据库会话
db = get_args_db(args, kwargs)
if not db:
# 如果没有获取到数据库会话,创建一个
db = ScopedSession()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的数据库会话
args, kwargs = update_args_db(args, kwargs, db)
try:
# 执行函数
result = func(*args, **kwargs)
# 提交事务
db.commit()
except Exception as err:
# 回滚事务
db.rollback()
raise err
finally:
# 关闭数据库会话
if _close_db:
db.close()
return result
return wrapper
def db_query(func):
"""
数据库查询操作装饰器第一个参数必须是数据库会话或存在db参数
注意db.query列表数据时需要转换为list返回
"""
def wrapper(*args, **kwargs):
# 是否关闭数据库会话
_close_db = False
# 从参数中获取数据库会话
db = get_args_db(args, kwargs)
if not db:
# 如果没有获取到数据库会话,创建一个
db = ScopedSession()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的数据库会话
args, kwargs = update_args_db(args, kwargs, db)
try:
# 执行函数
result = func(*args, **kwargs)
except Exception as err:
raise err
finally:
# 关闭数据库会话
if _close_db:
db.close()
return result
return wrapper
@as_declarative()
class Base:
id: Any
__name__: str
@db_update
def create(self, db: Session):
db.add(self)
@classmethod
@db_query
def get(cls, db: Session, rid: int) -> Self:
return db.query(cls).filter(cls.id == rid).first()
@db_update
def update(self, db: Session, payload: dict):
payload = {k: v for k, v in payload.items() if v is not None}
for key, value in payload.items():
setattr(self, key, value)
if inspect(self).detached:
db.add(self)
@classmethod
@db_update
def delete(cls, db: Session, rid):
db.query(cls).filter(cls.id == rid).delete()
@classmethod
@db_update
def truncate(cls, db: Session):
db.query(cls).delete()
@classmethod
@db_query
def list(cls, db: Session) -> List[Self]:
result = db.query(cls).all()
return list(result)
def to_dict(self):
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
@declared_attr
def __tablename__(self) -> str:
return self.__name__.lower()
class DbOper:
"""
数据库操作基类
"""
_db: Session = None
def __init__(self, db: Session = None):
if db:
self._db = db
else:
self._db = ScopedSession()
self._db = db

View File

@@ -24,12 +24,11 @@ class DownloadHistoryOper(DbOper):
"""
return DownloadHistory.get_by_hash(self._db, download_hash)
def add(self, **kwargs) -> DownloadHistory:
def add(self, **kwargs):
"""
新增下载历史
"""
downloadhistory = DownloadHistory(**kwargs)
return downloadhistory.create(self._db)
DownloadHistory(**kwargs).create(self._db)
def add_files(self, file_items: List[dict]):
"""
@@ -58,7 +57,14 @@ class DownloadHistoryOper(DbOper):
按fullpath查询下载文件记录
:param fullpath: 数据key
"""
return DownloadFiles.get_by_fullpath(self._db, fullpath)
return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]:
"""
按fullpath查询下载文件记录
:param fullpath: 数据key
"""
return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True)
def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:
"""
@@ -79,7 +85,7 @@ class DownloadHistoryOper(DbOper):
按fullpath查询下载文件记录hash
:param fullpath: 数据key
"""
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
if fileinfo:
return fileinfo.download_hash
return ""
@@ -109,10 +115,20 @@ class DownloadHistoryOper(DbOper):
episode=episode,
tmdbid=tmdbid)
def list_by_user_date(self, date: str, userid: str = None) -> List[DownloadHistory]:
def list_by_user_date(self, date: str, username: str = None) -> List[DownloadHistory]:
"""
查询某用户某时间之的下载历史
查询某用户某时间之的下载历史
"""
return DownloadHistory.list_by_user_date(db=self._db,
date=date,
userid=userid)
username=username)
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: str = None) -> List[DownloadHistory]:
"""
查询某时间之后的下载历史
"""
return DownloadHistory.list_by_date(db=self._db,
date=date,
type=type,
tmdbid=tmdbid,
seasons=seasons)

View File

@@ -1,14 +1,13 @@
import importlib
from pathlib import Path
import random
import string
from alembic.command import upgrade
from alembic.config import Config
from app.core.config import settings
from app.core.security import get_password_hash
from app.db import Engine, SessionFactory
from app.db.models import Base
from app.db.models.user import User
from app.db import Engine, SessionFactory, Base
from app.db.models import *
from app.log import logger
@@ -16,22 +15,29 @@ def init_db():
"""
初始化数据库
"""
# 导入模块,避免建表缺失
for module in Path(__file__).with_name("models").glob("*.py"):
importlib.import_module(f"app.db.models.{module.stem}")
# 全量建表
Base.metadata.create_all(bind=Engine)
def init_super_user():
"""
初始化超级管理员
"""
# 初始化超级管理员
db = SessionFactory()
user = User.get_by_name(db=db, name=settings.SUPERUSER)
if not user:
user = User(
name=settings.SUPERUSER,
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD),
is_superuser=True,
)
user.create(db)
db.close()
with SessionFactory() as db:
_user = User.get_by_name(db=db, name=settings.SUPERUSER)
if not _user:
# 定义包含数字、大小写字母的字符集合
characters = string.ascii_letters + string.digits
# 生成随机密码
random_password = ''.join(random.choice(characters) for _ in range(16))
logger.info(f"【超级管理员初始密码】{random_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。")
_user = User(
name=settings.SUPERUSER,
hashed_password=get_password_hash(random_password),
is_superuser=True,
)
_user.create(db)
def update_db():
@@ -46,4 +52,4 @@ def update_db():
alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}")
upgrade(alembic_cfg, 'head')
except Exception as e:
logger.error(f'数据库更新失败:{e}')
logger.error(f'数据库更新失败:{str(e)}')

View File

@@ -25,7 +25,7 @@ class MediaServerOper(DbOper):
return True
return False
def empty(self, server: str):
def empty(self, server: Optional[str] = None):
"""
清空媒体服务器数据
"""
@@ -39,10 +39,12 @@ class MediaServerOper(DbOper):
# 优先按TMDBID查
item = MediaServerItem.exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
mtype=kwargs.get("mtype"))
else:
elif kwargs.get("title"):
# 按标题、类型、年份查
item = MediaServerItem.exists_by_title(self._db, title=kwargs.get("title"),
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
else:
return None
if not item:
return None

View File

@@ -1,53 +1,9 @@
from typing import Any, Self, List
from sqlalchemy.orm import as_declarative, declared_attr, Session
@as_declarative()
class Base:
id: Any
__name__: str
@staticmethod
def commit(db: Session):
try:
db.commit()
except Exception as err:
db.rollback()
raise err
def create(self, db: Session) -> Self:
db.add(self)
self.commit(db)
return self
@classmethod
def get(cls, db: Session, rid: int) -> Self:
return db.query(cls).filter(cls.id == rid).first()
def update(self, db: Session, payload: dict):
payload = {k: v for k, v in payload.items() if v is not None}
for key, value in payload.items():
setattr(self, key, value)
Base.commit(db)
@classmethod
def delete(cls, db: Session, rid):
db.query(cls).filter(cls.id == rid).delete()
Base.commit(db)
@classmethod
def truncate(cls, db: Session):
db.query(cls).delete()
Base.commit(db)
@classmethod
def list(cls, db: Session) -> List[Self]:
return db.query(cls).all()
def to_dict(self):
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
@declared_attr
def __tablename__(self) -> str:
return self.__name__.lower()
from .downloadhistory import DownloadHistory, DownloadFiles
from .mediaserver import MediaServerItem
from .plugindata import PluginData
from .site import Site
from .siteicon import SiteIcon
from .subscribe import Subscribe
from .systemconfig import SystemConfig
from .transferhistory import TransferHistory
from .user import User

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class DownloadHistory(Base):
@@ -37,6 +37,8 @@ class DownloadHistory(Base):
torrent_site = Column(String)
# 下载用户
userid = Column(String)
# 下载用户名/插件名
username = Column(String)
# 下载渠道
channel = Column(String)
# 创建时间
@@ -45,68 +47,97 @@ class DownloadHistory(Base):
note = Column(String)
@staticmethod
@db_query
def get_by_hash(db: Session, download_hash: str):
return db.query(DownloadHistory).filter(DownloadHistory.download_hash == download_hash).first()
@staticmethod
@db_query
def list_by_page(db: Session, page: int = 1, count: int = 30):
return db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
result = db.query(DownloadHistory).offset((page - 1) * count).limit(count).all()
return list(result)
@staticmethod
@db_query
def get_by_path(db: Session, path: str):
return db.query(DownloadHistory).filter(DownloadHistory.path == path).first()
@staticmethod
@db_query
def get_last_by(db: Session, mtype: str = None, title: str = None, year: int = None, season: str = None,
episode: str = None, tmdbid: int = None):
"""
据tmdbid、season、season_episode查询转移记录
"""
result = None
if tmdbid and not season and not episode:
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
DownloadHistory.id.desc()).all()
if tmdbid and season and not episode:
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == season).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == season).order_by(
DownloadHistory.id.desc()).all()
if tmdbid and season and episode:
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode).order_by(
DownloadHistory.id.desc()).all()
# 电视剧所有季集|电影
if not season and not episode:
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
DownloadHistory.title == title,
DownloadHistory.year == year).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
DownloadHistory.title == title,
DownloadHistory.year == year).order_by(
DownloadHistory.id.desc()).all()
# 电视剧某季
if season and not episode:
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season).order_by(
DownloadHistory.id.desc()).all()
# 电视剧某季某集
if season and episode:
return db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
DownloadHistory.title == title,
DownloadHistory.year == year,
DownloadHistory.seasons == season,
DownloadHistory.episodes == episode).order_by(
DownloadHistory.id.desc()).all()
if result:
return list(result)
@staticmethod
def list_by_user_date(db: Session, date: str, userid: str = None):
@db_query
def list_by_user_date(db: Session, date: str, username: str = None):
"""
查询某用户某时间之后的下载历史
"""
if userid:
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
DownloadHistory.userid == userid).order_by(
if username:
result = db.query(DownloadHistory).filter(DownloadHistory.date < date,
DownloadHistory.username == username).order_by(
DownloadHistory.id.desc()).all()
else:
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
result = db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
DownloadHistory.id.desc()).all()
return list(result)
@staticmethod
@db_query
def list_by_date(db: Session, date: str, type: str, tmdbid: str, seasons: str = None):
"""
查询某时间之后的下载历史
"""
if seasons:
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
DownloadHistory.type == type,
DownloadHistory.tmdbid == tmdbid,
DownloadHistory.seasons == seasons).order_by(
DownloadHistory.id.desc()).all()
else:
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
DownloadHistory.type == type,
DownloadHistory.tmdbid == tmdbid).order_by(
DownloadHistory.id.desc()).all()
@@ -131,23 +162,33 @@ class DownloadFiles(Base):
state = Column(Integer, nullable=False, default=1)
@staticmethod
@db_query
def get_by_hash(db: Session, download_hash: str, state: int = None):
if state:
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
DownloadFiles.state == state).all()
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash,
DownloadFiles.state == state).all()
else:
return db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
result = db.query(DownloadFiles).filter(DownloadFiles.download_hash == download_hash).all()
return list(result)
@staticmethod
def get_by_fullpath(db: Session, fullpath: str):
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
DownloadFiles.id.desc()).first()
@db_query
def get_by_fullpath(db: Session, fullpath: str, all_files: bool = False):
if not all_files:
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
DownloadFiles.id.desc()).first()
else:
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
DownloadFiles.id.desc()).all()
@staticmethod
@db_query
def get_by_savepath(db: Session, savepath: str):
return db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
return list(result)
@staticmethod
@db_update
def delete_by_fullpath(db: Session, fullpath: str):
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,
DownloadFiles.state == 1).update(
@@ -155,4 +196,3 @@ class DownloadFiles(Base):
"state": 0
}
)
Base.commit(db)

View File

@@ -1,14 +1,15 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class MediaServerItem(Base):
"""
站点
媒体服务器媒体条目
"""
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 服务器类型
@@ -41,20 +42,26 @@ class MediaServerItem(Base):
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
@staticmethod
@db_query
def get_by_itemid(db: Session, item_id: str):
return db.query(MediaServerItem).filter(MediaServerItem.item_id == item_id).first()
@staticmethod
def empty(db: Session, server: str):
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
Base.commit(db)
@db_update
def empty(db: Session, server: Optional[str] = None):
if server is None:
db.query(MediaServerItem).delete()
else:
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
@staticmethod
@db_query
def exist_by_tmdbid(db: Session, tmdbid: int, mtype: str):
return db.query(MediaServerItem).filter(MediaServerItem.tmdbid == tmdbid,
MediaServerItem.item_type == mtype).first()
@staticmethod
@db_query
def exists_by_title(db: Session, title: str, mtype: str, year: str):
return db.query(MediaServerItem).filter(MediaServerItem.title == title,
MediaServerItem.item_type == mtype,

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class PluginData(Base):
@@ -14,18 +14,23 @@ class PluginData(Base):
value = Column(String)
@staticmethod
@db_query
def get_plugin_data(db: Session, plugin_id: str):
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
return list(result)
@staticmethod
@db_query
def get_plugin_data_by_key(db: Session, plugin_id: str, key: str):
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).first()
@staticmethod
@db_update
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
Base.commit(db)
@staticmethod
@db_query
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):
return db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
result = db.query(PluginData).filter(PluginData.plugin_id == plugin_id).all()
return list(result)

View File

@@ -3,7 +3,7 @@ from datetime import datetime
from sqlalchemy import Boolean, Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class Site(Base):
@@ -47,18 +47,23 @@ class Site(Base):
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
@staticmethod
@db_query
def get_by_domain(db: Session, domain: str):
return db.query(Site).filter(Site.domain == domain).first()
@staticmethod
@db_query
def get_actives(db: Session):
return db.query(Site).filter(Site.is_active == 1).all()
result = db.query(Site).filter(Site.is_active == 1).all()
return list(result)
@staticmethod
@db_query
def list_order_by_pri(db: Session):
return db.query(Site).order_by(Site.pri).all()
result = db.query(Site).order_by(Site.pri).all()
return list(result)
@staticmethod
@db_update
def reset(db: Session):
db.query(Site).delete()
Base.commit(db)

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, Base
class SiteIcon(Base):
@@ -19,5 +19,6 @@ class SiteIcon(Base):
base64 = Column(String)
@staticmethod
@db_query
def get_by_domain(db: Session, domain: str):
return db.query(SiteIcon).filter(SiteIcon.domain == domain).first()

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class Subscribe(Base):
@@ -37,6 +37,12 @@ class Subscribe(Base):
include = Column(String)
# 排除
exclude = Column(String)
# 质量
quality = Column(String)
# 分辨率
resolution = Column(String)
# 特效
effect = Column(String)
# 总集数
total_episode = Column(Integer)
# 开始集数
@@ -59,39 +65,58 @@ class Subscribe(Base):
best_version = Column(Integer, default=0)
# 当前优先级
current_priority = Column(Integer)
# 保存路径
save_path = Column(String)
@staticmethod
def exists(db: Session, tmdbid: int, season: int = None):
if season:
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
Subscribe.season == season).first()
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
@db_query
def exists(db: Session, tmdbid: int = None, doubanid: str = None, season: int = None):
if tmdbid:
if season:
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
Subscribe.season == season).first()
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).first()
elif doubanid:
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
return None
@staticmethod
@db_query
def get_by_state(db: Session, state: str):
return db.query(Subscribe).filter(Subscribe.state == state).all()
result = db.query(Subscribe).filter(Subscribe.state == state).all()
return list(result)
@staticmethod
@db_query
def get_by_tmdbid(db: Session, tmdbid: int, season: int = None):
if season:
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
Subscribe.season == season).all()
return db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
result = db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid,
Subscribe.season == season).all()
else:
result = db.query(Subscribe).filter(Subscribe.tmdbid == tmdbid).all()
return list(result)
@staticmethod
def get_by_title(db: Session, title: str):
@db_query
def get_by_title(db: Session, title: str, season: int = None):
if season:
return db.query(Subscribe).filter(Subscribe.name == title,
Subscribe.season == season).first()
return db.query(Subscribe).filter(Subscribe.name == title).first()
@staticmethod
@db_query
def get_by_doubanid(db: Session, doubanid: str):
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
@db_update
def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):
subscrbies = self.get_by_tmdbid(db, tmdbid, season)
for subscrbie in subscrbies:
subscrbie.delete(db, subscrbie.id)
return True
@db_update
def delete_by_doubanid(self, db: Session, doubanid: str):
subscribe = self.get_by_doubanid(db, doubanid)
if subscribe:

View File

@@ -1,7 +1,7 @@
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class SystemConfig(Base):
@@ -15,9 +15,11 @@ class SystemConfig(Base):
value = Column(String, nullable=True)
@staticmethod
@db_query
def get_by_key(db: Session, key: str):
return db.query(SystemConfig).filter(SystemConfig.key == key).first()
@db_update
def delete_by_key(self, db: Session, key: str):
systemconfig = self.get_by_key(db, key)
if systemconfig:

View File

@@ -3,7 +3,7 @@ import time
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func
from sqlalchemy.orm import Session
from app.db.models import Base
from app.db import db_query, db_update, Base
class TransferHistory(Base):
@@ -47,29 +47,49 @@ class TransferHistory(Base):
files = Column(String)
@staticmethod
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30):
return db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
TransferHistory.date.desc()).offset((page - 1) * count).limit(
count).all()
@db_query
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30, status: bool = None):
if status is not None:
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%'),
TransferHistory.status == status).order_by(
TransferHistory.date.desc()).offset((page - 1) * count).limit(
count).all()
else:
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
TransferHistory.date.desc()).offset((page - 1) * count).limit(
count).all()
return list(result)
@staticmethod
def list_by_page(db: Session, page: int = 1, count: int = 30):
return db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
count).all()
@db_query
def list_by_page(db: Session, page: int = 1, count: int = 30, status: bool = None):
if status is not None:
result = db.query(TransferHistory).filter(TransferHistory.status == status).order_by(
TransferHistory.date.desc()).offset((page - 1) * count).limit(
count).all()
else:
result = db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
count).all()
return list(result)
@staticmethod
@db_query
def get_by_hash(db: Session, download_hash: str):
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).first()
@staticmethod
@db_query
def get_by_src(db: Session, src: str):
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
@staticmethod
@db_query
def list_by_hash(db: Session, download_hash: str):
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
result = db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
return list(result)
@staticmethod
@db_query
def statistic(db: Session, days: int = 7):
"""
统计最近days天的下载历史数量按日期分组返回每日数量
@@ -78,74 +98,96 @@ class TransferHistory(Base):
TransferHistory.id.label('id')).filter(
TransferHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * days))).subquery()
return db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
result = db.query(sub_query.c.date, func.count(sub_query.c.id)).group_by(sub_query.c.date).all()
return list(result)
@staticmethod
def count(db: Session):
return db.query(func.count(TransferHistory.id)).first()[0]
@db_query
def count(db: Session, status: bool = None):
if status is not None:
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
else:
return db.query(func.count(TransferHistory.id)).first()[0]
@staticmethod
def count_by_title(db: Session, title: str):
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
@db_query
def count_by_title(db: Session, title: str, status: bool = None):
if status is not None:
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%'),
TransferHistory.status == status).first()[0]
else:
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
@staticmethod
@db_query
def list_by(db: Session, mtype: str = None, title: str = None, year: str = None, season: str = None,
episode: str = None, tmdbid: int = None, dest: str = None):
"""
据tmdbid、season、season_episode查询转移记录
tmdbid + mtype 或 title + year 必输
"""
result = None
# TMDBID + 类型
if tmdbid and mtype:
# 电视剧某季某集
if season and episode:
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype,
TransferHistory.seasons == season,
TransferHistory.episodes == episode,
TransferHistory.dest == dest).all()
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype,
TransferHistory.seasons == season,
TransferHistory.episodes == episode,
TransferHistory.dest == dest).all()
# 电视剧某季
elif season:
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype,
TransferHistory.seasons == season).all()
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype,
TransferHistory.seasons == season).all()
else:
if dest:
# 电影
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype,
TransferHistory.dest == dest).all()
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype,
TransferHistory.dest == dest).all()
else:
# 电视剧所有季集
return db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype).all()
result = db.query(TransferHistory).filter(TransferHistory.tmdbid == tmdbid,
TransferHistory.type == mtype).all()
# 标题 + 年份
elif title and year:
# 电视剧某季某集
if season and episode:
return db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.seasons == season,
TransferHistory.episodes == episode,
TransferHistory.dest == dest).all()
result = db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.seasons == season,
TransferHistory.episodes == episode,
TransferHistory.dest == dest).all()
# 电视剧某季
elif season:
return db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.seasons == season).all()
result = db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.seasons == season).all()
else:
if dest:
# 电影
return db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.dest == dest).all()
result = db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year,
TransferHistory.dest == dest).all()
else:
# 电视剧所有季集
return db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year).all()
result = db.query(TransferHistory).filter(TransferHistory.title == title,
TransferHistory.year == year).all()
# 类型 + 转移路径emby webhook season无tmdbid场景
elif mtype and season and dest:
# 电视剧某季
result = db.query(TransferHistory).filter(TransferHistory.type == mtype,
TransferHistory.seasons == season,
TransferHistory.dest.like(f"{dest}%")).all()
if result:
return list(result)
return []
@staticmethod
@db_query
def get_by_type_tmdbid(db: Session, mtype: str = None, tmdbid: int = None):
"""
据tmdbid、type查询转移记录
@@ -154,10 +196,10 @@ class TransferHistory(Base):
TransferHistory.type == mtype).first()
@staticmethod
@db_update
def update_download_hash(db: Session, historyid: int = None, download_hash: str = None):
db.query(TransferHistory).filter(TransferHistory.id == historyid).update(
{
"download_hash": download_hash
}
)
Base.commit(db)

View File

@@ -2,7 +2,7 @@ from sqlalchemy import Boolean, Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.core.security import verify_password
from app.db.models import Base
from app.db import db_query, db_update, Base
class User(Base):
@@ -25,6 +25,7 @@ class User(Base):
avatar = Column(String)
@staticmethod
@db_query
def authenticate(db: Session, name: str, password: str):
user = db.query(User).filter(User.name == name).first()
if not user:
@@ -34,9 +35,11 @@ class User(Base):
return user
@staticmethod
@db_query
def get_by_name(db: Session, name: str):
return db.query(User).filter(User.name == name).first()
@db_update
def delete_by_name(self, db: Session, name: str):
user = self.get_by_name(db, name)
if user:

View File

@@ -2,7 +2,7 @@ import json
from typing import Any
from app.db import DbOper
from app.db.models.plugin import PluginData
from app.db.models.plugindata import PluginData
from app.utils.object import ObjectUtils
@@ -11,7 +11,7 @@ class PluginDataOper(DbOper):
插件数据管理
"""
def save(self, plugin_id: str, key: str, value: Any) -> PluginData:
def save(self, plugin_id: str, key: str, value: Any):
"""
保存插件数据
:param plugin_id: 插件id
@@ -25,10 +25,8 @@ class PluginDataOper(DbOper):
plugin.update(self._db, {
"value": value
})
return plugin
else:
plugin = PluginData(plugin_id=plugin_id, key=key, value=value)
return plugin.create(self._db)
PluginData(plugin_id=plugin_id, key=key, value=value).create(self._db)
def get_data(self, plugin_id: str, key: str) -> Any:
"""

View File

@@ -31,6 +31,12 @@ class SiteOper(DbOper):
"""
return Site.list(self._db)
def list_order_by_pri(self) -> List[Site]:
"""
获取站点列表
"""
return Site.list_order_by_pri(self._db)
def list_active(self) -> List[Site]:
"""
按状态获取站点列表

View File

@@ -15,7 +15,10 @@ class SubscribeOper(DbOper):
"""
新增订阅
"""
subscribe = Subscribe.exists(self._db, tmdbid=mediainfo.tmdb_id, season=kwargs.get('season'))
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
if not subscribe:
subscribe = Subscribe(name=mediainfo.title,
year=mediainfo.year,
@@ -23,6 +26,7 @@ class SubscribeOper(DbOper):
tmdbid=mediainfo.tmdb_id,
imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id,
poster=mediainfo.get_poster_image(),
backdrop=mediainfo.get_backdrop_image(),
vote=mediainfo.vote_average,
@@ -30,18 +34,27 @@ class SubscribeOper(DbOper):
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
**kwargs)
subscribe.create(self._db)
# 查询订阅
subscribe = Subscribe.exists(self._db,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
season=kwargs.get('season'))
return subscribe.id, "新增订阅成功"
else:
return subscribe.id, "订阅已存在"
def exists(self, tmdbid: int, season: int) -> bool:
def exists(self, tmdbid: int = None, doubanid: str = None, season: int = None) -> bool:
"""
判断是否存在
"""
if season:
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
else:
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
if tmdbid:
if season:
return True if Subscribe.exists(self._db, tmdbid=tmdbid, season=season) else False
else:
return True if Subscribe.exists(self._db, tmdbid=tmdbid) else False
elif doubanid:
return True if Subscribe.exists(self._db, doubanid=doubanid) else False
return False
def get(self, sid: int) -> Subscribe:
"""

View File

@@ -1,7 +1,7 @@
import json
from typing import Any, Union
from app.db import DbOper, SessionFactory
from app.db import DbOper
from app.db.models.systemconfig import SystemConfig
from app.schemas.types import SystemConfigKey
from app.utils.object import ObjectUtils
@@ -16,8 +16,7 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
"""
加载配置到内存
"""
self._db = SessionFactory()
super().__init__(self._db)
super().__init__()
for item in SystemConfig.list(self._db):
if ObjectUtils.is_obj(item.value):
self.__SYSTEMCONF[item.key] = json.loads(item.value)
@@ -57,6 +56,20 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
return self.__SYSTEMCONF
return self.__SYSTEMCONF.get(key)
def delete(self, key: Union[str, SystemConfigKey]):
"""
删除系统设置
"""
if isinstance(key, SystemConfigKey):
key = key.value
# 更新内存
self.__SYSTEMCONF.pop(key, None)
# 写入数据库
conf = SystemConfig.get_by_key(self._db, key)
if conf:
conf.delete(self._db, conf.id)
return True
def __del__(self):
if self._db:
self._db.close()

View File

@@ -43,14 +43,14 @@ class TransferHistoryOper(DbOper):
"""
return TransferHistory.list_by_hash(self._db, download_hash)
def add(self, **kwargs) -> TransferHistory:
def add(self, **kwargs):
"""
新增转移历史
"""
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
return TransferHistory(**kwargs).create(self._db)
TransferHistory(**kwargs).create(self._db)
def statistic(self, days: int = 7) -> List[Any]:
"""
@@ -103,7 +103,8 @@ class TransferHistoryOper(DbOper):
kwargs.update({
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
})
return TransferHistory(**kwargs).create(self._db)
TransferHistory(**kwargs).create(self._db)
return TransferHistory.get_by_src(self._db, kwargs.get("src"))
def update_download_hash(self, historyid, download_hash):
"""
@@ -119,7 +120,7 @@ class TransferHistoryOper(DbOper):
"""
self.add_force(
src=str(src_path),
dest=str(transferinfo.target_path),
dest=str(transferinfo.target_path or ''),
mode=mode,
type=mediainfo.type.value,
category=mediainfo.category,
@@ -145,7 +146,7 @@ class TransferHistoryOper(DbOper):
if mediainfo and transferinfo:
his = self.add_force(
src=str(src_path),
dest=str(transferinfo.target_path),
dest=str(transferinfo.target_path or ''),
mode=mode,
type=mediainfo.type.value,
category=mediainfo.category,

View File

@@ -49,11 +49,11 @@ class PlaywrightHelper:
# 回调函数
return callback(page)
except Exception as e:
logger.error(f"网页操作失败: {e}")
logger.error(f"网页操作失败: {str(e)}")
finally:
browser.close()
except Exception as e:
logger.error(f"网页操作失败: {e}")
logger.error(f"网页操作失败: {str(e)}")
return None
def get_page_source(self, url: str,
@@ -85,12 +85,12 @@ class PlaywrightHelper:
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
source = page.content()
except Exception as e:
logger.error(f"获取网页源码失败: {e}")
logger.error(f"获取网页源码失败: {str(e)}")
source = None
finally:
browser.close()
except Exception as e:
logger.error(f"获取网页源码失败: {e}")
logger.error(f"获取网页源码失败: {str(e)}")
return source

View File

@@ -162,8 +162,8 @@ class CookieHelper:
page.click(submit_xpath)
page.wait_for_load_state("networkidle", timeout=30 * 1000)
except Exception as e:
logger.error(f"仿真登录失败:{e}")
return None, None, f"仿真登录失败:{e}"
logger.error(f"仿真登录失败:{str(e)}")
return None, None, f"仿真登录失败:{str(e)}"
# 登录后的源码
html_text = page.content()
if not html_text:

View File

@@ -15,7 +15,7 @@ class DisplayHelper(metaclass=Singleton):
self._display = Display(visible=False, size=(1024, 768))
self._display.start()
except Exception as err:
logger.error(f"DisplayHelper init error: {err}")
logger.error(f"DisplayHelper init error: {str(err)}")
def stop(self):
if self._display:

View File

@@ -1,6 +1,10 @@
# -*- coding: utf-8 -*-
import importlib
import pkgutil
import traceback
from pathlib import Path
from app.log import logger
class ModuleHelper:
@@ -20,14 +24,32 @@ class ModuleHelper:
submodules: list = []
packages = importlib.import_module(package_path)
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
if package_name.startswith('_'):
continue
full_package_name = f'{package_path}.{package_name}'
module = importlib.import_module(full_package_name)
for name, obj in module.__dict__.items():
if name.startswith('_'):
try:
if package_name.startswith('_'):
continue
if isinstance(obj, type) and filter_func(name, obj):
submodules.append(obj)
full_package_name = f'{package_path}.{package_name}'
module = importlib.import_module(full_package_name)
importlib.reload(module)
for name, obj in module.__dict__.items():
if name.startswith('_'):
continue
if isinstance(obj, type) and filter_func(name, obj):
submodules.append(obj)
except Exception as err:
logger.error(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
return submodules
@staticmethod
def dynamic_import_all_modules(base_path: Path, package_name: str):
"""
动态导入目录下所有模块
"""
modules = []
# 遍历文件夹,找到所有模块文件
for file in base_path.glob("*.py"):
file_name = file.stem
if file_name != "__init__":
modules.append(file_name)
full_module_name = f"{package_name}.{file_name}"
importlib.import_module(full_module_name)

153
app/helper/plugin.py Normal file
View File

@@ -0,0 +1,153 @@
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
class PluginHelper(metaclass=Singleton):
"""
插件市场管理,下载安装插件到本地
"""
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
@cached(cache=TTLCache(maxsize=10, ttl=1800))
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
"""
获取Github所有最新插件列表
:param repo_url: Github仓库地址
"""
if not repo_url:
return {}
user, repo = self.get_repo_info(repo_url)
if not user or not repo:
return {}
raw_url = self._base_url % (user, repo)
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)
return {}
@staticmethod
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
"""
获取Github仓库信息
:param repo_url: Github仓库地址
"""
if not repo_url:
return None, None
if not repo_url.endswith("/"):
repo_url += "/"
if repo_url.count("/") < 6:
repo_url = f"{repo_url}main/"
try:
user, repo = repo_url.split("/")[-4:-2]
except Exception as e:
logger.error(f"解析Github仓库地址失败{str(e)} - {traceback.format_exc()}")
return None, None
return user, repo
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
"""
安装插件
"""
if SystemUtils.is_frozen():
return False, "可执行文件模式下,只能安装本地插件"
# 从Github的repo_url获取用户和项目名
user, repo = self.get_repo_info(repo_url)
if not user or not repo:
return False, "不支持的插件仓库地址格式"
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
"""
获取插件的文件列表
"""
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p.lower()}"
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
if not r or r.status_code != 200:
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
ret = r.json()
if ret and ret[0].get("message") == "Not Found":
return None, "插件在仓库中不存在"
return ret, ""
def __download_files(_p: str, _l: List[dict]) -> Tuple[bool, str]:
"""
下载插件文件
"""
if not _l:
return False, "文件列表为空"
for item in _l:
if item.get("download_url"):
# 下载插件文件
res = RequestUtils(proxies=settings.PROXY,
headers=settings.GITHUB_HEADERS, timeout=60).get_res(item["download_url"])
if not res:
return False, f"文件 {item.get('name')} 下载失败!"
elif res.status_code != 200:
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}"
# 创建插件文件夹
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(res.text)
else:
# 递归下载子目录
p = f"{_p}/{item.get('name')}"
l, m = __get_filelist(p)
if not l:
return False, m
__download_files(p, l)
return True, ""
if not pid or not repo_url:
return False, "参数错误"
# 获取插件的文件列表
"""
[
{
"name": "__init__.py",
"path": "plugins/autobackup/__init__.py",
"sha": "cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
"size": 12385,
"url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
"html_url": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py",
"git_url": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
"download_url": "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/plugins/autobackup/__init__.py",
"type": "file",
"_links": {
"self": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/contents/plugins/autobackup/__init__.py?ref=main",
"git": "https://api.github.com/repos/jxxghp/MoviePilot-Plugins/git/blobs/cd10eba3f0355d61adeb35561cb26a0a36c15a6c",
"html": "https://github.com/jxxghp/MoviePilot-Plugins/blob/main/plugins/autobackup/__init__.py"
}
}
]
"""
# 获取第一级文件列表
file_list, msg = __get_filelist(pid.lower())
if not file_list:
return False, msg
# 本地存在时先删除
plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / pid.lower()
if plugin_dir.exists():
shutil.rmtree(plugin_dir, ignore_errors=True)
# 下载所有文件
__download_files(pid.lower(), file_list)
# 插件目录下如有requirements.txt则安装依赖
requirements_file = plugin_dir / "requirements.txt"
if requirements_file.exists():
SystemUtils.execute(f"pip install -r {requirements_file} > /dev/null 2>&1")
return True, ""

103
app/helper/resource.py Normal file
View File

@@ -0,0 +1,103 @@
import json
from pathlib import Path
from app.core.config import settings
from app.helper.sites import SitesHelper
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
class ResourceHelper(metaclass=Singleton):
"""
检测和更新资源包
"""
# 资源包的git仓库地址
_repo = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources"
_base_dir: Path = settings.ROOT_PATH
def __init__(self):
self.siteshelper = SitesHelper()
self.check()
def check(self):
"""
检测是否有更新,如有则下载安装
"""
if not settings.AUTO_UPDATE_RESOURCE:
return
if SystemUtils.is_frozen():
return
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)
else:
logger.warn("无法连接资源包仓库!")
return
online_version = resource_info.get("version")
if online_version:
logger.info(f"最新资源包版本v{online_version}")
# 需要更新的资源包
need_updates = {}
# 资源明细
resources: dict = resource_info.get("resources") or {}
for rname, resource in resources.items():
rtype = resource.get("type")
platform = resource.get("platform")
target = resource.get("target")
version = resource.get("version")
# 判断平台
if platform and platform != SystemUtils.platform:
continue
# 判断本地是否存在
local_path = self._base_dir / target / rname
if not local_path.exists():
continue
# 判断版本号
if rtype == "auth":
# 站点认证资源
local_version = self.siteshelper.auth_version
elif rtype == "sites":
# 站点索引资源
local_version = self.siteshelper.indexer_version
else:
continue
if StringUtils.compare_version(version, local_version) > 0:
logger.info(f"{rname} 资源包有更新最新版本v{version}")
else:
continue
# 需要安装
need_updates[rname] = target
if need_updates:
# 下载文件信息列表
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=30).get_res(self._files_api)
if not r or r.status_code != 200:
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
files_info = r.json()
for item in files_info:
save_path = need_updates.get(item.get("name"))
if not save_path:
continue
if item.get("download_url"):
# 下载资源文件
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(item["download_url"])
if not res:
logger.error(f"文件 {item.get('name')} 下载失败!")
elif res.status_code != 200:
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
# 创建插件文件夹
file_path = self._base_dir / save_path / item.get("name")
if not file_path.parent.exists():
file_path.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
file_path.write_bytes(res.content)
logger.info("资源包更新完成,开始重启服务...")
SystemUtils.restart()
else:
logger.info("所有资源已最新,无需更新")

View File

@@ -1,11 +1,15 @@
import re
import traceback
import xml.dom.minidom
from typing import List, Tuple, Union
from urllib.parse import urljoin
import chardet
from lxml import etree
from app.core.config import settings
from app.helper.browser import PlaywrightHelper
from app.log import logger
from app.utils.dom import DomUtils
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
@@ -236,13 +240,32 @@ class RssHelper:
ret = RequestUtils(proxies=settings.PROXY if proxy else None).get_res(url)
if not ret:
return []
ret.encoding = ret.apparent_encoding
except Exception as err:
print(str(err))
logger.error(f"获取RSS失败{str(err)} - {traceback.format_exc()}")
return []
if ret:
ret_xml = ret.text
ret_xml = ""
try:
# 使用chardet检测字符编码
raw_data = ret.content
if raw_data:
try:
result = chardet.detect(raw_data)
encoding = result['encoding']
# 解码为字符串
ret_xml = raw_data.decode(encoding)
except Exception as e:
logger.debug(f"chardet解码失败{str(e)}")
# 探测utf-8解码
match = re.search(r'encoding\s*=\s*["\']([^"\']+)["\']', ret.text)
if match:
encoding = match.group(1)
if encoding:
ret_xml = raw_data.decode(encoding)
else:
ret.encoding = ret.apparent_encoding
if not ret_xml:
ret_xml = ret.text
# 解析XML
dom_tree = xml.dom.minidom.parseString(ret_xml)
rootNode = dom_tree.documentElement
@@ -284,10 +307,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 链接已过期, 您需要获得一个新的!",

Binary file not shown.

31
app/helper/thread.py Normal file
View File

@@ -0,0 +1,31 @@
from concurrent.futures import ThreadPoolExecutor
from app.utils.singleton import Singleton
class ThreadHelper(metaclass=Singleton):
"""
线程池管理
"""
def __init__(self, max_workers=50):
self.pool = ThreadPoolExecutor(max_workers=max_workers)
def submit(self, func, *args, **kwargs):
"""
提交任务
:param func: 函数
:param args: 参数
:param kwargs: 参数
:return: future
"""
return self.pool.submit(func, *args, **kwargs)
def shutdown(self):
"""
关闭线程池
:return:
"""
self.pool.shutdown()
def __del__(self):
self.shutdown()

View File

@@ -14,13 +14,17 @@ 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
class TorrentHelper:
class TorrentHelper(metaclass=Singleton):
"""
种子帮助类
"""
# 失败的种子:站点链接
_invalid_torrents = []
def __init__(self):
self.system_config = SystemConfigOper()
@@ -95,7 +99,7 @@ class TorrentHelper:
logger.warn(f"触发了站点首次种子下载,且无法自动跳过:{url}")
break
except Exception as err:
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{err},链接:{url}")
logger.warn(f"触发了站点首次种子下载,尝试自动跳过时出现错误:{str(err)},链接:{url}")
if not skip_flag:
return None, None, "", [], "种子数据有误请确认链接是否正确如为PT站点则需手工在站点下载一次种子"
# 种子内容
@@ -113,7 +117,7 @@ class TorrentHelper:
# 成功拿到种子数据
return file_path, req.content, folder_name, file_list, ""
except Exception as err:
logger.error(f"种子文件解析失败:{err}")
logger.error(f"种子文件解析失败:{str(err)}")
# 种子数据仍然错误
return None, None, "", [], "种子数据有误,请确认链接是否正确"
# 返回失败
@@ -123,6 +127,8 @@ class TorrentHelper:
elif req.status_code == 429:
return None, None, "", [], "触发站点流控,请稍后重试"
else:
# 把错误的种子记下来,避免重复使用
self.add_invalid(url)
return None, None, "", [], f"下载种子出错,状态码:{req.status_code}"
@staticmethod
@@ -157,10 +163,10 @@ class TorrentHelper:
file_list.append(str(file_path.relative_to(root_path)))
else:
file_list.append(fileinfo.name)
logger.info(f"解析种子:{torrent_path.name} => 目录:{folder_name},文件清单:{file_list}")
logger.debug(f"解析种子:{torrent_path.name} => 目录:{folder_name},文件清单:{file_list}")
return folder_name, file_list
except Exception as err:
logger.error(f"种子文件解析失败:{err}")
logger.error(f"种子文件解析失败:{str(err)}")
return "", []
@staticmethod
@@ -276,3 +282,16 @@ class TorrentHelper:
continue
episodes = list(set(episodes).union(set(meta.episode_list)))
return episodes
def is_invalid(self, url: str) -> bool:
"""
判断种子是否是无效种子
"""
return url in self._invalid_torrents
def add_invalid(self, url: str):
"""
添加无效种子
"""
if url not in self._invalid_torrents:
self._invalid_torrents.append(url)

View File

@@ -1,28 +1,12 @@
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
import click
from app.core.config import settings
# logger
logger = logging.getLogger()
if settings.DEBUG:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
# 创建终端输出Handler
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
# 创建文件输出Handler
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)
# 日志级别颜色
level_name_colors = {
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
@@ -34,20 +18,40 @@ level_name_colors = {
}
# 定义日志输出格式
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)
# 终端日志
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)
# 文件日志
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)

View File

@@ -1,18 +1,33 @@
import multiprocessing
import os
import sys
import threading
import uvicorn as uvicorn
from PIL import Image
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from uvicorn import Config
from app.command import Command
from app.utils.system import SystemUtils
# 禁用输出
if SystemUtils.is_frozen():
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')
from app.core.config import settings
from app.core.module import ModuleManager
from app.core.plugin import PluginManager
from app.db.init import init_db, update_db
from app.db.init import init_db, update_db, init_super_user
from app.helper.thread import ThreadHelper
from app.helper.display import DisplayHelper
from app.helper.resource import ResourceHelper
from app.helper.sites import SitesHelper
from app.helper.message import MessageHelper
from app.scheduler import Scheduler
from app.command import Command, CommandChian
from app.schemas import Notification, NotificationType
# App
App = FastAPI(title=settings.PROJECT_NAME,
@@ -44,6 +59,103 @@ def init_routers():
App.include_router(arr_router, prefix="/api/v3")
def start_frontend():
"""
启动前端服务
"""
# 仅Windows可执行文件支持内嵌nginx
if not SystemUtils.is_frozen() \
or not SystemUtils.is_windows():
return
# 临时Nginx目录
nginx_path = settings.ROOT_PATH / 'nginx'
if not nginx_path.exists():
return
# 配置目录下的Nginx目录
run_nginx_dir = settings.CONFIG_PATH.with_name('nginx')
if not run_nginx_dir.exists():
# 移动到配置目录
SystemUtils.move(nginx_path, run_nginx_dir)
# 启动Nginx
import subprocess
subprocess.Popen("start nginx.exe",
cwd=run_nginx_dir,
shell=True)
def stop_frontend():
"""
停止前端服务
"""
if not SystemUtils.is_frozen() \
or not SystemUtils.is_windows():
return
import subprocess
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
def start_tray():
"""
启动托盘图标
"""
if not SystemUtils.is_frozen():
return
if not SystemUtils.is_windows():
return
def open_web():
"""
调用浏览器打开前端页面
"""
import webbrowser
webbrowser.open(f"http://localhost:{settings.NGINX_PORT}")
def quit_app():
"""
退出程序
"""
TrayIcon.stop()
Server.should_exit = True
import pystray
# 托盘图标
TrayIcon = pystray.Icon(
settings.PROJECT_NAME,
icon=Image.open(settings.ROOT_PATH / 'app.ico'),
menu=pystray.Menu(
pystray.MenuItem(
'打开',
open_web,
),
pystray.MenuItem(
'退出',
quit_app,
)
)
)
# 启动托盘图标
threading.Thread(target=TrayIcon.run, daemon=True).start()
def check_auth():
"""
检查认证状态
"""
if SitesHelper().auth_level < 2:
err_msg = "用户认证失败,站点相关功能将无法使用!"
MessageHelper().put(f"注意:{err_msg}")
CommandChian().post_message(
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证",
text=err_msg
)
)
@App.on_event("shutdown")
def shutdown_server():
"""
@@ -59,6 +171,10 @@ def shutdown_server():
DisplayHelper().stop()
# 停止定时服务
Scheduler().stop()
# 停止线程池
ThreadHelper().shutdown()
# 停止前端服务
stop_frontend()
@App.on_event("startup")
@@ -66,10 +182,14 @@ def start_module():
"""
启动模块
"""
# 虚伪显示
# 初始化超级管理员
init_super_user()
# 虚拟显示
DisplayHelper()
# 站点管理
SitesHelper()
# 资源包检测
ResourceHelper()
# 加载模块
ModuleManager()
# 加载插件
@@ -80,12 +200,18 @@ def start_module():
Command()
# 初始化路由
init_routers()
# 启动前端服务
start_frontend()
# 检查认证状态
check_auth()
if __name__ == '__main__':
# 启动托盘
start_tray()
# 初始化数据库
init_db()
# 更新数据库
update_db()
# 启动服务
# 启动API服务
Server.run()

View File

@@ -1,4 +1,4 @@
from datetime import datetime
import re
from pathlib import Path
from typing import List, Optional, Tuple, Union
@@ -9,6 +9,7 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.douban.apiv2 import DoubanApi
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
@@ -18,10 +19,12 @@ from app.utils.system import SystemUtils
class DoubanModule(_ModuleBase):
doubanapi: DoubanApi = None
scraper: DoubanScraper = None
cache: DoubanCache = None
def init_module(self) -> None:
self.doubanapi = DoubanApi()
self.scraper = DoubanScraper()
self.cache = DoubanCache()
def stop(self):
pass
@@ -29,10 +32,87 @@ class DoubanModule(_ModuleBase):
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def douban_info(self, doubanid: str) -> Optional[dict]:
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: str = None,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:return: 识别的媒体信息,包括剧集信息
"""
if settings.RECOGNIZE_SOURCE != "douban":
return None
if not meta:
cache_info = {}
else:
if mtype:
meta.type = mtype
cache_info = self.cache.get(meta)
if not cache_info:
# 缓存没有或者强制不使用缓存
if doubanid:
# 直接查询详情
info = self.douban_info(doubanid=doubanid, mtype=mtype or meta.type)
elif meta:
if meta.begin_season:
logger.info(f"正在识别 {meta.name}{meta.begin_season}季 ...")
else:
logger.info(f"正在识别 {meta.name} ...")
# 匹配豆瓣信息
match_info = self.match_doubaninfo(name=meta.name,
mtype=mtype or meta.type,
year=meta.year,
season=meta.begin_season)
if match_info:
# 匹配到豆瓣信息
info = self.douban_info(
doubanid=match_info.get("id"),
mtype=mtype or meta.type
)
else:
logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息")
return None
else:
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
return None
# 保存到缓存
if meta:
self.cache.update(meta, info)
else:
# 使用缓存信息
if cache_info.get("title"):
logger.info(f"{meta.name} 使用豆瓣识别缓存:{cache_info.get('title')}")
info = self.douban_info(mtype=cache_info.get("type"),
doubanid=cache_info.get("id"))
else:
logger.info(f"{meta.name} 使用豆瓣识别缓存:无法识别")
info = None
if info:
# 赋值TMDB信息并返回
mediainfo = MediaInfo(douban_info=info)
if meta:
logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year} "
f"{mediainfo.douban_id}")
else:
logger.info(f"{doubanid} 豆瓣识别结果:{mediainfo.type.value} "
f"{mediainfo.title_year}")
return mediainfo
else:
logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息")
return None
def douban_info(self, doubanid: str, mtype: MediaType = None) -> Optional[dict]:
"""
获取豆瓣信息
:param doubanid: 豆瓣ID
:param mtype: 媒体类型
:return: 豆瓣信息
"""
"""
@@ -300,22 +380,40 @@ class DoubanModule(_ModuleBase):
"interest_cmt_earlier_tip_desc": "该短评的发布时间早于公开上映时间,作者可能通过其他渠道提前观看,请谨慎参考。其评分将不计入总评分。"
}
"""
def __douban_tv():
"""
获取豆瓣剧集信息
"""
info = self.doubanapi.tv_detail(doubanid)
if info:
celebrities = self.doubanapi.tv_celebrities(doubanid)
if celebrities:
info["directors"] = celebrities.get("directors")
info["actors"] = celebrities.get("actors")
return info
def __douban_movie():
"""
获取豆瓣电影信息
"""
info = self.doubanapi.movie_detail(doubanid)
if info:
celebrities = self.doubanapi.movie_celebrities(doubanid)
if celebrities:
info["directors"] = celebrities.get("directors")
info["actors"] = celebrities.get("actors")
return info
if not doubanid:
return None
logger.info(f"开始获取豆瓣信息:{doubanid} ...")
douban_info = self.doubanapi.movie_detail(doubanid)
if douban_info:
celebrities = self.doubanapi.movie_celebrities(doubanid)
if celebrities:
douban_info["directors"] = celebrities.get("directors")
douban_info["actors"] = celebrities.get("actors")
if mtype == MediaType.TV:
return __douban_tv()
elif mtype == MediaType.MOVIE:
return __douban_movie()
else:
douban_info = self.doubanapi.tv_detail(doubanid)
celebrities = self.doubanapi.tv_celebrities(doubanid)
if douban_info and celebrities:
douban_info["directors"] = celebrities.get("directors")
douban_info["actors"] = celebrities.get("actors")
return douban_info
return __douban_movie() or __douban_tv()
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
page: int = 1, count: int = 30) -> Optional[List[dict]]:
@@ -369,6 +467,36 @@ class DoubanModule(_ModuleBase):
return []
return infos.get("subject_collection_items")
def tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
"""
获取豆瓣动画剧
"""
infos = self.doubanapi.tv_animation(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
def movie_hot(self, page: int = 1, count: int = 30) -> List[dict]:
"""
获取豆瓣热门电影
"""
infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
def tv_hot(self, page: int = 1, count: int = 30) -> List[dict]:
"""
获取豆瓣热门剧集
"""
infos = self.doubanapi.tv_hot(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
@@ -376,7 +504,7 @@ class DoubanModule(_ModuleBase):
:reutrn: 媒体信息
"""
# 未启用豆瓣搜索时返回None
if settings.SEARCH_SOURCE != "douban":
if settings.RECOGNIZE_SOURCE != "douban":
return None
if not meta.name:
@@ -387,7 +515,7 @@ class DoubanModule(_ModuleBase):
# 返回数据
ret_medias = []
for item_obj in result.get("items"):
if meta.type and meta.type.value != item_obj.get("type_name"):
if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"):
continue
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue
@@ -396,17 +524,30 @@ class DoubanModule(_ModuleBase):
return ret_medias
@retry(Exception, 5, 3, 3, logger=logger)
def match_doubaninfo(self, name: str, mtype: str = None,
year: str = None, season: int = None) -> dict:
def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
"""
搜索和匹配豆瓣信息
:param name: 名称
:param mtype: 类型 电影/电视剧
:param imdbid: IMDB ID
:param mtype: 类型
:param year: 年份
:param season: 季号
"""
result = self.doubanapi.search(f"{name} {year or ''}".strip(),
ts=datetime.strftime(datetime.now(), '%Y%m%d%H%M%S'))
if imdbid:
# 优先使用IMDBID查询
logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...")
result = self.doubanapi.imdbid(imdbid)
if result:
doubanid = result.get("id")
if doubanid and not str(doubanid).isdigit():
doubanid = re.search(r"\d+", doubanid).group(0)
result["id"] = doubanid
logger.info(f"{imdbid} 查询到豆瓣信息:{result.get('title')}")
return result
# 搜索
logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...")
result = self.doubanapi.search(f"{name} {year or ''}".strip())
if not result:
logger.warn(f"未找到 {name} 的豆瓣信息")
return {}
@@ -418,9 +559,9 @@ class DoubanModule(_ModuleBase):
type_name = item_obj.get("type_name")
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
continue
if mtype and mtype != type_name:
if mtype and mtype.value != type_name:
continue
if mtype == MediaType.TV and not season:
if mtype and mtype == MediaType.TV and not season:
season = 1
item = item_obj.get("target")
title = item.get("title")
@@ -433,6 +574,7 @@ class DoubanModule(_ModuleBase):
if meta.name == name \
and ((not season and not meta.begin_season) or meta.begin_season == season) \
and (not year or item.get('year') == year):
logger.info(f"{name} 匹配到豆瓣信息:{item.get('id')} {item.get('title')}")
return item
return {}
@@ -446,11 +588,15 @@ class DoubanModule(_ModuleBase):
return []
return infos.get("subject_collection_items")
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
force_nfo: bool = False, force_img: bool = False) -> None:
"""
刮削元数据
:param path: 媒体文件路径
:param mediainfo: 识别的媒体信息
:param transfer_type: 传输类型
:param force_nfo: 是否强制刮削nfo
:param force_img: 是否强制刮削图片
:return: 成功或失败
"""
if settings.SCRAP_SOURCE != "douban":
@@ -461,18 +607,35 @@ class DoubanModule(_ModuleBase):
meta = MetaInfo(path.stem)
if not meta.name:
return
# 根据名称查询豆瓣数据
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
mtype=mediainfo.type.value,
year=mediainfo.year,
season=meta.begin_season)
# 查询豆瓣详情
if not mediainfo.douban_id:
# 根据名称查询豆瓣数据
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
imdbid=mediainfo.imdb_id,
mtype=mediainfo.type,
year=mediainfo.year)
if not doubaninfo:
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
return
doubaninfo = self.douban_info(doubanid=doubaninfo.get("id"), mtype=mediainfo.type)
else:
doubaninfo = self.douban_info(doubanid=mediainfo.douban_id,
mtype=mediainfo.type)
if not doubaninfo:
logger.warn(f"{mediainfo.title} 的豆瓣信息")
logger(f"获取{mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
return
# 豆瓣媒体信息
mediainfo = MediaInfo(douban_info=doubaninfo)
# 补充图片
self.obtain_images(mediainfo)
# 刮削路径
scrape_path = path / path.name
self.scraper.gen_scraper_files(meta=meta,
mediainfo=MediaInfo(douban_info=doubaninfo),
file_path=scrape_path)
mediainfo=mediainfo,
file_path=scrape_path,
transfer_type=transfer_type,
force_nfo=force_nfo,
force_img=force_img)
else:
# 目录下的所有文件
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
@@ -483,18 +646,119 @@ class DoubanModule(_ModuleBase):
meta = MetaInfo(file.stem)
if not meta.name:
continue
# 根据名称查询豆瓣数据
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
mtype=mediainfo.type.value,
year=mediainfo.year,
season=meta.begin_season)
if not mediainfo.douban_id:
# 根据名称查询豆瓣数据
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
imdbid=mediainfo.imdb_id,
mtype=mediainfo.type,
year=mediainfo.year,
season=meta.begin_season)
if not doubaninfo:
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
break
# 查询豆瓣详情
doubaninfo = self.douban_info(doubanid=doubaninfo.get("id"), mtype=mediainfo.type)
else:
doubaninfo = self.douban_info(doubanid=mediainfo.douban_id,
mtype=mediainfo.type)
if not doubaninfo:
logger.warn(f"{mediainfo.title} 的豆瓣信息")
break
logger(f"获取{mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
continue
# 豆瓣媒体信息
mediainfo = MediaInfo(douban_info=doubaninfo)
# 补充图片
self.obtain_images(mediainfo)
# 刮削
self.scraper.gen_scraper_files(meta=meta,
mediainfo=MediaInfo(douban_info=doubaninfo),
file_path=file)
mediainfo=mediainfo,
file_path=file,
transfer_type=transfer_type,
force_nfo=force_nfo,
force_img=force_img)
except Exception as e:
logger.error(f"刮削文件 {file} 失败,原因:{e}")
logger.error(f"刮削文件 {file} 失败,原因:{str(e)}")
logger.info(f"{path} 刮削完成")
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
补充抓取媒体信息图片
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息
"""
if settings.RECOGNIZE_SOURCE != "douban":
return None
if not mediainfo.douban_id:
return None
if mediainfo.backdrop_path:
# 没有图片缺失
return mediainfo
# 调用图片接口
if not mediainfo.backdrop_path:
if mediainfo.type == MediaType.MOVIE:
info = self.doubanapi.movie_photos(mediainfo.douban_id)
else:
info = self.doubanapi.tv_photos(mediainfo.douban_id)
if not info:
return mediainfo
images = info.get("photos")
# 背景图
if images:
backdrop = images[0].get("image", {}).get("large") or {}
if backdrop:
mediainfo.backdrop_path = backdrop.get("url")
return mediainfo
def clear_cache(self):
"""
清除缓存
"""
logger.info("开始清除豆瓣缓存 ...")
self.doubanapi.clear_cache()
self.cache.clear()
logger.info("豆瓣缓存清除完成")
def douban_movie_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
"""
根据TMDBID查询电影演职员表
:param doubanid: 豆瓣ID
:param page: 页码
:param count: 数量
"""
result = self.doubanapi.movie_celebrities(subject_id=doubanid)
if not result:
return []
ret_list = result.get("actors") or []
if ret_list:
return ret_list[(page - 1) * count: page * count]
else:
return []
def douban_tv_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
"""
根据TMDBID查询电视剧演职员表
:param doubanid: 豆瓣ID
:param page: 页码
:param count: 数量
"""
result = self.doubanapi.tv_celebrities(subject_id=doubanid)
if not result:
return []
ret_list = result.get("actors") or []
if ret_list:
return ret_list[(page - 1) * count: page * count]
else:
return []
def douban_movie_recommend(self, doubanid: str) -> List[dict]:
"""
根据豆瓣ID查询推荐电影
:param doubanid: 豆瓣ID
"""
return self.doubanapi.movie_recommendations(subject_id=doubanid) or []
def douban_tv_recommend(self, doubanid: str) -> List[dict]:
"""
根据豆瓣ID查询推荐电视剧
:param doubanid: 豆瓣ID
"""
return self.doubanapi.tv_recommendations(subject_id=doubanid) or []

View File

@@ -18,28 +18,29 @@ class DoubanApi(metaclass=Singleton):
_urls = {
# 搜索类
# sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映
# q=search_word&start=0&count=20&sort=U
# q=search_word&start: int = 0&count: int = 20&sort=U
# 聚合搜索
"search": "/search/weixin",
"search_agg": "/search",
"imdbid": "/movie/imdb/%s",
# 电影探索
# sort=U:综合排序 T:近期热度 S:高分优先 R:首播时间
# tags='日本,动画,2022'&start=0&count=20&sort=U
# tags='日本,动画,2022'&start: int = 0&count: int = 20&sort=U
"movie_recommend": "/movie/recommend",
# 电视剧探索
"tv_recommend": "/tv/recommend",
# 搜索
"movie_tag": "/movie/tag",
"tv_tag": "/tv/tag",
# q=search_word&start=0&count=20
# q=search_word&start: int = 0&count: int = 20
"movie_search": "/search/movie",
"tv_search": "/search/movie",
"book_search": "/search/book",
"group_search": "/search/group",
# 各类主题合集
# start=0&count=20
# start: int = 0&count: int = 20
# 正在上映
"movie_showing": "/subject_collection/movie_showing/items",
# 热门电影
@@ -145,7 +146,9 @@ class DoubanApi(metaclass=Singleton):
"api-client/1 com.douban.frodo/7.3.0(207) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1"]
_api_secret_key = "bf7dddc7c9cfe6f7"
_api_key = "0dad551ec0f84ed02907ff5c42e8ec70"
_api_key2 = "0ab215a8b1977939201640fa14c66bab"
_base_url = "https://frodo.douban.com/api/v2"
_api_url = "https://api.douban.com/v2"
_session = None
def __init__(self):
@@ -153,6 +156,9 @@ class DoubanApi(metaclass=Singleton):
@classmethod
def __sign(cls, url: str, ts: int, method='GET') -> str:
"""
签名
"""
url_path = parse.urlparse(url).path
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
return base64.b64encode(
@@ -164,7 +170,10 @@ class DoubanApi(metaclass=Singleton):
).decode()
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
def __invoke(self, url, **kwargs):
def __invoke(self, url: str, **kwargs) -> dict:
"""
GET请求
"""
req_url = self._base_url + url
params = {'apiKey': self._api_key}
@@ -189,119 +198,224 @@ class DoubanApi(metaclass=Singleton):
return resp.json()
return resp.json() if resp else {}
def search(self, keyword, start=0, count=20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
def __post(self, url: str, **kwargs) -> dict:
"""
POST请求
esponse = requests.post(
url="https://api.douban.com/v2/movie/imdb/tt29139455",
headers={
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
"Cookie": "bid=J9zb1zA5sJc",
},
data={
"apikey": "0ab215a8b1977939201640fa14c66bab",
},
)
"""
req_url = self._api_url + url
params = {'apikey': self._api_key2}
if kwargs:
params.update(kwargs)
if '_ts' in params:
params.pop('_ts')
resp = RequestUtils(
ua=settings.USER_AGENT,
session=self._session,
).post_res(url=req_url, data=params)
if resp.status_code == 400 and "rate_limit" in resp.text:
return resp.json()
return resp.json() if resp else {}
def search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
"""
关键字搜索
"""
return self.__invoke(self._urls["search"], q=keyword,
start=start, count=count, _ts=ts)
def movie_search(self, keyword, start=0, count=20,
def imdbid(self, imdbid: str,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
IMDBID搜索
"""
return self.__post(self._urls["imdbid"] % imdbid, _ts=ts)
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影搜索
"""
return self.__invoke(self._urls["movie_search"], q=keyword,
start=start, count=count, _ts=ts)
def tv_search(self, keyword, start=0, count=20,
def tv_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视搜索
"""
return self.__invoke(self._urls["tv_search"], q=keyword,
start=start, count=count, _ts=ts)
def book_search(self, keyword, start=0, count=20,
def book_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
书籍搜索
"""
return self.__invoke(self._urls["book_search"], q=keyword,
start=start, count=count, _ts=ts)
def group_search(self, keyword, start=0, count=20,
def group_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
小组搜索
"""
return self.__invoke(self._urls["group_search"], q=keyword,
start=start, count=count, _ts=ts)
def movie_showing(self, start=0, count=20,
def movie_showing(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
正在热映
"""
return self.__invoke(self._urls["movie_showing"],
start=start, count=count, _ts=ts)
def movie_soon(self, start=0, count=20,
def movie_soon(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
即将上映
"""
return self.__invoke(self._urls["movie_soon"],
start=start, count=count, _ts=ts)
def movie_hot_gaia(self, start=0, count=20,
def movie_hot_gaia(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
热门电影
"""
return self.__invoke(self._urls["movie_hot_gaia"],
start=start, count=count, _ts=ts)
def tv_hot(self, start=0, count=20,
def tv_hot(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
热门剧集
"""
return self.__invoke(self._urls["tv_hot"],
start=start, count=count, _ts=ts)
def tv_animation(self, start=0, count=20,
def tv_animation(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
动画
"""
return self.__invoke(self._urls["tv_animation"],
start=start, count=count, _ts=ts)
def tv_variety_show(self, start=0, count=20,
def tv_variety_show(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
综艺
"""
return self.__invoke(self._urls["tv_variety_show"],
start=start, count=count, _ts=ts)
def tv_rank_list(self, start=0, count=20,
def tv_rank_list(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视剧排行榜
"""
return self.__invoke(self._urls["tv_rank_list"],
start=start, count=count, _ts=ts)
def show_hot(self, start=0, count=20,
def show_hot(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
综艺热门
"""
return self.__invoke(self._urls["show_hot"],
start=start, count=count, _ts=ts)
def movie_detail(self, subject_id):
def movie_detail(self, subject_id: str):
"""
电影详情
"""
return self.__invoke(self._urls["movie_detail"] + subject_id)
def movie_celebrities(self, subject_id):
def movie_celebrities(self, subject_id: str):
"""
电影演职员
"""
return self.__invoke(self._urls["movie_celebrities"] % subject_id)
def tv_detail(self, subject_id):
def tv_detail(self, subject_id: str):
"""
电视剧详情
"""
return self.__invoke(self._urls["tv_detail"] + subject_id)
def tv_celebrities(self, subject_id):
def tv_celebrities(self, subject_id: str):
"""
电视剧演职员
"""
return self.__invoke(self._urls["tv_celebrities"] % subject_id)
def book_detail(self, subject_id):
def book_detail(self, subject_id: str):
"""
书籍详情
"""
return self.__invoke(self._urls["book_detail"] + subject_id)
def movie_top250(self, start=0, count=20,
def movie_top250(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影TOP250
"""
return self.__invoke(self._urls["movie_top250"],
start=start, count=count, _ts=ts)
def movie_recommend(self, tags='', sort='R', start=0, count=20,
def movie_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影探索
"""
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort,
start=start, count=count, _ts=ts)
def tv_recommend(self, tags='', sort='R', start=0, count=20,
def tv_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视剧探索
"""
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort,
start=start, count=count, _ts=ts)
def tv_chinese_best_weekly(self, start=0, count=20,
def tv_chinese_best_weekly(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
华语口碑周榜
"""
return self.__invoke(self._urls["tv_chinese_best_weekly"],
start=start, count=count, _ts=ts)
def tv_global_best_weekly(self, start=0, count=20,
def tv_global_best_weekly(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
全球口碑周榜
"""
return self.__invoke(self._urls["tv_global_best_weekly"],
start=start, count=count, _ts=ts)
def doulist_detail(self, subject_id):
def doulist_detail(self, subject_id: str):
"""
豆列详情
:param subject_id: 豆列id
"""
return self.__invoke(self._urls["doulist"] + subject_id)
def doulist_items(self, subject_id, start=0, count=20,
def doulist_items(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
豆列列表
@@ -313,6 +427,60 @@ class DoubanApi(metaclass=Singleton):
return self.__invoke(self._urls["doulist_items"] % subject_id,
start=start, count=count, _ts=ts)
def movie_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影推荐
:param subject_id: 电影id
:param start: 开始
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["movie_recommendations"] % subject_id,
start=start, count=count, _ts=ts)
def tv_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视剧推荐
:param subject_id: 电视剧id
:param start: 开始
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["tv_recommendations"] % subject_id,
start=start, count=count, _ts=ts)
def movie_photos(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电影剧照
:param subject_id: 电影id
:param start: 开始
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["movie_photos"] % subject_id,
start=start, count=count, _ts=ts)
def tv_photos(self, subject_id: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
电视剧剧照
:param subject_id: 电视剧id
:param start: 开始
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["tv_photos"] % subject_id,
start=start, count=count, _ts=ts)
def clear_cache(self):
"""
清空LRU缓存
"""
self.__invoke.cache_clear()
def __del__(self):
if self._session:
self._session.close()

View File

@@ -0,0 +1,234 @@
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.core.metainfo import MetaInfo
from app.log import logger
from app.utils.singleton import Singleton
from app.schemas.types import MediaType
lock = RLock()
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
EXPIRE_TIMESTAMP = settings.CACHE_CONF.get('meta')
class DoubanCache(metaclass=Singleton):
"""
豆瓣缓存数据
{
"id": '',
"title": '',
"year": '',
"type": MediaType
}
"""
_meta_data: dict = {}
# 缓存文件路径
_meta_path: Path = None
# TMDB缓存过期
_tmdb_cache_expire: bool = True
def __init__(self):
self._meta_path = settings.TEMP_PATH / "__douban_cache__"
self._meta_data = self.__load(self._meta_path)
def clear(self):
"""
清空所有TMDB缓存
"""
with lock:
self._meta_data = {}
@staticmethod
def __get_key(meta: MetaBase) -> str:
"""
获取缓存KEY
"""
return f"[{meta.type.value if meta.type else '未知'}]{meta.name}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""
根据KEY值获取缓存值
"""
key = self.__get_key(meta)
with lock:
info: dict = self._meta_data.get(key)
if info:
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
if not expire or int(time.time()) < expire:
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
self._meta_data[key] = info
elif expire and self._tmdb_cache_expire:
self.delete(key)
return info or {}
def delete(self, key: str) -> dict:
"""
删除缓存信息
@param key: 缓存key
@return: 被删除的缓存内容
"""
with lock:
return self._meta_data.pop(key, None)
def delete_by_doubanid(self, doubanid: str) -> None:
"""
清空对应豆瓣ID的所有缓存记录以强制更新TMDB中最新的数据
"""
for key in list(self._meta_data):
if self._meta_data.get(key, {}).get("id") == doubanid:
with lock:
self._meta_data.pop(key)
def delete_unknown(self) -> None:
"""
清除未识别的缓存记录以便重新搜索TMDB
"""
for key in list(self._meta_data):
if self._meta_data.get(key, {}).get("id") == "0":
with lock:
self._meta_data.pop(key)
def modify(self, key: str, title: str) -> dict:
"""
删除缓存信息
@param key: 缓存key
@param title: 标题
@return: 被修改后缓存内容
"""
with lock:
if self._meta_data.get(key):
self._meta_data[key]['title'] = title
self._meta_data[key][CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
return self._meta_data.get(key)
@staticmethod
def __load(path: Path) -> dict:
"""
从文件中加载缓存
"""
try:
if path.exists():
with open(path, 'rb') as f:
data = pickle.load(f)
return data
return {}
except Exception as e:
logger.error(f"加载缓存失败: {str(e)} - {traceback.format_exc()}")
return {}
def update(self, meta: MetaBase, info: dict) -> None:
"""
新增或更新缓存条目
"""
with lock:
if info:
# 缓存标题
cache_title = info.get("title")
# 缓存年份
cache_year = info.get('year')
# 类型
if isinstance(info.get('media_type'), MediaType):
mtype = info.get('media_type')
elif info.get("type"):
mtype = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
else:
meta = MetaInfo(cache_title)
if meta.begin_season:
mtype = MediaType.TV
else:
mtype = MediaType.MOVIE
# 海报
poster_path = info.get("pic", {}).get("large")
if not poster_path and info.get("cover_url"):
poster_path = info.get("cover_url")
if not poster_path and info.get("cover"):
poster_path = info.get("cover").get("url")
self._meta_data[self.__get_key(meta)] = {
"id": info.get("id"),
"type": mtype,
"year": cache_year,
"title": cache_title,
"poster_path": poster_path,
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
}
elif info is not None:
# None时不缓存此时代表网络错误允许重复请求
self._meta_data[self.__get_key(meta)] = {'id': "0"}
def save(self, force: bool = False) -> None:
"""
保存缓存数据到文件
"""
meta_data = self.__load(self._meta_path)
new_meta_data = {k: v for k, v in self._meta_data.items() if v.get("id")}
if not force \
and not self._random_sample(new_meta_data) \
and meta_data.keys() == new_meta_data.keys():
return
with open(self._meta_path, 'wb') as f:
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
def _random_sample(self, new_meta_data: dict) -> bool:
"""
采样分析是否需要保存
"""
ret = False
if len(new_meta_data) < 25:
keys = list(new_meta_data.keys())
for k in keys:
info = new_meta_data.get(k)
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
if not expire:
ret = True
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
elif int(time.time()) >= expire:
ret = True
if self._tmdb_cache_expire:
new_meta_data.pop(k)
else:
count = 0
keys = random.sample(sorted(new_meta_data.keys()), 25)
for k in keys:
info = new_meta_data.get(k)
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
if not expire:
ret = True
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
elif int(time.time()) >= expire:
ret = True
if self._tmdb_cache_expire:
new_meta_data.pop(k)
count += 1
if count >= 5:
ret |= self._random_sample(new_meta_data)
return ret
def get_title(self, key: str) -> Optional[str]:
"""
获取缓存的标题
"""
cache_media_info = self._meta_data.get(key)
if not cache_media_info or not cache_media_info.get("id"):
return None
return cache_media_info.get("title")
def set_title(self, key: str, cn_title: str) -> None:
"""
重新设置缓存标题
"""
cache_media_info = self._meta_data.get(key)
if not cache_media_info:
return
self._meta_data[key]['title'] = cn_title

View File

@@ -1,54 +1,85 @@
import time
from pathlib import Path
from typing import Union
from xml.dom import minidom
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.log import logger
from app.schemas.types import MediaType
from app.utils.dom import DomUtils
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils
class DoubanScraper:
_transfer_type = settings.TRANSFER_TYPE
_force_nfo = False
_force_img = False
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo, file_path: Path):
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
file_path: Path, transfer_type: str,
force_nfo: bool = False, force_img: bool = False):
"""
生成刮削文件
:param meta: 元数据
:param mediainfo: 媒体信息
:param file_path: 文件路径或者目录路径
:param transfer_type: 转输类型
:param force_nfo: 强制生成NFO
:param force_img: 强制生成图片
"""
self._transfer_type = transfer_type
self._force_nfo = force_nfo
self._force_img = force_img
try:
# 电影
if mediainfo.type == MediaType.MOVIE:
# 强制或者不已存在时才处理
if not file_path.with_name("movie.nfo").exists() \
and not file_path.with_suffix(".nfo").exists():
if self._force_nfo or (not file_path.with_name("movie.nfo").exists()
and not file_path.with_suffix(".nfo").exists()):
# 生成电影描述文件
self.__gen_movie_nfo_file(mediainfo=mediainfo,
file_path=file_path)
# 生成电影图片
self.__save_image(url=mediainfo.poster_path,
file_path=file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}"))
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.poster_path,
file_path=image_path)
# 背景图
if mediainfo.backdrop_path:
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.backdrop_path,
file_path=image_path)
# 电视剧
else:
# 不存在时才处理
if not file_path.parent.with_name("tvshow.nfo").exists():
if self._force_nfo or not file_path.parent.with_name("tvshow.nfo").exists():
# 根目录描述文件
self.__gen_tv_nfo_file(mediainfo=mediainfo,
dir_path=file_path.parents[1])
# 生成根目录图片
self.__save_image(url=mediainfo.poster_path,
file_path=file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}"))
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.poster_path,
file_path=image_path)
# 背景图
if mediainfo.backdrop_path:
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
if self._force_img or not image_path.exists():
self.__save_image(url=mediainfo.backdrop_path,
file_path=image_path)
# 季目录NFO
if not file_path.with_name("season.nfo").exists():
if self._force_nfo or not file_path.with_name("season.nfo").exists():
self.__gen_tv_season_nfo_file(mediainfo=mediainfo,
season=meta.begin_season,
season_path=file_path.parent)
except Exception as e:
logger.error(f"{file_path} 刮削失败:{e}")
logger.error(f"{file_path} 刮削失败:{str(e)}")
@staticmethod
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
@@ -154,31 +185,51 @@ class DoubanScraper:
# 保存
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
@staticmethod
def __save_image(url: str, file_path: Path):
def __save_image(self, url: str, file_path: Path):
"""
下载图片并保存
"""
if file_path.exists():
if not url:
return
try:
# 没有后缀时处理URL转化为jpg格式
if not file_path.suffix:
url = url.replace("/format/webp", "/format/jpg")
file_path.with_suffix(".jpg")
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
r = RequestUtils().get_res(url=url)
if r:
file_path.write_bytes(r.content)
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content)
else:
file_path.write_bytes(r.content)
logger.info(f"图片已保存:{file_path}")
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
except Exception as err:
logger.error(f"{file_path.stem}图片下载失败:{err}")
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
@staticmethod
def __save_nfo(doc, file_path: Path):
def __save_nfo(self, doc, file_path: Path):
"""
保存NFO
"""
if file_path.exists():
return
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
file_path.write_bytes(xml_str)
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, xml_str)
else:
file_path.write_bytes(xml_str)
logger.info(f"NFO文件已保存{file_path}")
def __save_remove_file(self, out_file: Path, content: Union[str, bytes]):
"""
保存文件到远端
"""
temp_file = settings.TEMP_PATH / str(out_file)[1:]
temp_file_dir = temp_file.parent
if not temp_file_dir.exists():
temp_file_dir.mkdir(parents=True, exist_ok=True)
temp_file.write_bytes(content)
if self._transfer_type == 'rclone_move':
SystemUtils.rclone_move(temp_file, out_file)
elif self._transfer_type == 'rclone_copy':
SystemUtils.rclone_copy(temp_file, out_file)

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import Optional, Tuple, Union, Any, List, Generator
from app import schemas
@@ -26,7 +25,7 @@ class EmbyModule(_ModuleBase):
定时任务每10分钟调用一次
"""
# 定时重连
if not self.emby.is_inactive():
if self.emby.is_inactive():
self.emby.reconnect()
def user_authenticate(self, name: str, password: str) -> Optional[str]:
@@ -96,24 +95,6 @@ class EmbyModule(_ModuleBase):
itemid=itemid
)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
items = [
schemas.RefreshMediaItem(
title=mediainfo.title,
year=mediainfo.year,
type=mediainfo.type,
category=mediainfo.category,
target_path=file_path
)
]
self.emby.refresh_library_by_items(items)
def media_statistic(self) -> List[schemas.Statistic]:
"""
媒体数量统计
@@ -122,13 +103,13 @@ class EmbyModule(_ModuleBase):
media_statistic.user_count = self.emby.get_user_count()
return [media_statistic]
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "emby":
if server and server != "emby":
return None
return self.emby.get_librarys()
return self.emby.get_librarys(username)
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
@@ -160,3 +141,29 @@ class EmbyModule(_ModuleBase):
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
if server and server != "emby":
return []
return self.emby.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取媒体库播放地址
"""
if server != "emby":
return None
return self.emby.get_play_url(item_id)
def mediaserver_latest(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
if server and server != "emby":
return []
return self.emby.get_latest(num=count, username=username)

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
@@ -22,9 +23,16 @@ class Emby(metaclass=Singleton):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._playhost = settings.EMBY_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._apikey = settings.EMBY_API_KEY
self.user = self.get_user()
self.user = self.get_user(settings.SUPERUSER)
self.folders = self.get_emby_folders()
self.serverid = self.get_server_id()
def is_inactive(self) -> bool:
"""
@@ -59,13 +67,52 @@ class Emby(metaclass=Singleton):
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
def __get_emby_librarys(self) -> List[dict]:
def get_emby_virtual_folders(self) -> List[dict]:
"""
获取Emby媒体库所有路径列表包含共享路径
"""
if not self._host or not self._apikey:
return []
req_url = "%semby/Library/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
library_items = res.json().get("Items")
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
return []
def __get_emby_librarys(self, username: str = None) -> List[dict]:
"""
获取Emby媒体库列表
"""
if not self._host or not self._apikey:
return []
req_url = f"{self._host}emby/Users/{self.user}/Views?api_key={self._apikey}"
if username:
user = self.get_user(username)
else:
user = self.user
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -77,14 +124,17 @@ class Emby(metaclass=Singleton):
logger.error(f"连接User/Views 出错:" + str(e))
return []
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
libraries = []
for library in self.__get_emby_librarys() or []:
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.__get_emby_librarys(username) or []:
if library.get("Name") in black_list:
continue
match library.get("CollectionType"):
case "movies":
library_type = MediaType.MOVIE.value
@@ -92,13 +142,17 @@ class Emby(metaclass=Singleton):
library_type = MediaType.TV.value
case _:
continue
image = self.__get_local_image_by_id(library.get("Id"))
libraries.append(
schemas.MediaServerLibrary(
server="emby",
id=library.get("Id"),
name=library.get("Name"),
path=library.get("Path"),
type=library_type
type=library_type,
image=image,
link=f'{self._playhost or self._host}web/index.html'
f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}'
)
)
return libraries
@@ -244,7 +298,7 @@ class Emby(metaclass=Singleton):
"&Limit=10"
"&IncludeSearchTypes=false"
"&api_key=%s") % (
self._host, name, self._apikey)
self._host, name, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -366,7 +420,7 @@ class Emby(metaclass=Singleton):
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
# 返回
return tv_item.get("Id"), season_episodes
return item_id, season_episodes
except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None
@@ -482,7 +536,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"):
@@ -800,10 +854,13 @@ class Emby(metaclass=Singleton):
eventType = message.get('Event')
if not eventType:
return None
logger.info(f"接收到emby webhook{message}")
logger.debug(f"接收到emby webhook{message}")
eventItem = schemas.WebhookEventInfo(event=eventType, channel="emby")
if message.get('Item'):
if message.get('Item', {}).get('Type') == 'Episode':
eventItem.media_type = message.get('Item', {}).get('Type')
if message.get('Item', {}).get('Type') == 'Episode' \
or message.get('Item', {}).get('Type') == 'Series' \
or message.get('Item', {}).get('Type') == 'Season':
eventItem.item_type = "TV"
if message.get('Item', {}).get('SeriesName') \
and message.get('Item', {}).get('ParentIndexNumber') \
@@ -813,6 +870,10 @@ class Emby(metaclass=Singleton):
"S" + str(message.get('Item', {}).get('ParentIndexNumber')),
"E" + str(message.get('Item', {}).get('IndexNumber')),
message.get('Item', {}).get('Name'))
elif message.get('Item', {}).get('SeriesName'):
eventItem.item_name = "%s %s" % (
message.get('Item', {}).get('SeriesName'),
message.get('Item', {}).get('Name'))
else:
eventItem.item_name = message.get('Item', {}).get('Name')
eventItem.item_id = message.get('Item', {}).get('SeriesId')
@@ -839,7 +900,7 @@ class Emby(metaclass=Singleton):
eventItem.overview = message.get('Item', {}).get('Overview')
eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage')
if not eventItem.percentage:
if message.get('PlaybackInfo', {}).get('PositionTicks'):
if message.get('PlaybackInfo', {}).get('PositionTicks') and message.get('Item', {}).get('RunTimeTicks'):
eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \
message.get('Item', {}).get('RunTimeTicks') * 100
if message.get('Session'):
@@ -900,3 +961,160 @@ class Emby(metaclass=Singleton):
except Exception as e:
logger.error(f"连接Emby出错" + str(e))
return None
def get_play_url(self, item_id: str) -> str:
"""
拼装媒体播放链接
:param item_id: 媒体的的ID
"""
return f"{self._playhost or self._host}web/index.html#!" \
f"/item?id={item_id}&context=home&serverId={self.serverid}"
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
"""
获取Emby的Backdrop图片地址
:param: item_id: 在Emby中的ID
:param: image_tag: 图片的tag
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
if not image_tag or not item_id:
return ""
return f"{self._host}Items/{item_id}/" \
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
def __get_local_image_by_id(self, item_id: str) -> str:
"""
根据ItemId从媒体服务器查询本地图片地址
:param: item_id: 在Emby中的ID
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
return "%sItems/%s/Images/Primary" % (self._host, item_id)
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得继续观看
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Resume?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
if item_type == MediaType.MOVIE.value:
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
else:
image = self.__get_backdrop_url(item_id=item.get("SeriesId"),
image_tag=item.get("SeriesPrimaryImageTag"))
if not image:
image = self.__get_local_image_by_id(item.get("SeriesId"))
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错" + str(e))
return []
def get_latest(self, num: int = 20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得最近更新
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Latest?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []
def get_user_library_folders(self):
"""
获取Emby媒体库文件夹列表排除黑名单
"""
if not self._host or not self._apikey:
return []
library_folders = []
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.get_emby_virtual_folders() or []:
if library.get("Name") in black_list:
continue
library_folders += [folder for folder in library.get("Path")]
return library_folders

View File

@@ -326,17 +326,21 @@ class FanartModule(_ModuleBase):
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息
"""
if not settings.FANART_ENABLE:
return None
if not mediainfo.tmdb_id and not mediainfo.tvdb_id:
return None
if mediainfo.type == MediaType.MOVIE:
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
else:
if mediainfo.tvdb_id:
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
else:
logger.info(f"{mediainfo.title_year} 没有tvdbid无法获取Fanart图片")
return
logger.info(f"{mediainfo.title_year} 没有tvdbid无法获取fanart图片")
return None
if not result or result.get('status') == 'error':
logger.warn(f"没有获取到 {mediainfo.title_year}Fanart图片数据")
return
logger.warn(f"没有获取到 {mediainfo.title_year}fanart图片数据")
return None
# 获取所有图片
for name, images in result.items():
if not images:
@@ -382,5 +386,5 @@ class FanartModule(_ModuleBase):
if ret:
return ret.json()
except Exception as err:
logger.error(f"获取{queryid}的Fanart图片失败{err}")
logger.error(f"获取{queryid}的Fanart图片失败{str(err)}")
return None

View File

@@ -8,11 +8,12 @@ from jinja2 import Template
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.log import logger
from app.modules import _ModuleBase
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
from app.schemas.types import MediaType
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
lock = Lock()
@@ -44,14 +45,28 @@ class FileTransferModule(_ModuleBase):
"""
# 获取目标路径
if not target:
# 未指定目的目录,根据源目录选择一个媒体库
target = self.get_target_path(in_path=path)
# 拼装媒体库一、二级子目录
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target)
else:
target = self.get_library_path(target)
# 指定了目的目录
if target.is_file():
logger.error(f"转移目标路径是一个文件 {target} 是一个文件")
return TransferInfo(success=False,
path=path,
message=f"{target} 不是有效目录")
# 只拼装二级子目录(不要一级目录)
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target, typename_dir=False)
if not target:
logger.error("未找到媒体库目录,无法转移文件")
return TransferInfo(success=False,
path=path,
message="未找到媒体库目录")
else:
logger.info(f"获取转移目标路径:{target}")
# 转移
return self.transfer_media(in_path=path,
in_meta=meta,
@@ -80,6 +95,12 @@ class FileTransferModule(_ModuleBase):
elif transfer_type == 'move':
# 移动
retcode, retmsg = SystemUtils.move(file_item, target_file)
elif transfer_type == 'rclone_move':
# Rclone 移动
retcode, retmsg = SystemUtils.rclone_move(file_item, target_file)
elif transfer_type == 'rclone_copy':
# Rclone 复制
retcode, retmsg = SystemUtils.rclone_copy(file_item, target_file)
else:
# 复制
retcode, retmsg = SystemUtils.copy(file_item, target_file)
@@ -323,33 +344,42 @@ class FileTransferModule(_ModuleBase):
over_flag=over_flag)
@staticmethod
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path, typename_dir: bool = True) -> Path:
"""
根据设置并装媒体库目录
:param mediainfo: 媒体信息
:target_dir: 媒体库根目录
:typename_dir: 是否加上类型目录
"""
if not target_dir:
return target_dir
if mediainfo.type == MediaType.MOVIE:
# 电影
if settings.LIBRARY_MOVIE_NAME:
if typename_dir:
# 目的目录加上类型和二级分类
target_dir = target_dir / settings.LIBRARY_MOVIE_NAME / mediainfo.category
else:
# 目的目录加上类型和二级分类
target_dir = target_dir / mediainfo.type.value / mediainfo.category
# 目的目录加上二级分类
target_dir = target_dir / mediainfo.category
if mediainfo.type == MediaType.TV:
# 电视剧
if settings.LIBRARY_ANIME_NAME \
and mediainfo.genre_ids \
if mediainfo.genre_ids \
and set(mediainfo.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
target_dir = target_dir / settings.LIBRARY_ANIME_NAME / mediainfo.category
elif settings.LIBRARY_TV_NAME:
# 电视剧
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
if typename_dir:
target_dir = target_dir / (settings.LIBRARY_ANIME_NAME
or settings.LIBRARY_TV_NAME) / mediainfo.category
else:
target_dir = target_dir / mediainfo.category
else:
# 目的目录加上类型和二级分类
target_dir = target_dir / mediainfo.type.value / mediainfo.category
# 电视剧
if typename_dir:
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
else:
target_dir = target_dir / mediainfo.category
return target_dir
def transfer_media(self,
@@ -376,13 +406,11 @@ class FileTransferModule(_ModuleBase):
path=in_path,
message=f"{in_path} 路径不存在")
if not target_dir.exists():
return TransferInfo(success=False,
path=in_path,
message=f"{target_dir} 目标路径不存在")
# 媒体库目的目录
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
if transfer_type not in ['rclone_copy', 'rclone_move']:
# 检查目标路径
if not target_dir.exists():
logger.info(f"目标路径不存在,正在创建:{target_dir} ...")
target_dir.mkdir(parents=True, exist_ok=True)
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
@@ -395,6 +423,8 @@ class FileTransferModule(_ModuleBase):
bluray_flag = SystemUtils.is_bluray_dir(in_path)
if bluray_flag:
logger.info(f"{in_path} 是蓝光原盘文件夹")
# 原文件大小
file_size = in_path.stat().st_size
# 目的路径
new_path = self.get_rename_path(
path=target_dir,
@@ -419,7 +449,7 @@ class FileTransferModule(_ModuleBase):
return TransferInfo(success=True,
path=in_path,
target_path=new_path,
total_size=new_path.stat().st_size,
total_size=file_size,
is_bluray=bluray_flag)
else:
# 转移单个文件
@@ -457,10 +487,38 @@ class FileTransferModule(_ModuleBase):
# 判断是否要覆盖
overflag = False
if new_file.exists():
if new_file.stat().st_size < in_path.stat().st_size:
logger.info(f"目标文件已存在,但文件大小更小,将覆盖:{new_file}")
overflag = True
# 目标文件已存在
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
match settings.OVERWRITE_MODE:
case 'always':
# 总是覆盖同名文件
overflag = True
case 'size':
# 存在时大覆盖小
if new_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"媒体库中已存在,当前设置为不覆盖",
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
# 原文件大小
file_size = in_path.stat().st_size
# 转移文件
retcode = self.__transfer_file(file_item=in_path,
new_file=new_file,
@@ -479,7 +537,7 @@ class FileTransferModule(_ModuleBase):
path=in_path,
target_path=new_file,
file_count=1,
total_size=new_file.stat().st_size,
total_size=file_size,
is_bluray=False,
file_list=[str(in_path)],
file_list_new=[str(new_file)])
@@ -505,12 +563,14 @@ class FileTransferModule(_ModuleBase):
return {
# 标题
"title": mediainfo.title,
# 原文件名
"original_name": f"{meta.org_string}{file_ext}",
# 原语种标题
"original_title": mediainfo.original_title,
# 识别名称
# 原文件名
"original_name": f"{meta.org_string}{file_ext}",
# 识别名称(优先使用中文)
"name": meta.name,
# 识别的英文名称(可能为空)
"en_name": meta.en_name,
# 年份
"year": mediainfo.year or meta.year,
# 资源类型
@@ -531,6 +591,8 @@ class FileTransferModule(_ModuleBase):
"tmdbid": mediainfo.tmdb_id,
# IMDBID
"imdbid": mediainfo.imdb_id,
# 豆瓣ID
"doubanid": mediainfo.douban_id,
# 季号
"season": meta.season_seq,
# 集号
@@ -565,7 +627,7 @@ class FileTransferModule(_ModuleBase):
@staticmethod
def get_library_path(path: Path):
"""
根据目录查询其所在的媒体库目录,查询不到的返回输入目录
根据文件路径查询其所在的媒体库目录,查询不到的返回输入目录
"""
if not path:
return None
@@ -578,7 +640,7 @@ class FileTransferModule(_ModuleBase):
if path.is_relative_to(libpath):
return libpath
except Exception as e:
logger.debug(f"计算媒体库路径时出错:{e}")
logger.debug(f"计算媒体库路径时出错:{str(e)}")
continue
return path
@@ -601,29 +663,29 @@ class FileTransferModule(_ModuleBase):
if in_path:
for path in dest_paths:
try:
relative = in_path.relative_to(path).as_posix()
# 计算in_path和path的公共字符串长度
relative = StringUtils.find_common_prefix(str(in_path), str(path))
if len(relative) > max_length:
max_length = len(relative)
target_path = path
except Exception as e:
logger.debug(f"计算目标路径时出错:{e}")
logger.debug(f"计算目标路径时出错:{str(e)}")
continue
if target_path:
return target_path
# 顺序匹配第1个满足空间存储要求的目录
if in_path.exists():
file_size = in_path.stat().st_size
for path in dest_paths:
if SystemUtils.free_space(path) > file_size:
return path
# 顺序匹配第1个满足空间存储要求的目录
if in_path.exists():
file_size = in_path.stat().st_size
for path in dest_paths:
if SystemUtils.free_space(path) > file_size:
return path
# 默认返回第1个
return dest_paths[0]
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
"""
判断媒体文件是否存在于本地文件系统
判断媒体文件是否存在于本地文件系统,只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
:param itemid: 媒体服务器ItemID
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if not settings.LIBRARY_PATHS:
@@ -684,3 +746,34 @@ class FileTransferModule(_ModuleBase):
return ExistMediaInfo(type=MediaType.TV, seasons=seasons)
# 不存在
return None
@staticmethod
def delete_all_version_files(path: Path) -> bool:
"""
删除目录下的所有版本文件
:param path: 目录路径
"""
if not path.exists():
return False
# 识别文件中的季集信息
meta = MetaInfoPath(path)
season = meta.season
episode = meta.episode
# 检索媒体文件
logger.warn(f"正在删除目标目录中其它版本的文件:{path.parent}")
media_files = SystemUtils.list_files(directory=path.parent, extensions=settings.RMT_MEDIAEXT)
if not media_files:
logger.info(f"目录中没有媒体文件:{path.parent}")
return False
# 删除文件
for media_file in media_files:
if media_file == path:
continue
# 识别文件中的季集信息
filemeta = MetaInfoPath(media_file)
# 相同季集的文件才删除
if filemeta.season != season or filemeta.episode != episode:
continue
logger.info(f"正在删除文件:{media_file}")
media_file.unlink()
return True

View File

@@ -70,16 +70,26 @@ class FilterModule(_ModuleBase):
"include": [r'[Hx].?264|AVC'],
"exclude": []
},
# 杜比
# 杜比视界
"DOLBY": {
"include": [r"Dolby[\s.]+Vision|DOVI|[\s.]+DV[\s.]+|杜比视界"],
"exclude": []
},
# 杜比全景声
"ATMOS": {
"include": [r"Dolby[\s.+]+Atmos|Atmos|杜比全景[声聲]"],
"exclude": []
},
# HDR
"HDR": {
"include": [r"[\s.]+HDR[\s.]+|HDR10|HDR10\+"],
"exclude": []
},
# SDR
"SDR": {
"include": [r"[\s.]+SDR[\s.]+"],
"exclude": []
},
# 重编码
"REMUX": {
"include": [r'REMUX'],
@@ -98,7 +108,22 @@ class FilterModule(_ModuleBase):
"CNVOI": {
"include": [r'[国國][语語]配音|[国國]配|[国國][语語]'],
"exclude": []
}
},
# 粤语配音
"HKVOI": {
"include": [r'粤语配音|粤语'],
"exclude": []
},
# 60FPS
"60FPS": {
"include": [r'60fps'],
"exclude": []
},
# 3D
"3D": {
"include": [r'3D'],
"exclude": []
},
}
def init_module(self) -> None:

View File

@@ -43,7 +43,7 @@ class IndexerModule(_ModuleBase):
# 确认搜索的名字
if not keywords:
# 浏览种子页
keywords = [None]
keywords = ['']
# 开始索引
result_array = []
@@ -92,7 +92,7 @@ class IndexerModule(_ModuleBase):
if result_array:
break
except Exception as err:
logger.error(f"{site.get('name')} 搜索出错:{err}")
logger.error(f"{site.get('name')} 搜索出错:{str(err)}")
# 索引花费的时间
seconds = round((datetime.now() - start_time).seconds, 1)

View File

@@ -1,6 +1,7 @@
import copy
import datetime
import re
import traceback
from typing import List
from urllib.parse import quote, urlencode
@@ -12,9 +13,9 @@ from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.helper.browser import PlaywrightHelper
from app.log import logger
from app.schemas.types import MediaType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.schemas.types import MediaType
class TorrentSpider:
@@ -254,7 +255,7 @@ class TorrentSpider:
# 解码为字符串
page_source = raw_data.decode(encoding)
except Exception as e:
logger.debug(f"chardet解码失败{e}")
logger.debug(f"chardet解码失败{str(e)}")
# 探测utf-8解码
if re.search(r"charset=\"?utf-8\"?", ret.text, re.IGNORECASE):
ret.encoding = "utf-8"
@@ -547,6 +548,29 @@ class TorrentSpider:
else:
self.torrents_info['labels'] = []
def __get_free_date(self, torrent):
# free date
if 'freedate' not in self.fields:
return
selector = self.fields.get('freedate', {})
freedate = torrent(selector.get('selector', '')).clone()
self.__remove(freedate, selector)
items = self.__attribute_or_text(freedate, selector)
self.torrents_info['freedate'] = self.__index(items, selector)
self.torrents_info['freedate'] = self.__filter_text(self.torrents_info.get('freedate'),
selector.get('filters'))
def __get_hit_and_run(self, torrent):
# hitandrun
if 'hr' not in self.fields:
return
selector = self.fields.get('hr', {})
hit_and_run = torrent(selector.get('selector', ''))
if hit_and_run:
self.torrents_info['hit_and_run'] = True
else:
self.torrents_info['hit_and_run'] = False
def get_info(self, torrent) -> dict:
"""
解析单条种子数据
@@ -566,13 +590,15 @@ class TorrentSpider:
self.__get_uploadvolumefactor(torrent)
self.__get_pubdate(torrent)
self.__get_date_elapsed(torrent)
self.__get_free_date(torrent)
self.__get_labels(torrent)
self.__get_hit_and_run(torrent)
except Exception as err:
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
return self.torrents_info
@staticmethod
def __filter_text(text, filters):
def __filter_text(text: str, filters: list):
"""
对文件进行处理
"""
@@ -583,8 +609,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])
@@ -599,7 +625,7 @@ class TorrentSpider:
elif method_name == "appendleft":
text = f"{args}{text}"
except Exception as err:
print(str(err))
logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}')
return text.strip()
@staticmethod
@@ -613,7 +639,7 @@ class TorrentSpider:
item.remove(v)
@staticmethod
def __attribute_or_text(item, selector):
def __attribute_or_text(item, selector: dict):
if not selector:
return item
if not item:
@@ -625,7 +651,7 @@ class TorrentSpider:
return items
@staticmethod
def __index(items, selector):
def __index(items: list, selector: dict):
if not items:
return None
if selector:
@@ -661,4 +687,4 @@ class TorrentSpider:
return self.torrents_info_array
except Exception as err:
self.is_error = True
logger.warn(f"错误:{self.indexername} {err}")
logger.warn(f"错误:{self.indexername} {str(err)}")

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import Optional, Tuple, Union, Any, List, Generator
from app import schemas
@@ -23,7 +22,7 @@ class JellyfinModule(_ModuleBase):
定时任务每10分钟调用一次
"""
# 定时重连
if not self.jellyfin.is_inactive():
if self.jellyfin.is_inactive():
self.jellyfin.reconnect()
def stop(self):
@@ -94,15 +93,6 @@ class JellyfinModule(_ModuleBase):
itemid=itemid
)
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
self.jellyfin.refresh_root_library()
def media_statistic(self) -> List[schemas.Statistic]:
"""
媒体数量统计
@@ -111,13 +101,13 @@ class JellyfinModule(_ModuleBase):
media_statistic.user_count = self.jellyfin.get_user_count()
return [media_statistic]
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
"""
媒体库列表
"""
if server != "jellyfin":
if server and server != "jellyfin":
return None
return self.jellyfin.get_librarys()
return self.jellyfin.get_librarys(username)
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
"""
@@ -149,3 +139,29 @@ class JellyfinModule(_ModuleBase):
season=season,
episodes=episodes
) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
if server and server != "jellyfin":
return []
return self.jellyfin.get_resume(num=count, username=username)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取媒体库播放地址
"""
if server != "jellyfin":
return None
return self.jellyfin.get_play_url(item_id)
def mediaserver_latest(self, count: int = 20,
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
if server and server != "jellyfin":
return []
return self.jellyfin.get_latest(num=count, username=username)

View File

@@ -20,8 +20,14 @@ class Jellyfin(metaclass=Singleton):
self._host += "/"
if not self._host.startswith("http"):
self._host = "http://" + self._host
self._playhost = settings.JELLYFIN_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._apikey = settings.JELLYFIN_API_KEY
self.user = self.get_user()
self.user = self.get_user(settings.SUPERUSER)
self.serverid = self.get_server_id()
def is_inactive(self) -> bool:
@@ -39,13 +45,70 @@ class Jellyfin(metaclass=Singleton):
self.user = self.get_user()
self.serverid = self.get_server_id()
def __get_jellyfin_librarys(self) -> List[dict]:
def get_jellyfin_folders(self) -> List[dict]:
"""
获取Jellyfin媒体库路径列表
"""
if not self._host or not self._apikey:
return []
req_url = "%Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
def get_jellyfin_virtual_folders(self) -> List[dict]:
"""
获取Jellyfin媒体库所有路径列表包含共享路径
"""
if not self._host or not self._apikey:
return []
req_url = "%sLibrary/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
library_items = res.json().get("Items")
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
return []
def __get_jellyfin_librarys(self, username: str = None) -> List[dict]:
"""
获取Jellyfin媒体库的信息
"""
if not self._host or not self._apikey:
return []
req_url = f"{self._host}Users/{self.user}/Views?api_key={self._apikey}"
if username:
user = self.get_user(username)
else:
user = self.user
req_url = f"{self._host}Users/{user}/Views?api_key={self._apikey}"
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -57,14 +120,17 @@ class Jellyfin(metaclass=Singleton):
logger.error(f"连接Users/Views 出错:" + str(e))
return []
def get_librarys(self):
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库列表
"""
if not self._host or not self._apikey:
return []
libraries = []
for library in self.__get_jellyfin_librarys() or []:
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.__get_jellyfin_librarys(username) or []:
if library.get("Name") in black_list:
continue
match library.get("CollectionType"):
case "movies":
library_type = MediaType.MOVIE.value
@@ -72,13 +138,21 @@ class Jellyfin(metaclass=Singleton):
library_type = MediaType.TV.value
case _:
continue
image = self.__get_local_image_by_id(library.get("Id"))
link = f"{self._playhost or self._host}web/index.html#!" \
f"/movies.html?topParentId={library.get('Id')}" \
if library_type == MediaType.MOVIE.value \
else f"{self._playhost or self._host}web/index.html#!" \
f"/tv.html?topParentId={library.get('Id')}"
libraries.append(
schemas.MediaServerLibrary(
server="jellyfin",
id=library.get("Id"),
name=library.get("Name"),
path=library.get("Path"),
type=library_type
type=library_type,
image=image,
link=link
))
return libraries
@@ -212,7 +286,7 @@ class Jellyfin(metaclass=Singleton):
return None
req_url = ("%sUsers/%s/Items?"
"api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true") % (
self._host, self.user, self._apikey, name)
self._host, self.user, self._apikey, name)
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -227,8 +301,8 @@ class Jellyfin(metaclass=Singleton):
return None
return ""
def get_movies(self,
title: str,
def get_movies(self,
title: str,
year: str = None,
tmdb_id: int = None) -> Optional[List[schemas.MediaServerItem]]:
"""
@@ -242,7 +316,7 @@ class Jellyfin(metaclass=Singleton):
return None
req_url = ("%sUsers/%s/Items?"
"api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true") % (
self._host, self.user, self._apikey, title)
self._host, self.user, self._apikey, title)
try:
res = RequestUtils().get_res(req_url)
if res:
@@ -332,7 +406,7 @@ class Jellyfin(metaclass=Singleton):
if not season_episodes.get(season_index):
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
return tv_info.get('Id'), season_episodes
return item_id, season_episodes
except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None
@@ -452,7 +526,7 @@ class Jellyfin(metaclass=Singleton):
return None
if not message:
return None
logger.info(f"接收到jellyfin webhook{message}")
logger.debug(f"接收到jellyfin webhook{message}")
eventType = message.get('NotificationType')
if not eventType:
return None
@@ -466,7 +540,10 @@ class Jellyfin(metaclass=Singleton):
eventItem.device_name = message.get('DeviceName')
eventItem.user_name = message.get('NotificationUsername')
eventItem.client = message.get('ClientName')
if message.get("ItemType") == "Episode":
eventItem.media_type = message.get('ItemType')
if message.get("ItemType") == "Episode" \
or message.get("ItemType") == "Series" \
or message.get("ItemType") == "Season":
# 剧集
eventItem.item_type = "TV"
eventItem.season_id = message.get('SeasonNumber')
@@ -584,3 +661,154 @@ class Jellyfin(metaclass=Singleton):
except Exception as e:
logger.error(f"连接Jellyfin出错" + str(e))
return None
def get_play_url(self, item_id: str) -> str:
"""
拼装媒体播放链接
:param item_id: 媒体的的ID
"""
return f"{self._playhost or self._host}web/index.html#!" \
f"/details?id={item_id}&serverId={self.serverid}"
def __get_local_image_by_id(self, item_id: str) -> str:
"""
根据ItemId从媒体服务器查询有声书图片地址
:param: item_id: 在Emby中的ID
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
return "%sItems/%s/Images/Primary" % (self._host, item_id)
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
"""
获取Backdrop图片地址
:param: item_id: 在Emby中的ID
:param: image_tag: 图片的tag
:param: remote 是否远程使用TG微信等客户端调用应为True
:param: inner 是否NT内部调用为True是会使用NT中转
"""
if not self._host or not self._apikey:
return ""
if not image_tag or not item_id:
return ""
return f"{self._host}Items/{item_id}/" \
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得继续观看
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Resume?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
ret_resume.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage")
))
return ret_resume
else:
logger.error(f"Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Resume出错" + str(e))
return []
def get_latest(self, num=20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获得最近更新
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
req_url = (f"{self._host}Users/{user}/Items/Latest?"
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
try:
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []
def get_user_library_folders(self):
"""
获取Emby媒体库文件夹列表排除黑名单
"""
if not self._host or not self._apikey:
return []
library_folders = []
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self.get_jellyfin_virtual_folders() or []:
if library.get("Name") in black_list:
continue
library_folders += [folder for folder in library.get("Path")]
return library_folders

Some files were not shown because too many files have changed in this diff Show More