Compare commits

...

475 Commits

Author SHA1 Message Date
jxxghp
e501154ad4 v1.8.0
- 搜索和订阅支持指定季,输入:xxxx 第x季
- 插件市场支持查看插件的更新日志(需要插件作者补充)
- 优化了媒体信息识别
- 优化了动漫及拼音标题的资源搜索匹配
- 优化了UI性能
- 修复了手动搜索时默认过滤规则不生效的问题
- 新增下载文件实时整理API,可在 QB设置->下载完成时运行外部程序 处填入:curl "http://localhost:3000/api/v1/transfer/now?token=moviepilot",实现下载器监控模式下无需等待轮循,下载完成后立即整理入库(地址、端口和token按实际调整,curl也可更换为wget)。

注意:如搜索异常请清理浏览器缓存。
2024-04-11 08:21:29 +08:00
jxxghp
c73cf1d7e2 v1.8.0
- 搜索和订阅支持指定季,输入:xxxx 第x季
- 插件市场支持查看插件的更新日志(需要插件作者补充)
- 优化了媒体信息识别
- 优化了动漫及拼音标题的资源搜索匹配
- 优化了UI性能
- 修复了手动搜索时默认过滤规则不生效的问题
- 新增下载文件实时整理API,可在 QB设置->下载完成时运行外部程序 处填入:curl "http://localhost:3000/api/v1/transfer/now?token=moviepilot",实现下载器监控模式下无需等待轮循,下载完成后立即整理入库(地址、端口和token按实际调整,curl也可更换为wget)。

注意:如搜索异常请清理浏览器缓存。
2024-04-11 08:02:24 +08:00
jxxghp
a3603f79c8 fix requests 2024-04-10 22:16:10 +08:00
jxxghp
294b4a6bf9 fix torrents match 2024-04-10 20:05:43 +08:00
jxxghp
f365d93316 fix torrents match 2024-04-10 20:02:02 +08:00
jxxghp
facd20ba3c fix bangumi 2024-04-10 19:04:59 +08:00
jxxghp
d0e596c93c feat: 插件更新历史 2024-04-10 16:44:08 +08:00
jxxghp
e20ec4ddf5 fix bug 2024-04-10 15:05:32 +08:00
jxxghp
ba0a1cb1bd fix #1738 搜索和订阅支持指定季 2024-04-10 14:51:34 +08:00
jxxghp
17438f8c5c fix log 2024-04-10 13:41:11 +08:00
jxxghp
e0c2ae0f0c fix log 2024-04-10 13:20:33 +08:00
jxxghp
9ebb211589 fix meta cases 2024-04-10 12:22:32 +08:00
jxxghp
8a0350c566 fix mtype 2024-04-10 11:50:14 +08:00
jxxghp
765d37fd6a fix meta 2024-04-10 11:44:14 +08:00
jxxghp
b3d57b868e fix:自定义识别词不处理空格 2024-04-10 07:09:31 +08:00
jxxghp
18e7099848 fix:自定义识别词不处理空格 2024-04-10 07:07:17 +08:00
jxxghp
27cb968a18 fix #1846 2024-04-09 18:47:25 +08:00
jxxghp
45bf84d448 fix #1849 2024-04-09 18:43:24 +08:00
jxxghp
85300b0931 more log 2024-04-09 13:36:13 +08:00
jxxghp
ac87c778f4 fix anime match 2024-04-09 13:20:28 +08:00
jxxghp
1ed511034c fix search match 2024-04-09 07:09:54 +08:00
jxxghp
ca7f121a21 Merge pull request #1847 from hotlcc/develop-修复PTLSP站点测试 2024-04-08 14:05:50 +08:00
Allen
c8e73e17d3 修复ptlsp测试问题 2024-04-08 04:26:49 +00:00
Allen
3bfc87f1cc ptlsp站点测试问题修复 2024-04-08 03:16:07 +00:00
jxxghp
e0e76bf3fe fix 2024-04-07 16:32:26 +08:00
jxxghp
6a3e3f1562 feat:中英文名依次匹配 2024-04-07 16:20:33 +08:00
jxxghp
59330657b2 add nano 2024-04-07 14:56:32 +08:00
jxxghp
927d510619 add 立即执行下载器文件整理 API 2024-04-07 14:45:59 +08:00
jxxghp
80a390ac6c feat:种子名为拼音的情况下,从副标题中提取中文名用于识别 2024-04-07 14:25:12 +08:00
jxxghp
cae563ce53 test:更加宽松的匹配规则 2024-04-06 21:07:00 +08:00
jxxghp
0495936ef8 v1.7.9
- 订阅支持预设订阅规则
- 插件新增快速搜索功能、优化了插件安装和卸载的响应速度
- 优化了文件管理、历史记录的性能和易用性
- 修复了馒头种子下载失败的问题

温馨提示:
1. 如遇到前端奇奇怪怪的问题,请先清理浏览器缓存
2. 合理设置优先级层级,如层级过多且搜索结果很多时,会明显增加搜索耗时
2024-04-06 17:27:48 +08:00
jxxghp
34d27fe85b fix #1818 2024-04-06 11:47:22 +08:00
jxxghp
0e2c4d74d6 feat:优化插件重载 2024-04-05 23:20:51 +08:00
jxxghp
bd137de042 Merge pull request #1833 from honue/main 2024-04-05 22:47:46 +08:00
honue
4a2688b52f fix #1744 2024-04-05 22:22:41 +08:00
jxxghp
36acb1daaa Merge pull request #1832 from cddjr/fix_ua 2024-04-05 12:01:47 +08:00
景大侠
a0c3b6b26b fix: 站点User-Agent没有设置的情况下以系统设置的UA进行访问 2024-04-05 11:40:52 +08:00
jxxghp
7c93432505 Merge pull request #1815 from z3shan33/main 2024-04-02 09:26:19 +08:00
z3shan33
2760f25992 fix #1792 2024-04-02 09:24:43 +08:00
jxxghp
d199c47666 fix #1804 2024-04-02 08:22:12 +08:00
jxxghp
a6550a21ef Merge pull request #1804 from thsrite/main 2024-04-01 19:11:29 +08:00
thsrite
26a321f119 feat 设置订阅默认规则 2024-04-01 13:29:22 +08:00
jxxghp
7e8f7be905 v1.7.8
- 支持用户开启管理后台登录双重认证,增强安全性
- 管理后台的大部分表单均增加了hint提示信息
- 重启时会重新安装插件依赖,避免安装在线插件时依赖安装不成功的问题(此特性需要重拉镜像生效)
- 提升了框架对于插件错误的兼容性,插件市场插件按下载热度排序
2024-03-31 19:38:20 +08:00
jxxghp
600b6144e4 fix #1783 目录完整度匹配 2024-03-31 08:17:51 +08:00
jxxghp
dfb11420e5 Merge pull request #1789 from DDS-Derek/main 2024-03-30 17:44:22 +08:00
DDSRem
584c8a2d94 feat: install the plug-in pip extension in advance 2024-03-30 17:41:04 +08:00
jxxghp
536bd9268a feat:新增订阅相关事件 2024-03-30 08:04:52 +08:00
jxxghp
5ee41b87a2 fix login api 2024-03-29 11:13:57 +08:00
jxxghp
89b2fe10fe Merge pull request #1774 from jeblove/main 2024-03-28 21:33:16 +08:00
jeblove
c180e50164 feat: 增加session方法,用于获取tr的会话、配置信息 2024-03-28 21:24:16 +08:00
jxxghp
8f7b08afae fix #1763 2024-03-28 17:04:44 +08:00
jxxghp
72de8a2192 Merge pull request #1772 from z3shan33/main
feat #1763
2024-03-28 16:57:55 +08:00
zss
40d99f1dd5 feat #1763 2024-03-28 16:39:34 +08:00
jxxghp
ff07841dd6 roll back site test 2024-03-28 13:20:48 +08:00
jxxghp
828fc08362 Merge pull request #1766 from cddjr/1761--bug 2024-03-28 06:48:22 +08:00
景大侠
3fd043bb9b fix #1761 2024-03-28 02:09:47 +08:00
jxxghp
f51c4ebed7 fix bug 2024-03-27 20:46:06 +08:00
jxxghp
9b917cd4c2 更新 requirements.txt 2024-03-27 19:50:22 +08:00
jxxghp
91eac50ab9 v1.7.7
- 多别名搜索(`SEARCH_MULTIPLE_NAME`)默认为关,优化了站点无法连通时的搜索处理逻辑,加快搜索速度 - 修复了站点删除或重置后订阅等站点设置残留的问题 - `馒头`站点数据统计切换为使用ApiKey - 优化了Bangumi每日放送的演员阵容显示 - 插件支持显示下载安装次数
2024-03-27 17:01:33 +08:00
jxxghp
f6468ad327 fix scraper 2024-03-27 16:01:20 +08:00
jxxghp
fb6c3a9f36 fix site test 2024-03-27 15:45:27 +08:00
jxxghp
eb751bb581 fix site test 2024-03-27 15:35:01 +08:00
jxxghp
f9069bf19b fix #1758 2024-03-27 12:22:15 +08:00
jxxghp
ef0c88a3b6 fix 种子去重 2024-03-27 11:37:51 +08:00
jxxghp
f1f8ccb5d6 feat:plugins statistics 2024-03-27 08:24:06 +08:00
jxxghp
2df113ad38 fix SiteDeleted 2024-03-27 07:09:00 +08:00
jxxghp
fa03232321 Merge pull request #1759 from cddjr/fix_remove_site 2024-03-27 06:24:18 +08:00
景大侠
04f50284c6 fix 删除站点会导致其订阅的站点列表出现数字ID 2024-03-27 00:54:58 +08:00
jxxghp
9fc950c2ed Merge pull request #1751 from z3shan33/main 2024-03-26 16:41:59 +08:00
zss
9c1aeb933e fix bangumi中通过characters获取配音角色信息 2024-03-26 16:11:03 +08:00
jxxghp
1cee20134a fix 插件去重&排序 2024-03-26 09:30:05 +08:00
jxxghp
0ca5f5bd89 fix timeout 2024-03-25 23:06:30 +08:00
jxxghp
25e0c25bc6 fix timeout 2024-03-25 23:01:50 +08:00
jxxghp
3f8453f054 fix 2024-03-25 20:14:24 +08:00
jxxghp
cf259af2d1 feat:插件安装统计 2024-03-25 18:02:57 +08:00
jxxghp
0b70f74553 fix site test 2024-03-24 21:33:41 +08:00
jxxghp
f0bc5d737b - 问题修复 2024-03-24 15:45:20 +08:00
jxxghp
181d87f68e fix mtorrent 2024-03-24 15:31:00 +08:00
jxxghp
e37ac4da6a v1.7.6
- 馒头搜索切换为使用ApiKey,需要先在`控制台`->`实验室`建立存取令牌,手工维护站点cookie后ApiKey会自动获取并缓存使用,如更换了ApiKey,需要手动触发站点修改才会清除缓存。
- 资源搜索时整合多个别名的搜索结果,避免搜索不全

注意:馒头除搜索下载外,站点签到、数据统计、刷流等仍然使用cookie访问,请自行评估风险。
2024-03-24 14:01:20 +08:00
jxxghp
bd7ca7fa60 feat:m-team x-api-key 2024-03-24 13:38:36 +08:00
jxxghp
96de772119 fix mtorrent 2024-03-24 10:20:12 +08:00
jxxghp
72b6556c62 add SEARCH_MULTIPLE_NAME 2024-03-24 08:26:59 +08:00
jxxghp
e4bb182668 feat:搜索更多结果 2024-03-24 08:13:08 +08:00
jxxghp
595d097235 v1.7.5
- 认证站点新增支持青蛙🐸,蝴蝶🦋支持ipv4域名,适配了馒头新UI
- 加快了插件市场的加载速度
- 插件日志倒序显示
2024-03-23 19:01:09 +08:00
jxxghp
9b53aad34f fix mtorrent 2024-03-23 13:46:06 +08:00
jxxghp
e92a2e1ff1 Merge pull request #1728 from developer-wlj/wlj0323 2024-03-23 13:38:33 +08:00
mayun110
764359c3e8 fix 2024-03-23 13:18:36 +08:00
mayun110
abd1a51863 fix: labels by mTorrent 2024-03-23 12:26:49 +08:00
jxxghp
2f05f8dc4d fix mtorrent 2024-03-23 09:50:03 +08:00
jxxghp
23c678e71e fix mtorrent 2024-03-23 09:42:11 +08:00
jxxghp
ef67b76453 fix 下载消息显示用户名 2024-03-22 13:26:07 +08:00
jxxghp
c4e7870f7b Merge pull request #1726 from sundxfansky/main 2024-03-22 06:53:18 +08:00
jxxghp
9cef50436a Merge pull request #1725 from Vincwnt/main 2024-03-22 06:51:40 +08:00
sundxfansky
a15aded0a0 无需添加时间 2024-03-22 04:40:33 +08:00
chenyuan
8ac40dc205 fix: 存在已删除用户时, 消息批量推送失败bug 2024-03-21 22:27:01 +08:00
jxxghp
92a5b3d227 feat:线上插件多线程加载 2024-03-21 21:30:26 +08:00
jxxghp
761f1e7a4b feat:线上插件多线程加载 2024-03-21 21:27:54 +08:00
jxxghp
ad0731e1ec 更新 README.md 2024-03-21 18:27:36 +08:00
jxxghp
a451f12d86 add qingwa 2024-03-21 16:55:57 +08:00
jxxghp
dcde619e77 插件日志倒序 & 补充安装版本Windows指引 2024-03-21 16:28:16 +08:00
jxxghp
92769b27f1 v1.7.4
- 推荐增加了`Bangumi每日放送`
- `api.themoviedb.org`等域名会自动使用DOH解析IP地址,以避免DNS污染提升网络连通性(通过`DOH_ENABLE`变量控制,默认开)
- 站点浏览增加点击添加下载功能
- 优化了个别页面在数据多时的展示速度
2024-03-19 17:27:52 +08:00
jxxghp
fa83168b92 feat:增加DOH开关 2024-03-19 12:26:04 +08:00
jxxghp
f96295de3a add download api 2024-03-18 23:27:54 +08:00
jxxghp
6cecb3c6a6 fix bug 2024-03-18 20:02:03 +08:00
jxxghp
b6486035c4 add Bangumi 2024-03-18 19:02:34 +08:00
jxxghp
f7c1d28c0f remove cloudflared 2024-03-18 08:23:43 +08:00
jxxghp
47c2ae1c08 fix doh domains 2024-03-18 07:19:56 +08:00
jxxghp
c03f24dcf5 更新 doh.py 2024-03-17 23:40:19 +08:00
jxxghp
6e2f5762b4 add doh 2024-03-17 23:30:50 +08:00
jxxghp
75330a08cc add doh 2024-03-17 23:25:04 +08:00
jxxghp
3f17e371c3 add doh 2024-03-17 23:15:21 +08:00
jxxghp
a820341ec7 rollback cloudflared 2024-03-17 22:32:15 +08:00
jxxghp
c1f04f5631 Merge pull request #1697 from DDS-Derek/main 2024-03-17 19:06:31 +08:00
DDSRem
a121e45b94 fix: container resolv cannot be modified 2024-03-17 18:43:54 +08:00
DDSRem
885ee976b2 feat: better cloudflared install 2024-03-17 18:15:29 +08:00
jxxghp
e6229beb94 add cloudflared 2024-03-17 16:52:13 +08:00
jxxghp
f2a40e1ec3 fix themoviedb季不显示 2024-03-17 15:59:21 +08:00
jxxghp
5f80aa5b7c - 豆瓣订阅及本地CookieCloud服务问题修复 2024-03-17 15:12:24 +08:00
jxxghp
14ff1e9af6 fix resource 2024-03-17 15:09:10 +08:00
jxxghp
49ab5ac709 - 豆瓣订阅及本地CookieCloud服务问题修复 2024-03-17 13:43:11 +08:00
jxxghp
74c7a1927b fix cookiecloud 2024-03-17 13:42:01 +08:00
jxxghp
cbd704373c try fix cookiecloud 2024-03-17 12:57:38 +08:00
jxxghp
a05724f664 fix 自动校正站点地址格式 2024-03-17 12:21:32 +08:00
jxxghp
97d0fc046a fix 豆瓣订阅Bug 2024-03-17 11:27:54 +08:00
jxxghp
6248e34400 fix v1.7.3 2024-03-17 10:00:59 +08:00
jxxghp
a442dab85b fix nginx.conf 2024-03-17 09:51:04 +08:00
jxxghp
d4514edba6 v1.7.3
- `捷径`新增消息中心功能
- 内建支持CookieCloud本地化服务器,Cookie数据加密后保存在用户配置目录中,可在`设定`-`站点`中选择开启
- 优化了推荐详情页面,豆瓣推荐详情直接展示豆瓣数据源
- 修复了`蜜柑`无法搜索的问题
2024-03-17 09:09:21 +08:00
jxxghp
0c581565ad 更新 message.py 2024-03-16 22:21:12 +08:00
jxxghp
350def0a6f 更新 message.py 2024-03-16 22:20:14 +08:00
jxxghp
5b3027c0a7 fix reload 2024-03-16 21:06:52 +08:00
jxxghp
e4b90ca8f7 fix #1694 2024-03-16 20:40:02 +08:00
jxxghp
d917b00055 Merge pull request #1694 from lingjiameng/main
CookieCloud配置支持实时更新
2024-03-16 20:36:05 +08:00
s0mE
cc94c6c367 Merge branch 'jxxghp:main' into main 2024-03-16 19:24:25 +08:00
ljmeng
6410051e3a CookieCloud配置支持实时加载 2024-03-16 19:23:06 +08:00
jxxghp
aaa1b80edf fix 资源包更新Bug 2024-03-16 18:38:25 +08:00
jxxghp
f345d94009 fix README.md 2024-03-16 18:28:09 +08:00
jxxghp
550fe26d76 Merge pull request #1693 from lingjiameng/main
集成CookieCloud服务器端
2024-03-16 17:52:49 +08:00
jxxghp
7ad498b3a3 fix 2024-03-16 17:06:24 +08:00
jxxghp
20eb0b4635 fix message 2024-03-16 16:29:14 +08:00
ljmeng
747dc3fafe 默认关闭本地CookieCloud服务 2024-03-16 15:40:10 +08:00
s0mE
4708fbb3cb Merge branch 'jxxghp:main' into main 2024-03-16 15:36:20 +08:00
ljmeng
6ba40edeb4 Merge branch 'main' of github.com:lingjiameng/MoviePilot 2024-03-16 15:35:02 +08:00
ljmeng
79cb28faf9 默认配置关闭本地cookiecloud服务 2024-03-16 15:34:46 +08:00
jxxghp
9acf05f334 fix #1691 2024-03-16 15:31:04 +08:00
jxxghp
d0af1bf075 Merge pull request #1691 from hoey94/main 2024-03-16 13:53:10 +08:00
hoey94
f8a95cec4a fix: TR远程控制插件限速问题 104 2024-03-16 12:37:21 +08:00
jxxghp
3cd672fa8d fix 2024-03-16 08:40:36 +08:00
jxxghp
fe03638552 fix api 2024-03-16 08:39:57 +08:00
ljmeng
1ae220c654 集成CookieCloud服务端 2024-03-16 04:48:34 +08:00
jxxghp
75c7e71ee6 Merge pull request #1689 from hoey94/main 2024-03-15 19:14:26 +08:00
hoey94
4619158b99 fix: 限速开关BUG 104 2024-03-15 18:23:44 +08:00
jxxghp
3f88907ba9 fix bug 2024-03-15 18:17:04 +08:00
jxxghp
ae6440bd0a Merge pull request #1683 from lingjiameng/main 2024-03-15 07:55:01 +08:00
s0mE
261f5fc0c6 Merge branch 'jxxghp:main' into main 2024-03-14 23:26:58 +08:00
jxxghp
a5d044d535 fix message 2024-03-14 20:36:15 +08:00
jxxghp
6e607ca89f fix 优化推荐跳转
feat 消息落库
2024-03-14 19:44:15 +08:00
jxxghp
06e4b9ad83 Merge remote-tracking branch 'origin/main' 2024-03-14 19:15:22 +08:00
jxxghp
c755dc9b85 fix 优化推荐跳转
feat 消息落库
2024-03-14 19:15:13 +08:00
jxxghp
209451d5f9 Merge pull request #1678 from HankunYu/main 2024-03-14 06:57:31 +08:00
HankunYu
60b2d30f42 Update README.md
增加使用反代的描述,解决使用https反代时日志加载时间过长(十几分钟)不可用的问题。
2024-03-13 18:54:55 +00:00
ljmeng
399d26929d CookieCloud改为本地解密,增强安全性 2024-03-14 02:35:22 +08:00
jxxghp
f50c2e59a9 fix #1674 2024-03-13 14:54:37 +08:00
jxxghp
1cd768b3d0 v1.7.2
- 站点索引新增支持`蟹黄堡`,修复了`蝴蝶`、`蜜柑`的索引问题
- 针对themoviedb被大量删除中文标题的问题,补充使用新加坡(zh-sg)中文标题搜索和刮削
- 支持设定识别元数据的缓存时间(`META_CACHE_EXPIRE`,单位小时)
- 修复了未设定anime分类策略时,原tv下动漫二级分类失效的问题
- 提升了插件升级的使用体验
2024-03-13 08:21:59 +08:00
jxxghp
abc26b65ed fix #1645 兼容蝴蝶种子链接格式 2024-03-12 17:01:41 +08:00
jxxghp
dc1a41da90 fix 减少不必要的检测 2024-03-12 13:48:37 +08:00
jxxghp
a95dac1b32 fix 目录检测 2024-03-12 13:36:33 +08:00
jxxghp
18d9620687 #1653 搜索词中加入新加坡标题,同时主标题不是中文时会考虑使用中文新加坡标题 2024-03-12 11:55:47 +08:00
jxxghp
8808dcee52 fix 1659 2024-03-12 11:16:10 +08:00
jxxghp
17adc4deab Merge pull request #1662 from thsrite/main 2024-03-11 16:36:19 +08:00
thsrite
9351489166 fix 不查缓存识别媒体信息也应更新最新信息到缓存 2024-03-11 16:34:53 +08:00
jxxghp
e2148cb77f fix 2024-03-11 16:28:36 +08:00
jxxghp
e322204094 Merge pull request #1661 from jeblove/main 2024-03-11 16:25:05 +08:00
jxxghp
0fa884157a 支持设定meta缓存时间 2024-03-11 16:23:07 +08:00
jeblove
96468213fe Merge branch 'main' of https://github.com/jeblove/MoviePilot 2024-03-11 16:17:36 +08:00
jeblove
d044a9db00 fix 继续观看部分剧集图片 2024-03-11 16:17:10 +08:00
jxxghp
d5f5e0d526 Merge pull request #1660 from thsrite/main 2024-03-11 15:59:20 +08:00
thsrite
14a3bb8fc2 add db订阅、下载历史根据类型和时间查询列表(插件方法) 2024-03-11 15:56:19 +08:00
jxxghp
5921d43ae8 fix #1655 2024-03-11 12:34:19 +08:00
jxxghp
635061c054 Merge pull request #1654 from jeblove/main 2024-03-11 11:32:27 +08:00
jeblove
3c8c6e5375 fix 语法问题 2024-03-11 11:27:24 +08:00
jeblove
dd063bb16b fix 播放剧集微信消息推送图片问题 2024-03-11 01:57:57 +08:00
jeblove
750711611b fix 语法问题 2024-03-11 00:15:55 +08:00
jxxghp
d3983c51c2 Merge pull request #1652 from jeblove/main 2024-03-10 18:39:16 +08:00
jeblove
b9dec73773 fix 语法问题 2024-03-10 18:10:09 +08:00
jeblove
b310367d25 fix 播放微信消息推送图片问题 2024-03-10 17:50:01 +08:00
jxxghp
55beea87fd Merge pull request #1649 from thsrite/main 2024-03-10 11:24:57 +08:00
thsrite
4510382f74 fix tv动漫分类不生效 2024-03-10 09:27:48 +08:00
jxxghp
9b9ae9401e fix bug 2024-03-09 21:33:59 +08:00
jxxghp
e10464c278 v1.7.1
- 动漫独立目录时支持二级分类(category.yml配置模板已更新)
- 支持同时启用两个下载器,但只有第1个才会被默认使用(官方插件库个别插件进行了适配升级)
- 实时日志的最新日志显示在最顶部
- 优化了下载器及媒体目录的健康检查
- 优化了版本升级后因为浏览器缓存一直加载中的问题
- 优先级规则新增支持`官种`
- 修复了普通用户无法查看下载中任务的问题
- 修复了设定中修改定时服务相关设置时不立即生效的问题
2024-03-09 21:20:36 +08:00
jxxghp
542531a1ca fix yyyymmdd期 识别 2024-03-09 21:13:23 +08:00
jxxghp
04c21232e3 fix yyyymmdd期 识别 2024-03-09 21:03:29 +08:00
jxxghp
48a19fd57c fix 下载器测试 2024-03-09 20:03:57 +08:00
jxxghp
59cb69a96b Merge pull request #1643 from jeblove/main 2024-03-09 19:39:31 +08:00
jeblove
e7d94f7f70 fix 对接Library/VirtualFolders接口参数 2024-03-09 19:03:02 +08:00
jxxghp
27d2d01a20 feat:下载器支持多选 2024-03-09 18:52:27 +08:00
jxxghp
8b4495c857 feat:下载器支持多选 2024-03-09 18:25:04 +08:00
jxxghp
15bdb694cc fix 优化部分消息格式 2024-03-09 17:43:21 +08:00
jxxghp
3ef9c5ea2c fix 优化部分消息格式 2024-03-09 17:34:49 +08:00
jxxghp
ab6577f752 fix #1561 2024-03-09 17:12:21 +08:00
jxxghp
49a82d7a48 feat:新增官种优先级规则
fix #1635
feat:动漫支持二级目录 fix #1633
2024-03-09 09:53:15 +08:00
jxxghp
bdcbb168a0 Merge pull request #1636 from HankunYu/main 2024-03-09 08:05:49 +08:00
HankunYu
2e1cb0bd76 fix #1630
这里混淆了remove_tags与delete_tags。将原来的remove tags函数更正为delete,并新增一个remove tags函数。
2024-03-08 18:23:42 +00:00
jxxghp
851864cd49 fix 定时服务立即生效
fix #1615
2024-03-08 16:22:53 +08:00
jxxghp
b5d7b6fb53 fix 订阅全局通知 2024-03-08 15:38:40 +08:00
jxxghp
92bab2fc2f fix 下载全局通知 2024-03-08 15:27:24 +08:00
jxxghp
0dad6860c4 fix 下载任务userid登记 2024-03-08 14:40:00 +08:00
jxxghp
de4a7becc2 Merge pull request #1620 from thsrite/main 2024-03-08 11:58:52 +08:00
thsrite
2eeb24e22d fix 不开下载器监控,link测试无意义 2024-03-08 09:29:30 +08:00
jxxghp
e4a67ea052 - 修复了健康检查themoviedb、thetvdb时未使用内置代理的问题
- 修复了VoceChat部分场景下消息发送失败的问题
- 修复了VoceChat响应干扰了微信回调的问题
- 提升了VoceChat的安全性,机器人Webhook需要参考说明重新设置

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

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

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

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

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

2
.gitignore vendored
View File

@@ -10,7 +10,9 @@ app/helper/*.pyd
app/helper/*.bin app/helper/*.bin
app/plugins/** app/plugins/**
!app/plugins/__init__.py !app/plugins/__init__.py
config/cookies/**
config/user.db config/user.db
config/sites/** config/sites/**
*.pyc *.pyc
*.log *.log
.vscode

View File

@@ -33,6 +33,7 @@ RUN apt-get update -y \
fuse3 \ fuse3 \
rsync \ rsync \
ffmpeg \ ffmpeg \
nano \
&& \ && \
if [ "$(uname -m)" = "x86_64" ]; \ if [ "$(uname -m)" = "x86_64" ]; \
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \ then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \

206
README.md
View File

@@ -13,13 +13,19 @@
## 安装 ## 安装
### 注意管理员用户不要使用弱密码如非必要不要暴露到公网。如被盗取管理账号权限将会导致站点Cookie等敏感数据泄露
### 1. **安装CookieCloud插件** ### 1. **安装CookieCloud插件**
站点信息需要通过CookieCloud同步获取因此需要安装CookieCloud插件将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。 站点信息需要通过CookieCloud同步获取因此需要安装CookieCloud插件将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
### 2. **安装CookieCloud服务端可选** ### 2. **安装CookieCloud服务端可选**
MoviePilot内置了公共CookieCloud服务器如果需要自建服务可参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)。 通过CookieCloud可以快速同步浏览器中保存的站点数据到MoviePilot支持以下服务方式
- 使用公共CookieCloud远程服务器默认服务器地址为https://movie-pilot.org/cookiecloud
- 使用内建的本地Cookie服务`设定` - `站点` 中打开`启用本地CookieCloud服务器`将启用内建的CookieCloud提供服务服务地址为`http://localhost:${NGINX_PORT}/cookiecloud/`, Cookie数据加密保存在配置文件目录下的`cookies`文件中
- 自建服务CookieCloud服务器参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)
**声明:** 本项目不会收集用户敏感数据Cookie同步也是基于CookieCloud项目实现非本项目提供的能力。技术角度上CookieCloud采用端到端加密在个人不泄露`用户KEY``端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关! **声明:** 本项目不会收集用户敏感数据Cookie同步也是基于CookieCloud项目实现非本项目提供的能力。技术角度上CookieCloud采用端到端加密在个人不泄露`用户KEY``端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
@@ -41,7 +47,8 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- Windows - Windows
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases)双击运行后自动生成配置文件目录访问http://localhost:3000 1. 独立执行文件版本:下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases)双击运行后自动生成配置文件目录访问http://localhost:3000
2. 安装包版本:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
- 群晖套件 - 群晖套件
@@ -52,12 +59,14 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录 1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录 2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
3) 执行命令:`pip install -r requirements.txt` 安装依赖 3) 执行命令:`pip install -r requirements.txt` 安装依赖
4) 执行命令:`python app/main.py` 启动服务 4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务 5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
## 配置 ## 配置
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置** 大部分配置可启动后通过WEB管理界面进行配置但仍有部分配置需要通过环境变量/配置文件进行配置。
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件或通过WEB界面配置 > 默认值。
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。 > ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
@@ -70,150 +79,67 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022` - **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
- **PROXY_HOST** 网络代理访问themoviedb或者重启更新需要使用代理访问格式为`http(s)://ip:port`、`socks5://user:pass@host:port` - **PROXY_HOST** 网络代理访问themoviedb或者重启更新需要使用代理访问格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
- **MOVIEPILOT_AUTO_UPDATE** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`** - **MOVIEPILOT_AUTO_UPDATE** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker
- **❗AUTH_SITE** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。 - **❗AUTH_SITE** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数,认证资源`v1.1.1`支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba`1ptba /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt` 配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数
认证资源`v1.2.4+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`
| 站点 | 参数 | | 站点 | 参数 |
|:------------:|:-----------------------------------------------------:| |:------------:|:-----------------------------------------------------:|
| iyuu | `IYUU_SIGN`IYUU登录令牌 | | iyuu | `IYUU_SIGN`IYUU登录令牌 |
| hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 | | hhclub | `HHCLUB_USERNAME`:用户名<br/>`HHCLUB_PASSKEY`:密钥 |
| audiences | `AUDIENCES_UID`用户ID<br/>`AUDIENCES_PASSKEY`:密钥 | | audiences | `AUDIENCES_UID`用户ID<br/>`AUDIENCES_PASSKEY`:密钥 |
| hddolby | `HDDOLBY_ID`用户ID<br/>`HDDOLBY_PASSKEY`:密钥 | | hddolby | `HDDOLBY_ID`用户ID<br/>`HDDOLBY_PASSKEY`:密钥 |
| zmpt | `ZMPT_UID`用户ID<br/>`ZMPT_PASSKEY`:密钥 | | zmpt | `ZMPT_UID`用户ID<br/>`ZMPT_PASSKEY`:密钥 |
| freefarm | `FREEFARM_UID`用户ID<br/>`FREEFARM_PASSKEY`:密钥 | | freefarm | `FREEFARM_UID`用户ID<br/>`FREEFARM_PASSKEY`:密钥 |
| hdfans | `HDFANS_UID`用户ID<br/>`HDFANS_PASSKEY`:密钥 | | hdfans | `HDFANS_UID`用户ID<br/>`HDFANS_PASSKEY`:密钥 |
| wintersakura | `WINTERSAKURA_UID`用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 | | wintersakura | `WINTERSAKURA_UID`用户ID<br/>`WINTERSAKURA_PASSKEY`:密钥 |
| leaves | `LEAVES_UID`用户ID<br/>`LEAVES_PASSKEY`:密钥 | | leaves | `LEAVES_UID`用户ID<br/>`LEAVES_PASSKEY`:密钥 |
| ptba | `PTBA_UID`用户ID<br/>`PTBA_PASSKEY`:密钥 | | ptba | `PTBA_UID`用户ID<br/>`PTBA_PASSKEY`:密钥 |
| icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 | | icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 |
| ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 | | ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 |
| xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 | | xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
| ptvicomo | `PTVICOMO_UID`用户ID<br/>`PTVICOMO_PASSKEY`:密钥 | | ptvicomo | `PTVICOMO_UID`用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
| agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 | | agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
| hdkyl | `HDKYL_UID`用户ID<br/>`HDKYL_PASSKEY`:密钥 |
| qingwa | `QINGWA_UID`用户ID<br/>`QINGWA_PASSKEY`:密钥 |
### 2. **app.env配置文件** ### 2. **环境变量 / 配置文件**
下载 [app.env 模板](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env),修改后放配置文件目录app.env 的所有配置项也可以通过环境变量进行配置 配置文件名:`app.env`放配置文件目录。
- **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!** - **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
- **❗SUPERUSER_PASSWORD** 超级管理员初始密码,默认`password`,建议修改为复杂密码,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
- **❗API_TOKEN** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串 - **❗API_TOKEN** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
- **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快 - **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
- **DOH_ENABLE** DNS over HTTPS开关`true`/`false`,默认`true`开启后会使用DOH对api.themoviedb.org等域名进行解析以减少被DNS污染的情况提升网络连通性
- **META_CACHE_EXPIRE** 元数据识别缓存过期时间小时数字型不配置或者配置为0时使用系统默认大内存模式为7天否则为3天调大该值可减少themoviedb的访问次数
- **GITHUB_TOKEN** Github token提高自动更新、插件安装等请求Github Api的限流阈值格式ghp_**** - **GITHUB_TOKEN** Github token提高自动更新、插件安装等请求Github Api的限流阈值格式ghp_****
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker镜像
--- ---
- **TMDB_API_DOMAIN** TMDB API地址默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可 - **TMDB_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` - **TMDB_IMAGE_DOMAIN** TMDB图片地址默认`image.tmdb.org`可配置为其它中转代理以加速TMDB图片显示`static-mdb.v.geilijiasu.com`
- **WALLPAPER** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb` - **WALLPAPER** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
- **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类 - **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
--- - **FANART_ENABLE** Fanart开关`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
- **SCRAP_METADATA** 刮削入库的媒体文件,`true`/`false`,默认`true`
- **SCRAP_SOURCE** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb` - **SCRAP_SOURCE** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
- **SCRAP_FOLLOW_TMDB** 新增已入库媒体是否跟随TMDB信息变化`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削 - **SCRAP_FOLLOW_TMDB** 新增已入库媒体是否跟随TMDB信息变化`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
--- ---
- **❗LIBRARY_PATH** 媒体库目录,多个目录使用`,`分隔 - **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID消息通知渠道的用户ID多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
- **LIBRARY_MOVIE_NAME** 电影媒体库目录名称(不是完整路径),默认`电影`
- **LIBRARY_TV_NAME** 电视剧媒体库目录称(不是完整路径),默认`电视剧`
- **LIBRARY_ANIME_NAME** 动漫媒体库目录称(不是完整路径),默认`电视剧/动漫`
- **LIBRARY_CATEGORY** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
- **❗TRANSFER_TYPE** 整理转移方式,支持`link`/`copy`/`move`/`softlink`/`rclone_copy`/`rclone_move` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响rclone需要自行映射rclone配置目录到容器中或在容器内完成rclone配置节点名称必须为`MP`**
- **OVERWRITE_MODE** 转移覆盖模式,默认为`size`,支持`nerver`/`size`/`always`/`latest`,分别表示`不覆盖同名文件`/`同名文件根据文件大小覆盖(大覆盖小)`/`总是覆盖同名文件`/`仅保留最新版本,删除旧版本文件(包括非同名文件)`
---
- **❗COOKIECLOUD_HOST** CookieCloud服务器地址格式`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
- **❗COOKIECLOUD_KEY** CookieCloud用户KEY
- **❗COOKIECLOUD_PASSWORD** CookieCloud端对端加密密码
- **❗COOKIECLOUD_INTERVAL** CookieCloud同步间隔分钟
- **❗USER_AGENT** CookieCloud保存Cookie对应的浏览器UA建议配置设置后可增加连接站点的成功率同步站点后可以在管理界面中修改
---
- **SUBSCRIBE_MODE** 订阅模式,`rss`/`spider`,默认`spider``rss`模式通过定时刷新RSS来匹配订阅RSS地址会自动获取也可手动维护对站点压力小同时可设置订阅刷新周期24小时运行但订阅和下载通知不能过滤和显示免费推荐使用rss模式。
- **SUBSCRIBE_RSS_INTERVAL** RSS订阅模式刷新时间间隔分钟默认`30`分钟不能小于5分钟。
- **SUBSCRIBE_SEARCH** 订阅搜索,`true`/`false`,默认`false`开启后会每隔24小时对所有订阅进行全量搜索以补齐缺失剧集一般情况下正常订阅即可订阅搜索只做为兜底会增加站点压力不建议开启
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID消息通知渠道的用户ID多个用户使用,分割,未设置需要选择资源或者回复`0`
--- ---
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。 - **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
--- ---
- **❗MESSAGER** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
- `wechat`设置项:
- **WECHAT_CORPID** WeChat企业ID
- **WECHAT_APP_SECRET** WeChat应用Secret
- **WECHAT_APP_ID** WeChat应用ID
- **WECHAT_TOKEN** WeChat消息回调的Token
- **WECHAT_ENCODING_AESKEY** WeChat消息回调的EncodingAESKey
- **WECHAT_ADMINS** WeChat管理员列表多个管理员用英文逗号分隔可选
- **WECHAT_PROXY** WeChat代理服务器后面不要加/
- `telegram`设置项:
- **TELEGRAM_TOKEN** Telegram Bot Token
- **TELEGRAM_CHAT_ID** Telegram Chat ID
- **TELEGRAM_USERS** Telegram 用户ID多个使用,分隔只有用户ID在列表中才可以使用Bot如未设置则均可以使用Bot
- **TELEGRAM_ADMINS** Telegram 管理员ID多个使用,分隔只有管理员才可以操作Bot菜单如未设置则均可以操作菜单可选
- `slack`设置项:
- **SLACK_OAUTH_TOKEN** Slack Bot User OAuth Token
- **SLACK_APP_TOKEN** Slack App-Level Token
- **SLACK_CHANNEL** Slack 频道名称,默认`全体`(可选)
- `synologychat`设置项:
- **SYNOLOGYCHAT_WEBHOOK** 在Synology Chat中创建机器人获取机器人`传入URL`
- **SYNOLOGYCHAT_TOKEN** SynologyChat机器人`令牌`
---
- **❗DOWNLOAD_PATH** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
- **DOWNLOAD_MOVIE_PATH** 电影下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
- **DOWNLOAD_TV_PATH** 电视剧下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
- **DOWNLOAD_ANIME_PATH** 动漫下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
- **DOWNLOAD_CATEGORY** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在下载目录下建立二级目录分类
- **DOWNLOAD_SUBTITLE** 下载站点字幕,`true`/`false`,默认`true` - **DOWNLOAD_SUBTITLE** 下载站点字幕,`true`/`false`,默认`true`
--- ---
- **❗DOWNLOADER** 下载器,支持`qbittorrent`/`transmission`QB版本号要求>= 4.3.9TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent` - **SEARCH_MULTIPLE_NAME** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
- `qbittorrent`设置项:
- **QB_HOST** qbittorrent地址格式`ip:port`https需要添加`https://`前缀
- **QB_USER** qbittorrent用户名
- **QB_PASSWORD** qbittorrent密码
- **QB_CATEGORY** qbittorrent分类自动管理`true`/`false`,默认`false`,开启后会将下载二级分类传递到下载器,由下载器管理下载目录,需要同步开启`DOWNLOAD_CATEGORY`
- **QB_SEQUENTIAL** qbittorrent按顺序下载`true`/`false`,默认`true`
- **QB_FORCE_RESUME** qbittorrent忽略队列限制强制继续`true`/`false`,默认 `false`
- `transmission`设置项:
- **TR_HOST** transmission地址格式`ip:port`https需要添加`https://`前缀
- **TR_USER** transmission用户名
- **TR_PASSWORD** transmission密码
- **DOWNLOADER_MONITOR** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
- **TORRENT_TAG** 下载器种子标签,默认为`MOVIEPILOT`设置后只有MoviePilot添加的下载才会处理留空所有下载器中的任务均会处理
---
- **❗MEDIASERVER** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
- `emby`设置项:
- **EMBY_HOST** Emby服务器地址格式`ip:port`https需要添加`https://`前缀
- **EMBY_API_KEY** Emby Api Key在`设置->高级->API密钥`处生成
- `jellyfin`设置项:
- **JELLYFIN_HOST** Jellyfin服务器地址格式`ip:port`https需要添加`https://`前缀
- **JELLYFIN_API_KEY** Jellyfin Api Key在`设置->高级->API密钥`处生成
- `plex`设置项:
- **PLEX_HOST** Plex服务器地址格式`ip:port`https需要添加`https://`前缀
- **PLEX_TOKEN** Plex网页Url中的`X-Plex-Token`通过浏览器F12->网络从请求URL中获取
- **MEDIASERVER_SYNC_INTERVAL:** 媒体服务器同步间隔(小时),默认`6`,留空则不同步
- **MEDIASERVER_SYNC_BLACKLIST:** 媒体服务器同步黑名单,多个媒体库名称使用,分割
--- ---
- **MOVIE_RENAME_FORMAT** 电影重命名格式基于jinjia2语法 - **MOVIE_RENAME_FORMAT** 电影重命名格式基于jinjia2语法
`MOVIE_RENAME_FORMAT`支持的配置项: `MOVIE_RENAME_FORMAT`支持的配置项:
> `title` TMDB/豆瓣中的标题 > `title` TMDB/豆瓣中的标题
> `en_title` TMDB中的英文标题 (暂不支持豆瓣)
> `original_title` TMDB/豆瓣中的原语种标题 > `original_title` TMDB/豆瓣中的原语种标题
> `name` 从文件名中识别的名称(同时存在中英文时,优先使用中文) > `name` 从文件名中识别的名称(同时存在中英文时,优先使用中文)
> `en_name`:从文件名中识别的英文名称(可能为空) > `en_name`:从文件名中识别的英文名称(可能为空)
@@ -258,7 +184,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
### 3. **优先级规则** ### 3. **优先级规则**
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等 - 仅支持使用内置规则进行排列组合,通过设置多层规则来实现优先级顺序匹配
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高 - 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
- 不符合过滤规则所有层级规则的资源将不会被选中 - 不符合过滤规则所有层级规则的资源将不会被选中
@@ -269,13 +195,26 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
## 使用 ## 使用
- 通过CookieCloud同步快速同步站点不需要使用的站点可在WEB管理界面中禁用无法同步的站点可手动新增。 ### 1. **WEB后台管理**
- 通过WEB进行管理将WEB添加到手机桌面获得类App使用效果管理界面端口`3000`后台API端口:`3001`。 - 通过设置的超级管理员用户登录后台管理界面(`SUPERUSER`配置项默认用户admin默认端口3000
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一) > ❗**注意:超级管理员用户初始密码为自动生成,需要在首次运行时的后台日志中查看!** 如首次运行日志丢失,则需要删除配置文件目录下的`user.db`文件,然后重启服务
- 通过微信/Telegram/Slack/SynologyChat远程管理其中微信/Telegram将会自动添加操作菜单微信菜单条数有限制部分菜单不显示微信需要在官方页面设置回调地址SynologyChat需要设置机器人传入地址地址相对路径为`/api/v1/message/`。 ### 2. **站点维护**
- 设置媒体服务器Webhook通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot``3001`端口),其中`moviepilot`为设置的`API_TOKEN` - 通过CookieCloud同步快速添加站点不需要使用的站点可在WEB管理界面中禁用或删除无法同步的站点也可手动新增
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr`API服务端口`可使用Overseerr/Jellyseerr浏览订阅 - 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro` ### 3. **文件整理**
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关且仅会处理通过MoviePilot添加下载的任务。
- 使用`目录监控`等插件实现更灵活的自动整理。
### 4. **通知交互**
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
- `微信`回调地址、`SynologyChat`传入地址地址相对路径均为:`/api/v1/message/``VoceChat`的Webhook地址相对路径为`/api/v1/message/?token=moviepilot`其中moviepilot为设置的`API_TOKEN`。
### 5. **订阅与搜索**
- 通过MoviePilot管理后台搜索和订阅。
- 将MoviePilot做为`Radarr`或`Sonarr`服务器添加到`Overseerr`或`Jellyseerr`,可使用`Overseerr/Jellyseerr`浏览和添加订阅。
- 安装`豆瓣榜单订阅`、`猫眼订阅`等插件,实现自动订阅豆瓣榜单、猫眼榜单等。
### 6. **其他**
- 通过设置媒体服务器Webhook指向MoviePilot相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`可实现通过MoviePilot发送播放通知以及配合各类插件实现播放限速等功能。
- 映射宿主机`docker.sock`文件到容器`/var/run/docker.sock`,可支持应用内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
### **注意** ### **注意**
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。 - 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
@@ -289,6 +228,14 @@ location / {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
``` ```
- 反代使用ssl时需要开启`http2`,否则会导致日志加载时间过长或不可用。以`Nginx`为例:
```nginx configuration
server {
listen 443 ssl;
http2 on;
# ...
}
```
- 新建的企业微信应用需要固定公网IP的代理才能收到消息代理添加以下代码 - 新建的企业微信应用需要固定公网IP的代理才能收到消息代理添加以下代码
```nginx configuration ```nginx configuration
location /cgi-bin/gettoken { location /cgi-bin/gettoken {
@@ -302,6 +249,13 @@ location /cgi-bin/menu/create {
} }
``` ```
- 部分插件功能基于文件系统监控实现(如`目录监控`等需在宿主机上不是docker容器内执行以下命令并重启
```shell
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
```
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/f2654b09-26f3-464f-a0af-1de3f97832ee) ![image](https://github.com/jxxghp/MoviePilot/assets/51039935/f2654b09-26f3-464f-a0af-1de3f97832ee)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/fcb87529-56dd-43df-8337-6e34b8582819) ![image](https://github.com/jxxghp/MoviePilot/assets/51039935/fcb87529-56dd-43df-8337-6e34b8582819)

View File

@@ -1,7 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import login, user, site, message, webhook, subscribe, \ 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, bangumi
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"]) api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -21,3 +22,6 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"]) api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"]) api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])

View File

@@ -0,0 +1,64 @@
from typing import List, Any
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.bangumi import BangumiChain
from app.core.context import MediaInfo
from app.core.security import verify_token
router = APIRouter()
@router.get("/calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo])
def calendar(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览Bangumi每日放送
"""
infos = BangumiChain().calendar(page=page, count=count)
if not infos:
return []
medias = [MediaInfo(bangumi_info=info) for info in infos]
return [media.to_dict() for media in medias]
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.BangumiPerson])
def bangumi_credits(bangumiid: int,
page: int = 1,
count: int = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询Bangumi演职员表
"""
persons = BangumiChain().bangumi_credits(bangumiid, page=page, count=count)
if not persons:
return []
return [schemas.BangumiPerson(**person) for person in persons]
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
def bangumi_recommend(bangumiid: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询Bangumi推荐
"""
infos = BangumiChain().bangumi_recommend(bangumiid)
if not infos:
return []
medias = [MediaInfo(bangumi_info=info) for info in infos]
return [media.to_dict() for media in medias]
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
def bangumi_info(bangumiid: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询Bangumi详情
"""
info = BangumiChain().bangumi_info(bangumiid)
if info:
return MediaInfo(bangumi_info=info).to_dict()
else:
return schemas.MediaInfo()

View File

@@ -75,18 +75,16 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
查询下载器信息 查询下载器信息
""" """
transfer_info = DashboardChain().downloader_info() downloader_info = schemas.DownloaderInfo()
free_space = SystemUtils.free_space(settings.SAVE_PATH) transfer_infos = DashboardChain().downloader_info()
if transfer_info: if transfer_infos:
return schemas.DownloaderInfo( for transfer_info in transfer_infos:
download_speed=transfer_info.download_speed, downloader_info.download_speed += transfer_info.download_speed
upload_speed=transfer_info.upload_speed, downloader_info.upload_speed += transfer_info.upload_speed
download_size=transfer_info.download_size, downloader_info.download_size += transfer_info.download_size
upload_size=transfer_info.upload_size, downloader_info.upload_size += transfer_info.upload_size
free_space=free_space downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
) return downloader_info
else:
return schemas.DownloaderInfo()
@router.get("/downloader2", summary="下载器信息API_TOKEN", response_model=schemas.DownloaderInfo) @router.get("/downloader2", summary="下载器信息API_TOKEN", response_model=schemas.DownloaderInfo)

View File

@@ -90,7 +90,7 @@ def movie_top250(page: int = 1,
""" """
浏览豆瓣剧集信息 浏览豆瓣剧集信息
""" """
movies = DoubanChain().movie_top250(page=page, count=count) movies = DoubanChain().movie_top250(page=page, count=count) or []
return [MediaInfo(douban_info=movie).to_dict() for movie in movies] return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
@@ -101,7 +101,7 @@ def tv_weekly_chinese(page: int = 1,
""" """
中国每周剧集口碑榜 中国每周剧集口碑榜
""" """
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs] return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@@ -112,7 +112,7 @@ def tv_weekly_global(page: int = 1,
""" """
全球每周剧集口碑榜 全球每周剧集口碑榜
""" """
tvs = DoubanChain().tv_weekly_global(page=page, count=count) tvs = DoubanChain().tv_weekly_global(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs] return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@@ -123,7 +123,7 @@ def tv_animation(page: int = 1,
""" """
热门动画剧集 热门动画剧集
""" """
tvs = DoubanChain().tv_animation(page=page, count=count) tvs = DoubanChain().tv_animation(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs] return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
@@ -134,7 +134,7 @@ def movie_hot(page: int = 1,
""" """
热门电影 热门电影
""" """
movies = DoubanChain().movie_hot(page=page, count=count) movies = DoubanChain().movie_hot(page=page, count=count) or []
return [MediaInfo(douban_info=movie).to_dict() for movie in movies] return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
@@ -145,7 +145,7 @@ def tv_hot(page: int = 1,
""" """
热门电视剧 热门电视剧
""" """
tvs = DoubanChain().tv_hot(page=page, count=count) tvs = DoubanChain().tv_hot(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs] return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]

View File

@@ -1,6 +1,6 @@
from typing import Any, List from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from app import schemas from app import schemas
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
@@ -10,13 +10,12 @@ from app.core.metainfo import MetaInfo
from app.core.security import verify_token from app.core.security import verify_token
from app.db.models.user import User from app.db.models.user import User
from app.db.userauth import get_current_active_user from app.db.userauth import get_current_active_user
from app.schemas import NotExistMediaInfo, MediaType
router = APIRouter() router = APIRouter()
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent]) @router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
def read_downloading( def read(
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
查询正在下载的任务 查询正在下载的任务
@@ -25,11 +24,10 @@ def read_downloading(
@router.post("/", summary="添加下载", response_model=schemas.Response) @router.post("/", summary="添加下载", response_model=schemas.Response)
def add_downloading( def download(
media_in: schemas.MediaInfo, media_in: schemas.MediaInfo,
torrent_in: schemas.TorrentInfo, torrent_in: schemas.TorrentInfo,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user)) -> Any:
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
添加下载任务 添加下载任务
""" """
@@ -53,43 +51,36 @@ def add_downloading(
}) })
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo]) @router.post("/add", summary="添加下载", response_model=schemas.Response)
def exists(media_in: schemas.MediaInfo, def add(
_: schemas.TokenPayload = Depends(verify_token)) -> Any: torrent_in: schemas.TorrentInfo,
current_user: User = Depends(get_current_active_user)) -> Any:
""" """
查询缺失媒体信息 添加下载任
""" """
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
# 媒体信息 # 媒体信息
meta = MetaInfo(title=media_in.title) mediainfo = MediaChain().recognize_media(meta=metainfo)
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: if not mediainfo:
raise HTTPException(status_code=404, detail="媒体信息不存在") return schemas.Response(success=False, message="无法识别媒体信息")
mediakey = mediainfo.tmdb_id or mediainfo.douban_id # 种子信息
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo) torrentinfo = TorrentInfo()
if mediainfo.type == MediaType.MOVIE: torrentinfo.from_dict(torrent_in.dict())
# 电影已存在时返回空列表,存在时返回空对像列表 # 上下文
return [] if exist_flag else [NotExistMediaInfo()] context = Context(
elif no_exists and no_exists.get(mediakey): meta_info=metainfo,
# 电视剧返回缺失的剧集 media_info=mediainfo,
return list(no_exists.get(mediakey).values()) torrent_info=torrentinfo
return [] )
did = DownloadChain().download_single(context=context, username=current_user.name)
return schemas.Response(success=True if did else False, data={
"download_id": did
})
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response) @router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
def start_downloading( def start(
hashString: str, hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
@@ -100,7 +91,7 @@ def start_downloading(
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response) @router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
def stop_downloading( def stop(
hashString: str, hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
@@ -111,7 +102,7 @@ def stop_downloading(
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response) @router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
def remove_downloading( def info(
hashString: str, hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """

View File

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

View File

@@ -1,7 +1,7 @@
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,48 +15,55 @@ from app.db import get_db
from app.db.models.user import User from app.db.models.user import User
from app.log import logger from app.log import logger
from app.utils.web import WebUtils from app.utils.web import WebUtils
from app.utils.otp import OtpUtils
router = APIRouter() router = APIRouter()
@router.post("/access-token", summary="获取token", response_model=schemas.Token) @router.post("/access-token", summary="获取token", response_model=schemas.Token)
async def login_access_token( async def login_access_token(
db: Session = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends() db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
otp_password: str = Form(None)
) -> Any: ) -> Any:
""" """
获取认证Token 获取认证Token
""" """
# 检查数据库 # 检查数据库
user = User.authenticate( success, user = User.authenticate(
db=db, db=db,
name=form_data.username, name=form_data.username,
password=form_data.password password=form_data.password,
otp_password=otp_password
) )
if not user: if not success:
# 请求协助认证 # 认证不成功
logger.warn("登录用户本地不匹配,尝试辅助认证 ...") if not user:
token = UserChain().user_authenticate(form_data.username, form_data.password) # 未找到用户,请求协助认证
if not token: logger.warn(f"登录用户 {form_data.username} 本地不存在,尝试辅助认证 ...")
logger.warn(f"用户 {form_data.username} 登录失败!") token = UserChain().user_authenticate(form_data.username, form_data.password)
raise HTTPException(status_code=401, detail="用户名或密码不正确") if not token:
else: logger.warn(f"用户 {form_data.username} 登录失败!")
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token}") raise HTTPException(status_code=401, detail="用户名、密码、二次校验码不正确")
# 加入用户信息表 else:
user = User.get_by_name(db=db, name=form_data.username) logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
if not user: # 加入用户信息表
logger.info(f"用户不存在,创建普通用户: {form_data.username}") logger.info(f"创建用户: {form_data.username}")
user = User(name=form_data.username, is_active=True, user = User(name=form_data.username, is_active=True,
is_superuser=False, hashed_password=get_password_hash(token)) is_superuser=False, hashed_password=get_password_hash(token))
user.create(db) user.create(db)
else: else:
# 普通用户权限 # 用户存在,但认证失败
user.is_superuser = False logger.warn(f"用户 {user.name} 登录失败!")
elif not user.is_active: raise HTTPException(status_code=401, detail="用户名、密码或二次校验码不正确")
elif user and not user.is_active:
raise HTTPException(status_code=403, detail="用户未启用") raise HTTPException(status_code=403, detail="用户未启用")
logger.info(f"用户 {user.name} 登录成功!") logger.info(f"用户 {user.name} 登录成功!")
return schemas.Token( return schemas.Token(
access_token=security.create_access_token( access_token=security.create_access_token(
user.id, userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
), ),
token_type="bearer", token_type="bearer",

View File

@@ -1,16 +1,14 @@
from pathlib import Path
from typing import List, Any from typing import List, Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas from app import schemas
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.core.config import settings from app.core.config import settings
from app.core.context import Context from app.core.context import Context
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.security import verify_token, verify_uri_token from app.core.security import verify_token, verify_uri_token
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.schemas import MediaType from app.schemas import MediaType
router = APIRouter() router = APIRouter()
@@ -79,26 +77,26 @@ def search_by_title(title: str,
return [] return []
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response) @router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
def exists(title: str = None, def scrape(path: str,
year: int = None,
mtype: str = None,
tmdbid: int = None,
season: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
判断本地是否存在 刮削媒体信息
""" """
meta = MetaInfo(title) if not path:
if not season: return schemas.Response(success=False, message="刮削路径无效")
season = meta.begin_season scrape_path = Path(path)
exist = MediaServerOper(db).exists( if not scrape_path.exists():
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season return schemas.Response(success=False, message="刮削路径不存在")
) # 识别
return schemas.Response(success=True if exist else False, data={ chain = MediaChain()
"item": exist or {} meta = MetaInfoPath(scrape_path)
}) mediainfo = chain.recognize_media(meta)
if not media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
# 刮削
chain.scrape_metadata(path=scrape_path, mediainfo=mediainfo, transfer_type=settings.TRANSFER_TYPE)
return schemas.Response(success=True, message="刮削完成")
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo) @router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
@@ -108,28 +106,17 @@ def media_info(mediaid: str, type_name: str,
根据媒体ID查询themoviedb或豆瓣媒体信息type_name: 电影/电视剧 根据媒体ID查询themoviedb或豆瓣媒体信息type_name: 电影/电视剧
""" """
mtype = MediaType(type_name) mtype = MediaType(type_name)
tmdbid, doubanid = None, None tmdbid, doubanid, bangumiid = None, None, None
if mediaid.startswith("tmdb:"): if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid[5:]) tmdbid = int(mediaid[5:])
elif mediaid.startswith("douban:"): elif mediaid.startswith("douban:"):
doubanid = mediaid[7:] doubanid = mediaid[7:]
if not tmdbid and not doubanid: elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid[8:])
if not tmdbid and not doubanid and not bangumiid:
return schemas.MediaInfo() return schemas.MediaInfo()
if settings.RECOGNIZE_SOURCE == "themoviedb": # 识别
if not tmdbid and doubanid: mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, mtype=mtype)
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: if mediainfo:
MediaChain().obtain_images(mediainfo) MediaChain().obtain_images(mediainfo)
return mediainfo.to_dict() return mediainfo.to_dict()

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

@@ -1,18 +1,24 @@
import json
from typing import Union, Any, List from typing import Union, Any, List
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import Request from fastapi import Request
from sqlalchemy.orm import Session
from starlette.responses import PlainTextResponse from starlette.responses import PlainTextResponse
from app import schemas from app import schemas
from app.chain.message import MessageChain from app.chain.message import MessageChain
from app.core.config import settings from app.core.config import settings
from app.core.security import verify_token from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
from app.db.models.message import Message
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.db.userauth import get_current_active_superuser
from app.log import logger from app.log import logger
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
from app.schemas import NotificationSwitch from app.schemas import NotificationSwitch
from app.schemas.types import SystemConfigKey, NotificationType from app.schemas.types import SystemConfigKey, NotificationType, MessageChannel
router = APIRouter() router = APIRouter()
@@ -36,13 +42,44 @@ async def user_message(background_tasks: BackgroundTasks, request: Request):
return schemas.Response(success=True) return schemas.Response(success=True)
@router.get("/", summary="微信验证") @router.post("/web", summary="接收WEB消息", response_model=schemas.Response)
def web_message(text: str, current_user: User = Depends(get_current_active_superuser)):
"""
WEB消息响应
"""
MessageChain().handle_message(
channel=MessageChannel.Web,
userid=current_user.name,
username=current_user.name,
text=text
)
return schemas.Response(success=True)
@router.get("/web", summary="获取WEB消息", response_model=List[dict])
def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
db: Session = Depends(get_db),
page: int = 1,
count: int = 20):
"""
获取WEB消息列表
"""
ret_messages = []
messages = Message.list_by_page(db, page=page, count=count)
for message in messages:
try:
ret_messages.append(message.to_dict())
except Exception as e:
logger.error(f"获取WEB消息列表失败: {str(e)}")
continue
return ret_messages
def wechat_verify(echostr: str, msg_signature: str, def wechat_verify(echostr: str, msg_signature: str,
timestamp: Union[str, int], nonce: str) -> Any: timestamp: Union[str, int], nonce: str) -> Any:
""" """
用户消息响应 微信验证响应
""" """
logger.info(f"收到微信验证请求: {echostr}")
try: try:
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN, wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY, sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
@@ -60,6 +97,28 @@ def wechat_verify(echostr: str, msg_signature: str,
return PlainTextResponse(sEchoStr) return PlainTextResponse(sEchoStr)
def vocechat_verify(token: str) -> Any:
"""
VoceChat验证响应
"""
if token == settings.API_TOKEN:
return {"status": "OK"}
return {"status": "ERROR"}
@router.get("/", summary="回调请求验证")
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
"""
微信/VoceChat等验证响应
"""
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
if echostr and msg_signature and timestamp and nonce:
return wechat_verify(echostr, msg_signature, timestamp, nonce)
return vocechat_verify(token)
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch]) @router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any: def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
@@ -72,7 +131,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
for noti in NotificationType: for noti in NotificationType:
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True, telegram=True, slack=True,
synologychat=True)) synologychat=True, vocechat=True))
else: else:
for switch in switchs: for switch in switchs:
return_list.append(NotificationSwitch(**switch)) return_list.append(NotificationSwitch(**switch))
@@ -83,7 +142,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def set_switchs(switchs: List[NotificationSwitch], def set_switchs(switchs: List[NotificationSwitch],
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
查询通知消息渠道开关 设置通知消息渠道开关
""" """
switch_list = [] switch_list = []
for switch in switchs: for switch in switchs:

View File

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

View File

@@ -21,17 +21,19 @@ async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
return [torrent.to_dict() for torrent in torrents] return [torrent.to_dict() for torrent in torrents]
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=List[schemas.Context]) @router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
def search_by_id(mediaid: str, def search_by_id(mediaid: str,
mtype: str = None, mtype: str = None,
area: str = "title", area: str = "title",
season: str = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/ 根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
""" """
torrents = []
if mtype: if mtype:
mtype = MediaType(mtype) mtype = MediaType(mtype)
if season:
season = int(season)
if mediaid.startswith("tmdb:"): if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", "")) tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban": if settings.RECOGNIZE_SOURCE == "douban":
@@ -39,9 +41,11 @@ def search_by_id(mediaid: str,
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype) doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
if doubaninfo: if doubaninfo:
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"), torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
mtype=mtype, area=area) mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else: else:
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area) torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area, season=season)
elif mediaid.startswith("douban:"): elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "") doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb": if settings.RECOGNIZE_SOURCE == "themoviedb":
@@ -49,12 +53,36 @@ def search_by_id(mediaid: str,
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype) tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo: if tmdbinfo:
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"), torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=mtype, area=area) mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else: else:
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area) torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area, season=season)
elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过BangumiID识别TMDBID
tmdbinfo = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
if tmdbinfo:
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
# 通过BangumiID识别豆瓣ID
doubaninfo = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
if doubaninfo:
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else: else:
return [] return schemas.Response(success=False, message="未知的媒体ID")
return [torrent.to_dict() for torrent in torrents]
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
else:
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
@router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo]) @router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo])

View File

@@ -50,10 +50,17 @@ def add_site(
return schemas.Response(success=False, message=f"{domain} 站点己存在") return schemas.Response(success=False, message=f"{domain} 站点己存在")
# 保存站点信息 # 保存站点信息
site_in.domain = domain site_in.domain = domain
# 校正地址格式
_scheme, _netloc = StringUtils.get_url_netloc(site_in.url)
site_in.url = f"{_scheme}://{_netloc}/"
site_in.name = site_info.get("name") site_in.name = site_info.get("name")
site_in.id = None site_in.id = None
site = Site(**site_in.dict()) site = Site(**site_in.dict())
site.create(db) site.create(db)
# 通知站点更新
EventManager().send_event(EventType.SiteUpdated, {
"domain": domain
})
return schemas.Response(success=True) return schemas.Response(success=True)
@@ -70,7 +77,14 @@ def update_site(
site = Site.get(db, site_in.id) site = Site.get(db, site_in.id)
if not site: if not site:
return schemas.Response(success=False, message="站点不存在") return schemas.Response(success=False, message="站点不存在")
# 校正地址格式
_scheme, _netloc = StringUtils.get_url_netloc(site_in.url)
site_in.url = f"{_scheme}://{_netloc}/"
site.update(db, site_in.dict()) site.update(db, site_in.dict())
# 通知站点更新
EventManager().send_event(EventType.SiteUpdated, {
"domain": site_in.domain
})
return schemas.Response(success=True) return schemas.Response(success=True)
@@ -103,8 +117,8 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
@router.get("/reset", summary="重置站点", response_model=schemas.Response) @router.get("/reset", summary="重置站点", response_model=schemas.Response)
def cookie_cloud_sync(db: Session = Depends(get_db), def reset(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
清空所有站点数据并重新同步CookieCloud站点信息 清空所有站点数据并重新同步CookieCloud站点信息
""" """
@@ -116,7 +130,7 @@ def cookie_cloud_sync(db: Session = Depends(get_db),
# 插件站点删除 # 插件站点删除
EventManager().send_event(EventType.SiteDeleted, EventManager().send_event(EventType.SiteDeleted,
{ {
"site_id": None "site_id": "*"
}) })
return schemas.Response(success=True, message="站点已重置!") return schemas.Response(success=True, message="站点已重置!")
@@ -126,6 +140,7 @@ def update_cookie(
site_id: int, site_id: int,
username: str, username: str,
password: str, password: str,
code: str = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
@@ -141,7 +156,8 @@ def update_cookie(
# 更新Cookie # 更新Cookie
state, message = SiteChain().update_cookie(site_info=site_info, state, message = SiteChain().update_cookie(site_info=site_info,
username=username, username=username,
password=password) password=password,
two_step_code=code)
return schemas.Response(success=state, message=message) return schemas.Response(success=state, message=message)

View File

@@ -65,7 +65,7 @@ def create_subscribe(
else: else:
mtype = None mtype = None
# 豆瓣标理 # 豆瓣标理
if subscribe_in.doubanid: if subscribe_in.doubanid or subscribe_in.bangumiid:
meta = MetaInfo(subscribe_in.name) meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name subscribe_in.name = meta.name
subscribe_in.season = meta.begin_season subscribe_in.season = meta.begin_season
@@ -80,13 +80,15 @@ def create_subscribe(
tmdbid=subscribe_in.tmdbid, tmdbid=subscribe_in.tmdbid,
season=subscribe_in.season, season=subscribe_in.season,
doubanid=subscribe_in.doubanid, doubanid=subscribe_in.doubanid,
bangumiid=subscribe_in.bangumiid,
username=current_user.name, username=current_user.name,
best_version=subscribe_in.best_version, best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path, save_path=subscribe_in.save_path,
search_imdbid=subscribe_in.search_imdbid,
exist_ok=True) exist_ok=True)
return schemas.Response(success=True if sid else False, message=message, data={ return schemas.Response(
"id": sid success=bool(sid), message=message, data={"id": sid}
}) )
@router.put("/", summary="更新订阅", response_model=schemas.Response) @router.put("/", summary="更新订阅", response_model=schemas.Response)
@@ -115,6 +117,9 @@ def update_subscribe(
subscribe_dict["lack_episode"] = (subscribe.lack_episode subscribe_dict["lack_episode"] = (subscribe.lack_episode
+ (subscribe_in.total_episode + (subscribe_in.total_episode
- (subscribe.total_episode or 0))) - (subscribe.total_episode or 0)))
# 是否手动修改过总集数
if subscribe_in.total_episode != subscribe.total_episode:
subscribe_dict["manual_total_episode"] = 1
subscribe.update(db, subscribe_dict) subscribe.update(db, subscribe_dict)
return schemas.Response(success=True) return schemas.Response(success=True)
@@ -127,9 +132,10 @@ def subscribe_mediaid(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any: _: schemas.TokenPayload = Depends(verify_token)) -> Any:
""" """
根据TMDBID豆瓣ID查询订阅 tmdb:/douban: 根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
""" """
result = None result = None
title_check = False
if mediaid.startswith("tmdb:"): if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:] tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit(): if not tmdbid or not str(tmdbid).isdigit():
@@ -140,13 +146,21 @@ def subscribe_mediaid(
if not doubanid: if not doubanid:
return Subscribe() return Subscribe()
result = Subscribe.get_by_doubanid(db, doubanid) result = Subscribe.get_by_doubanid(db, doubanid)
if not result and title:
if not result and title: title_check = True
elif mediaid.startswith("bangumi:"):
bangumiid = mediaid[8:]
if not bangumiid or not str(bangumiid).isdigit():
return Subscribe()
result = Subscribe.get_by_bangumiid(db, int(bangumiid))
if not result and title:
title_check = True
# 使用名称检查订阅
if title_check and title:
meta = MetaInfo(title) meta = MetaInfo(title)
if season: if season:
meta.begin_season = season meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season) result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
if result and result.sites: if result and result.sites:
result.sites = json.loads(result.sites) result.sites = json.loads(result.sites)

View File

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

View File

@@ -6,7 +6,7 @@ from sqlalchemy.orm import Session
from app import schemas from app import schemas
from app.chain.transfer import TransferChain from app.chain.transfer import TransferChain
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 import get_db
from app.db.models.transferhistory import TransferHistory from app.db.models.transferhistory import TransferHistory
from app.schemas import MediaType from app.schemas import MediaType
@@ -37,7 +37,7 @@ def manual_transfer(path: str = None,
:param type_name: 媒体类型、电影/电视剧 :param type_name: 媒体类型、电影/电视剧
:param tmdbid: tmdbid :param tmdbid: tmdbid
:param season: 剧集季号 :param season: 剧集季号
:param transfer_type: 转移类型move/copy :param transfer_type: 转移类型move/copy
:param episode_format: 剧集识别格式 :param episode_format: 剧集识别格式
:param episode_detail: 剧集识别详细信息 :param episode_detail: 剧集识别详细信息
:param episode_part: 剧集识别分集信息 :param episode_part: 剧集识别分集信息
@@ -56,16 +56,20 @@ def manual_transfer(path: str = None,
return schemas.Response(success=False, message=f"历史记录不存在ID{logid}") return schemas.Response(success=False, message=f"历史记录不存在ID{logid}")
# 强制转移 # 强制转移
force = True force = True
# 源路径 if history.status and ("move" in history.mode):
in_path = Path(history.src) # 重新整理成功的转移,则使用成功的 dest 做 in_path
# 目的路径 in_path = Path(history.dest)
if history.dest and str(history.dest) != "None": else:
# 删除旧的已整理文件 # 源路径
transfer.delete_files(Path(history.dest)) in_path = Path(history.src)
if not target: # 目的路径
target = transfer.get_root_path(path=history.dest, if history.dest and str(history.dest) != "None":
type_name=history.type, # 删除旧的已整理文件
category=history.category) 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: elif path:
in_path = Path(path) in_path = Path(path)
else: else:
@@ -101,3 +105,12 @@ def manual_transfer(path: str = None,
return schemas.Response(success=False, message=errormsg) return schemas.Response(success=False, message=errormsg)
# 成功 # 成功
return schemas.Response(success=True) return schemas.Response(success=True)
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
def now(_: str = Depends(verify_uri_token)) -> Any:
"""
立即执行下载器文件整理 API_TOKEN认证?token=xxx
"""
TransferChain().process()
return schemas.Response(success=True)

View File

@@ -1,4 +1,5 @@
import base64 import base64
import re
from typing import Any, List from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
@@ -9,14 +10,15 @@ from app.core.security import get_password_hash
from app.db import get_db from app.db import get_db
from app.db.models.user import User from app.db.models.user import User
from app.db.userauth import get_current_active_superuser, get_current_active_user from app.db.userauth import get_current_active_superuser, get_current_active_user
from app.utils.otp import OtpUtils
router = APIRouter() router = APIRouter()
@router.get("/", summary="所有用户", response_model=List[schemas.User]) @router.get("/", summary="所有用户", response_model=List[schemas.User])
def read_users( def read_users(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_superuser), current_user: User = Depends(get_current_active_superuser),
) -> Any: ) -> Any:
""" """
查询用户列表 查询用户列表
@@ -27,10 +29,10 @@ def read_users(
@router.post("/", summary="新增用户", response_model=schemas.Response) @router.post("/", summary="新增用户", response_model=schemas.Response)
def create_user( def create_user(
*, *,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user_in: schemas.UserCreate, user_in: schemas.UserCreate,
current_user: User = Depends(get_current_active_superuser), current_user: User = Depends(get_current_active_superuser),
) -> Any: ) -> Any:
""" """
新增用户 新增用户
@@ -49,16 +51,21 @@ def create_user(
@router.put("/", summary="更新用户", response_model=schemas.Response) @router.put("/", summary="更新用户", response_model=schemas.Response)
def update_user( def update_user(
*, *,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user_in: schemas.UserCreate, user_in: schemas.UserCreate,
_: User = Depends(get_current_active_superuser), _: User = Depends(get_current_active_superuser),
) -> Any: ) -> Any:
""" """
更新用户 更新用户
""" """
user_info = user_in.dict() user_info = user_in.dict()
if user_info.get("password"): 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["hashed_password"] = get_password_hash(user_info["password"])
user_info.pop("password") user_info.pop("password")
user = User.get_by_name(db, name=user_info["name"]) user = User.get_by_name(db, name=user_info["name"])
@@ -70,7 +77,7 @@ def update_user(
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User) @router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
def read_current_user( def read_current_user(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
) -> Any: ) -> Any:
""" """
当前登录用户信息 当前登录用户信息
@@ -96,12 +103,51 @@ async def upload_avatar(user_id: int, db: Session = Depends(get_db),
return schemas.Response(success=True, message=file.filename) return schemas.Response(success=True, message=file.filename)
@router.post('/otp/generate', summary='生成otp验证uri', response_model=schemas.Response)
def otp_generate(
current_user: User = Depends(get_current_active_user)
) -> Any:
secret, uri = OtpUtils.generate_secret_key(current_user.name)
return schemas.Response(success=secret != "", data={'secret': secret, 'uri': uri})
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
def otp_judge(
data: dict,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
) -> Any:
uri = data.get("uri")
otp_password = data.get("otpPassword")
if not OtpUtils.is_legal(uri, otp_password):
return schemas.Response(success=False, message="验证码错误")
current_user.update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
return schemas.Response(success=True)
@router.post('/otp/disable', summary='关闭当前用户的otp验证', response_model=schemas.Response)
def otp_disable(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
) -> Any:
current_user.update_otp_by_name(db, current_user.name, False, "")
return schemas.Response(success=True)
@router.get('/otp/{userid}', summary='判断当前用户是否开启otp验证', response_model=schemas.Response)
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
user: User = User.get_by_name(db, userid)
if not user:
return schemas.Response(success=False, message="用户不存在")
return schemas.Response(success=user.is_otp)
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response) @router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
def delete_user( def delete_user(
*, *,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user_name: str, user_name: str,
current_user: User = Depends(get_current_active_superuser), current_user: User = Depends(get_current_active_superuser),
) -> Any: ) -> Any:
""" """
删除用户 删除用户
@@ -115,9 +161,9 @@ def delete_user(
@router.get("/{user_id}", summary="用户详情", response_model=schemas.User) @router.get("/{user_id}", summary="用户详情", response_model=schemas.User)
def read_user_by_id( def read_user_by_id(
user_id: int, user_id: int,
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Any: ) -> Any:
""" """
查询用户详情 查询用户详情

View File

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

137
app/api/servcookie.py Normal file
View File

@@ -0,0 +1,137 @@
import gzip
import json
from hashlib import md5
from typing import Annotated, Callable
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
from app import schemas
from app.core.config import settings
from app.log import logger
from app.utils.common import decrypt
class GzipRequest(Request):
async def body(self) -> bytes:
if not hasattr(self, "_body"):
body = await super().body()
if "gzip" in self.headers.getlist("Content-Encoding"):
body = gzip.decompress(body)
self._body = body
return self._body
class GzipRoute(APIRoute):
def get_route_handler(self) -> Callable:
original_route_handler = super().get_route_handler()
async def custom_route_handler(request: Request) -> Response:
request = GzipRequest(request.scope, request.receive)
return await original_route_handler(request)
return custom_route_handler
async def verify_server_enabled():
"""
校验CookieCloud服务路由是否打开
"""
if not settings.COOKIECLOUD_ENABLE_LOCAL:
raise HTTPException(status_code=400, detail="本地CookieCloud服务器未启用")
return True
cookie_router = APIRouter(route_class=GzipRoute,
tags=['servcookie'],
dependencies=[Depends(verify_server_enabled)])
@cookie_router.get("/", response_class=PlainTextResponse)
def get_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.post("/", response_class=PlainTextResponse)
def post_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.post("/update")
async def update_cookie(req: schemas.CookieData):
"""
上传Cookie数据
"""
file_path = settings.COOKIE_PATH / f"{req.uuid}.json"
content = json.dumps({"encrypted": req.encrypted})
with open(file_path, encoding="utf-8", mode="w") as file:
file.write(content)
with open(file_path, encoding="utf-8", mode="r") as file:
read_content = file.read()
if read_content == content:
return {"action": "done"}
else:
return {"action": "error"}
def load_encrypt_data(uuid: str) -> Dict[str, Any]:
"""
加载本地加密原始数据
"""
file_path = settings.COOKIE_PATH / f"{uuid}.json"
# 检查文件是否存在
if not file_path.exists():
raise HTTPException(status_code=404, detail="Item not found")
# 读取文件
with open(file_path, encoding="utf-8", mode="r") as file:
read_content = file.read()
data = json.loads(read_content.encode("utf-8"))
return data
def get_decrypted_cookie_data(uuid: str, password: str,
encrypted: str) -> Optional[Dict[str, Any]]:
"""
加载本地加密数据并解密为Cookie
"""
key_md5 = md5()
key_md5.update((uuid + '-' + password).encode('utf-8'))
aes_key = (key_md5.hexdigest()[:16]).encode('utf-8')
if encrypted:
try:
decrypted_data = decrypt(encrypted, aes_key).decode('utf-8')
decrypted_data = json.loads(decrypted_data)
if 'cookie_data' in decrypted_data:
return decrypted_data
except Exception as e:
logger.error(f"解密Cookie数据失败{str(e)}")
return None
else:
return None
@cookie_router.get("/get/{uuid}")
async def get_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")]):
"""
GET 下载加密数据
"""
return load_encrypt_data(uuid)
@cookie_router.post("/get/{uuid}")
async def post_cookie(
uuid: Annotated[str, Path(min_length=5, pattern="^[a-zA-Z0-9]+$")],
request: schemas.CookiePassword):
"""
POST 下载加密数据
"""
data = load_encrypt_data(uuid)
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])

View File

@@ -15,6 +15,8 @@ from app.core.context import MediaInfo, TorrentInfo
from app.core.event import EventManager from app.core.event import EventManager
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.module import ModuleManager from app.core.module import ModuleManager
from app.db.message_oper import MessageOper
from app.helper.message import MessageHelper
from app.log import logger from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \ from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
WebhookEventInfo, TmdbEpisode WebhookEventInfo, TmdbEpisode
@@ -33,6 +35,8 @@ class ChainBase(metaclass=ABCMeta):
""" """
self.modulemanager = ModuleManager() self.modulemanager = ModuleManager()
self.eventmanager = EventManager() self.eventmanager = EventManager()
self.messageoper = MessageOper()
self.messagehelper = MessageHelper()
@staticmethod @staticmethod
def load_cache(filename: str) -> Any: def load_cache(filename: str) -> Any:
@@ -108,19 +112,23 @@ class ChainBase(metaclass=ABCMeta):
break break
except Exception as err: except Exception as err:
logger.error( logger.error(
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.print_exc()}") f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
return result return result
def recognize_media(self, meta: MetaBase = None, def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None, mtype: MediaType = None,
tmdbid: int = None, tmdbid: int = None,
doubanid: str = None) -> Optional[MediaInfo]: doubanid: str = None,
bangumiid: int = None,
cache: bool = True) -> Optional[MediaInfo]:
""" """
识别媒体信息 识别媒体信息
:param meta: 识别的元数据 :param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套 :param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid :param tmdbid: tmdbid
:param doubanid: 豆瓣ID :param doubanid: 豆瓣ID
:param bangumiid: BangumiID
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息 :return: 识别的媒体信息,包括剧集信息
""" """
# 识别用名中含指定信息情形 # 识别用名中含指定信息情形
@@ -130,8 +138,12 @@ class ChainBase(metaclass=ABCMeta):
tmdbid = meta.tmdbid tmdbid = meta.tmdbid
if not doubanid and hasattr(meta, "doubanid"): if not doubanid and hasattr(meta, "doubanid"):
doubanid = meta.doubanid doubanid = meta.doubanid
# 有tmdbid时不使用其它ID
if tmdbid:
doubanid = None
bangumiid = None
return self.run_module("recognize_media", meta=meta, mtype=mtype, return self.run_module("recognize_media", meta=meta, mtype=mtype,
tmdbid=tmdbid, doubanid=doubanid) tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, cache=cache)
def match_doubaninfo(self, name: str, imdbid: str = None, def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]: mtype: MediaType = None, year: str = None, season: int = None) -> Optional[dict]:
@@ -208,6 +220,14 @@ class ChainBase(metaclass=ABCMeta):
""" """
return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype) return self.run_module("tmdb_info", tmdbid=tmdbid, mtype=mtype)
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
获取Bangumi信息
:param bangumiid: int
:return: Bangumi信息
"""
return self.run_module("bangumi_info", bangumiid=bangumiid)
def message_parser(self, body: Any, form: Any, def message_parser(self, body: Any, form: Any,
args: Any) -> Optional[CommingMessage]: args: Any) -> Optional[CommingMessage]:
""" """
@@ -280,7 +300,8 @@ class ChainBase(metaclass=ABCMeta):
mediainfo=mediainfo) mediainfo=mediainfo)
def download(self, content: Union[Path, str], download_dir: Path, cookie: str, def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: str = None episodes: Set[int] = None, category: str = None,
downloader: str = settings.DEFAULT_DOWNLOADER
) -> Optional[Tuple[Optional[str], str]]: ) -> Optional[Tuple[Optional[str], str]]:
""" """
根据种子文件,选择并添加下载任务 根据种子文件,选择并添加下载任务
@@ -289,10 +310,12 @@ class ChainBase(metaclass=ABCMeta):
:param cookie: cookie :param cookie: cookie
:param episodes: 需要下载的集数 :param episodes: 需要下载的集数
:param category: 种子分类 :param category: 种子分类
:param downloader: 下载器
:return: 种子Hash错误信息 :return: 种子Hash错误信息
""" """
return self.run_module("download", content=content, download_dir=download_dir, return self.run_module("download", content=content, download_dir=download_dir,
cookie=cookie, episodes=episodes, category=category) cookie=cookie, episodes=episodes, category=category,
downloader=downloader)
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None: def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
""" """
@@ -306,14 +329,17 @@ class ChainBase(metaclass=ABCMeta):
download_dir=download_dir) download_dir=download_dir)
def list_torrents(self, status: TorrentStatus = None, def list_torrents(self, status: TorrentStatus = None,
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: hashs: Union[list, str] = None,
downloader: str = settings.DEFAULT_DOWNLOADER
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
""" """
获取下载器种子列表 获取下载器种子列表
:param status: 种子状态 :param status: 种子状态
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器
:return: 下载器中符合状态的种子列表 :return: 下载器中符合状态的种子列表
""" """
return self.run_module("list_torrents", status=status, hashs=hashs) return self.run_module("list_torrents", status=status, hashs=hashs, downloader=downloader)
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo, def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None, transfer_type: str, target: Path = None,
@@ -329,48 +355,56 @@ class ChainBase(metaclass=ABCMeta):
:return: {path, target_path, message} :return: {path, target_path, message}
""" """
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo, return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
transfer_type=transfer_type, target=target, transfer_type=transfer_type, target=target, episodes_info=episodes_info)
episodes_info=episodes_info)
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None: def transfer_completed(self, hashs: Union[str, list], path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
""" """
转移完成后的处理 转移完成后的处理
:param hashs: 种子Hash :param hashs: 种子Hash
:param path: 源目录 :param path: 源目录
:param downloader: 下载器
""" """
return self.run_module("transfer_completed", hashs=hashs, path=path) return self.run_module("transfer_completed", hashs=hashs, path=path, downloader=downloader)
def remove_torrents(self, hashs: Union[str, list]) -> bool: def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
""" """
删除下载器种子 删除下载器种子
:param hashs: 种子Hash :param hashs: 种子Hash
:param delete_file: 是否删除文件
:param downloader: 下载器
:return: bool :return: bool
""" """
return self.run_module("remove_torrents", hashs=hashs) return self.run_module("remove_torrents", hashs=hashs, delete_file=delete_file, downloader=downloader)
def start_torrents(self, hashs: Union[list, str]) -> bool: def start_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
""" """
开始下载 开始下载
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器
:return: bool :return: bool
""" """
return self.run_module("start_torrents", hashs=hashs) return self.run_module("start_torrents", hashs=hashs, downloader=downloader)
def stop_torrents(self, hashs: Union[list, str]) -> bool: def stop_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> bool:
""" """
停止下载 停止下载
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器
:return: bool :return: bool
""" """
return self.run_module("stop_torrents", hashs=hashs) return self.run_module("stop_torrents", hashs=hashs, downloader=downloader)
def torrent_files(self, tid: str) -> Optional[Union[TorrentFilesList, List[File]]]: def torrent_files(self, tid: str,
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Union[TorrentFilesList, List[File]]]:
""" """
获取种子文件 获取种子文件
:param tid: 种子Hash :param tid: 种子Hash
:param downloader: 下载器
:return: 种子文件 :return: 种子文件
""" """
return self.run_module("torrent_files", tid=tid) return self.run_module("torrent_files", tid=tid, downloader=downloader)
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]: def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
""" """
@@ -387,6 +421,10 @@ class ChainBase(metaclass=ABCMeta):
:param message: 消息体 :param message: 消息体
:return: 成功或失败 :return: 成功或失败
""" """
logger.info(f"发送消息channel={message.channel}"
f"title={message.title}, "
f"text={message.text}"
f"userid={message.userid}")
# 发送事件 # 发送事件
self.eventmanager.send_event(etype=EventType.NoticeMessage, self.eventmanager.send_event(etype=EventType.NoticeMessage,
data={ data={
@@ -397,10 +435,13 @@ class ChainBase(metaclass=ABCMeta):
"image": message.image, "image": message.image,
"userid": message.userid, "userid": message.userid,
}) })
logger.info(f"发送消息channel={message.channel}" # 保存消息
f"title={message.title}, " self.messagehelper.put(message, role="user")
f"text={message.text}" self.messageoper.add(channel=message.channel, mtype=message.mtype,
f"userid={message.userid}") title=message.title, text=message.text,
image=message.image, link=message.link,
userid=message.userid, action=1)
# 发送
self.run_module("post_message", message=message) self.run_module("post_message", message=message)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]: def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -410,6 +451,13 @@ class ChainBase(metaclass=ABCMeta):
:param medias: 媒体列表 :param medias: 媒体列表
:return: 成功或失败 :return: 成功或失败
""" """
note_list = [media.to_dict() for media in medias]
self.messagehelper.put(message, role="user", note=note_list)
self.messageoper.add(channel=message.channel, mtype=message.mtype,
title=message.title, text=message.text,
image=message.image, link=message.link,
userid=message.userid, action=1,
note=note_list)
return self.run_module("post_medias_message", message=message, medias=medias) return self.run_module("post_medias_message", message=message, medias=medias)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]: def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
@@ -419,6 +467,13 @@ class ChainBase(metaclass=ABCMeta):
:param torrents: 种子列表 :param torrents: 种子列表
:return: 成功或失败 :return: 成功或失败
""" """
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
self.messagehelper.put(message, role="user", note=note_list)
self.messageoper.add(channel=message.channel, mtype=message.mtype,
title=message.title, text=message.text,
image=message.image, link=message.link,
userid=message.userid, action=1,
note=note_list)
return self.run_module("post_torrents_message", message=message, torrents=torrents) return self.run_module("post_torrents_message", message=message, torrents=torrents)
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str, def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,

42
app/chain/bangumi.py Normal file
View File

@@ -0,0 +1,42 @@
from typing import Optional, List
from app.chain import ChainBase
from app.utils.singleton import Singleton
class BangumiChain(ChainBase, metaclass=Singleton):
"""
Bangumi处理链单例运行
"""
def calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取Bangumi每日放送
:param page: 页码
:param count: 每页数量
"""
return self.run_module("bangumi_calendar", page=page, count=count)
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
获取Bangumi信息
:param bangumiid: BangumiID
:return: Bangumi信息
"""
return self.run_module("bangumi_info", bangumiid=bangumiid)
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
"""
根据BangumiID查询电影演职员表
:param bangumiid: BangumiID
:param page: 页码
:param count: 数量
"""
return self.run_module("bangumi_credits", bangumiid=bangumiid, page=page, count=count)
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
"""
根据BangumiID查询推荐电影
:param bangumiid: BangumiID
"""
return self.run_module("bangumi_recommend", bangumiid=bangumiid)

View File

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

View File

@@ -15,7 +15,7 @@ class DashboardChain(ChainBase, metaclass=Singleton):
""" """
return self.run_module("media_statistic") return self.run_module("media_statistic")
def downloader_info(self) -> schemas.DownloaderInfo: def downloader_info(self) -> Optional[List[schemas.DownloaderInfo]]:
""" """
下载器信息 下载器信息
""" """

View File

@@ -9,6 +9,7 @@ from typing import List, Optional, Tuple, Set, Dict, Union
from app.chain import ChainBase from app.chain import ChainBase
from app.core.config import settings from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context from app.core.context import MediaInfo, TorrentInfo, Context
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.downloadhistory_oper import DownloadHistoryOper
@@ -33,14 +34,19 @@ class DownloadChain(ChainBase):
self.mediaserver = MediaServerOper() self.mediaserver = MediaServerOper()
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo, def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
channel: MessageChannel = None, channel: MessageChannel = None, userid: str = None, username: str = None):
userid: str = None):
""" """
发送添加下载的消息 发送添加下载的消息
:param meta: 元数据
:param mediainfo: 媒体信息
:param torrent: 种子信息
:param channel: 通知渠道
:param userid: 用户ID指定时精确发送对应用户
:param username: 通知显示的下载用户信息
""" """
msg_text = "" msg_text = ""
if userid: if username:
msg_text = f"用户:{userid}" msg_text = f"用户:{username}"
if torrent.site_name: if torrent.site_name:
msg_text = f"{msg_text}\n站点:{torrent.site_name}" msg_text = f"{msg_text}\n站点:{torrent.site_name}"
if meta.resource_term: if meta.resource_term:
@@ -72,6 +78,7 @@ class DownloadChain(ChainBase):
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
mtype=NotificationType.Download, mtype=NotificationType.Download,
userid=userid,
title=f"{mediainfo.title_year} " title=f"{mediainfo.title_year} "
f"{meta.season_episode} 开始下载", f"{meta.season_episode} 开始下载",
text=msg_text, text=msg_text,
@@ -102,17 +109,27 @@ class DownloadChain(ChainBase):
# 解码参数 # 解码参数
req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8') req_str = base64.b64decode(base64_str.encode('utf-8')).decode('utf-8')
req_params: Dict[str, dict] = json.loads(req_str) req_params: Dict[str, dict] = json.loads(req_str)
# 是否使用cookie
if not req_params.get('cookie'):
cookie = None
# 请求头
if req_params.get('header'):
headers = req_params.get('header')
else:
headers = None
if req_params.get('method') == 'get': if req_params.get('method') == 'get':
# GET请求 # GET请求
res = RequestUtils( res = RequestUtils(
ua=ua, ua=ua,
cookies=cookie cookies=cookie,
headers=headers
).get_res(url, params=req_params.get('params')) ).get_res(url, params=req_params.get('params'))
else: else:
# POST请求 # POST请求
res = RequestUtils( res = RequestUtils(
ua=ua, ua=ua,
cookies=cookie cookies=cookie,
headers=headers
).post_res(url, params=req_params.get('params')) ).post_res(url, params=req_params.get('params'))
if not res: if not res:
return None return None
@@ -133,12 +150,15 @@ class DownloadChain(ChainBase):
return None, "", [] return None, "", []
if torrent.enclosure.startswith("magnet:"): if torrent.enclosure.startswith("magnet:"):
return torrent.enclosure, "", [] return torrent.enclosure, "", []
# Cookie
site_cookie = torrent.site_cookie
if torrent.enclosure.startswith("["): if torrent.enclosure.startswith("["):
# 需要解码获取下载地址 # 需要解码获取下载地址
torrent_url = __get_redict_url(url=torrent.enclosure, torrent_url = __get_redict_url(url=torrent.enclosure,
ua=torrent.site_ua, ua=torrent.site_ua,
cookie=torrent.site_cookie) cookie=site_cookie)
# 涉及解析地址的不使用Cookie下载种子否则MT会出错
site_cookie = None
else: else:
torrent_url = torrent.enclosure torrent_url = torrent.enclosure
if not torrent_url: if not torrent_url:
@@ -147,7 +167,7 @@ class DownloadChain(ChainBase):
# 下载种子文件 # 下载种子文件
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent( torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
url=torrent_url, url=torrent_url,
cookie=torrent.site_cookie, cookie=site_cookie,
ua=torrent.site_ua, ua=torrent.site_ua,
proxy=torrent.site_proxy) proxy=torrent.site_proxy)
@@ -211,7 +231,7 @@ class DownloadChain(ChainBase):
if _media.genre_ids \ if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)): and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫 # 动漫
download_dir = settings.SAVE_ANIME_PATH download_dir = settings.SAVE_ANIME_PATH / _media.category
else: else:
# 电视剧 # 电视剧
download_dir = settings.SAVE_TV_PATH / _media.category download_dir = settings.SAVE_TV_PATH / _media.category
@@ -291,7 +311,7 @@ class DownloadChain(ChainBase):
continue continue
files_to_add.append({ files_to_add.append({
"download_hash": _hash, "download_hash": _hash,
"downloader": settings.DOWNLOADER, "downloader": settings.DEFAULT_DOWNLOADER,
"fullpath": str(download_dir / _folder_name / file), "fullpath": str(download_dir / _folder_name / file),
"savepath": str(download_dir / _folder_name), "savepath": str(download_dir / _folder_name),
"filepath": file, "filepath": file,
@@ -300,19 +320,21 @@ class DownloadChain(ChainBase):
if files_to_add: if files_to_add:
self.downloadhis.add_files(files_to_add) self.downloadhis.add_files(files_to_add)
# 发送消息 # 发送消息群发不带channel和userid
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, channel=channel, userid=userid) self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, username=username)
# 下载成功后处理 # 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file) self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
# 广播事件 # 广播事件
self.eventmanager.send_event(EventType.DownloadAdded, { self.eventmanager.send_event(EventType.DownloadAdded, {
"hash": _hash, "hash": _hash,
"context": context "context": context,
"username": username
}) })
else: else:
# 下载失败 # 下载失败
logger.error(f"{_media.title_year} 添加下载任务失败:" logger.error(f"{_media.title_year} 添加下载任务失败:"
f"{_torrent.title} - {_torrent.enclosure}{error_msg}") f"{_torrent.title} - {_torrent.enclosure}{error_msg}")
# 只发送给对应渠道和用户
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
mtype=NotificationType.Manual, mtype=NotificationType.Manual,
@@ -357,12 +379,13 @@ class DownloadChain(ChainBase):
need = list(set(_need).difference(set(_current))) need = list(set(_need).difference(set(_current)))
# 清除已下载的季信息 # 清除已下载的季信息
seas = copy.deepcopy(no_exists.get(_mid)) seas = copy.deepcopy(no_exists.get(_mid))
for _sea in list(seas): if seas:
if _sea not in need: for _sea in list(seas):
no_exists[_mid].pop(_sea) if _sea not in need:
if not no_exists.get(_mid) and no_exists.get(_mid) is not None: no_exists[_mid].pop(_sea)
no_exists.pop(_mid) if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
break no_exists.pop(_mid)
break
return need return need
def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list: def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:
@@ -406,8 +429,8 @@ class DownloadChain(ChainBase):
# 如果是电影,直接下载 # 如果是电影,直接下载
for context in contexts: for context in contexts:
if context.media_info.type == MediaType.MOVIE: if context.media_info.type == MediaType.MOVIE:
if self.download_single(context, save_path=save_path, if self.download_single(context, save_path=save_path, channel=channel,
channel=channel, userid=userid, username=username): userid=userid, username=username):
# 下载成功 # 下载成功
downloaded_list.append(context) downloaded_list.append(context)
@@ -480,8 +503,9 @@ class DownloadChain(ChainBase):
) )
else: else:
# 下载 # 下载
download_id = self.download_single(context, save_path=save_path, download_id = self.download_single(context,
channel=channel, userid=userid, username=username) save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id: if download_id:
# 下载成功 # 下载成功
@@ -490,6 +514,9 @@ class DownloadChain(ChainBase):
need_season = __update_seasons(_mid=need_mid, need_season = __update_seasons(_mid=need_mid,
_need=need_season, _need=need_season,
_current=torrent_season) _current=torrent_season)
if not need_season:
# 全部下载完成
break
# 电视剧季内的集匹配 # 电视剧季内的集匹配
if no_exists: if no_exists:
# TMDBID列表 # TMDBID列表
@@ -540,8 +567,9 @@ class DownloadChain(ChainBase):
# 为需要集的子集则下载 # 为需要集的子集则下载
if torrent_episodes.issubset(set(need_episodes)): if torrent_episodes.issubset(set(need_episodes)):
# 下载 # 下载
download_id = self.download_single(context, save_path=save_path, download_id = self.download_single(context,
channel=channel, userid=userid, username=username) save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id: if download_id:
# 下载成功 # 下载成功
downloaded_list.append(context) downloaded_list.append(context)
@@ -816,6 +844,7 @@ class DownloadChain(ChainBase):
} }
# 下载用户 # 下载用户
torrent.userid = history.userid torrent.userid = history.userid
torrent.username = history.username
ret_torrents.append(torrent) ret_torrents.append(torrent)
return ret_torrents return ret_torrents
@@ -834,3 +863,16 @@ class DownloadChain(ChainBase):
删除下载任务 删除下载任务
""" """
return self.remove_torrents(hashs=[hash_str]) return self.remove_torrents(hashs=[hash_str])
@eventmanager.register(EventType.DownloadFileDeleted)
def download_file_deleted(self, event: Event):
"""
下载文件删除时,同步删除下载任务
"""
if not event:
return
hash_str = event.event_data.get("hash")
if not hash_str:
return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
self.remove_torrents(hashs=[hash_str], delete_file=False)

View File

@@ -195,14 +195,11 @@ class MediaChain(ChainBase, metaclass=Singleton):
doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype) doubaninfo = self.douban_info(doubanid=doubanid, mtype=mtype)
if doubaninfo: if doubaninfo:
# 优先使用原标题匹配 # 优先使用原标题匹配
season_meta = None
if doubaninfo.get("original_title"): 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")) meta = MetaInfo(title=doubaninfo.get("title"))
meta_org = MetaInfo(title=doubaninfo.get("original_title"))
else:
meta_org = meta = MetaInfo(title=doubaninfo.get("title"))
# 年份 # 年份
if doubaninfo.get("year"): if doubaninfo.get("year"):
meta.year = doubaninfo.get("year") meta.year = doubaninfo.get("year")
@@ -211,24 +208,53 @@ class MediaChain(ChainBase, metaclass=Singleton):
meta.type = doubaninfo.get('media_type') meta.type = doubaninfo.get('media_type')
else: else:
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
# 使用原标题识别TMDB媒体信息 # 匹配TMDB信息
tmdbinfo = self.match_tmdbinfo( meta_names = list(dict.fromkeys([k for k in [meta_org.name,
name=meta.name, meta.cn_name,
year=meta.year, meta.en_name] if k]))
mtype=mtype or meta.type, for name in meta_names:
season=meta.begin_season tmdbinfo = self.match_tmdbinfo(
) name=name,
if not tmdbinfo: year=meta.year,
if season_meta and season_meta.name != meta.name: mtype=mtype or meta.type,
# 使用主标题识别媒体信息 season=meta.begin_season
tmdbinfo = self.match_tmdbinfo( )
name=season_meta.name, if tmdbinfo:
year=meta.year, break
mtype=mtype or meta.type,
season=meta.begin_season
)
return tmdbinfo return tmdbinfo
def get_tmdbinfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
"""
根据BangumiID获取TMDB信息
"""
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
if bangumiinfo:
# 优先使用原标题匹配
if bangumiinfo.get("name_cn"):
meta = MetaInfo(title=bangumiinfo.get("name"))
meta_cn = MetaInfo(title=bangumiinfo.get("name_cn"))
else:
meta_cn = meta = MetaInfo(title=bangumiinfo.get("name"))
# 年份
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
if release_date:
year = release_date[:4]
else:
year = None
# 识别TMDB媒体信息
meta_names = list(dict.fromkeys([k for k in [meta_cn.name,
meta.name] if k]))
for name in meta_names:
tmdbinfo = self.match_tmdbinfo(
name=name,
year=year,
mtype=MediaType.TV,
season=meta.begin_season
)
if tmdbinfo:
return tmdbinfo
return None
def get_doubaninfo_by_tmdbid(self, tmdbid: int, def get_doubaninfo_by_tmdbid(self, tmdbid: int,
mtype: MediaType = None, season: int = None) -> Optional[dict]: mtype: MediaType = None, season: int = None) -> Optional[dict]:
""" """
@@ -261,3 +287,29 @@ class MediaChain(ChainBase, metaclass=Singleton):
imdbid=imdbid imdbid=imdbid
) )
return None return None
def get_doubaninfo_by_bangumiid(self, bangumiid: int) -> Optional[dict]:
"""
根据BangumiID获取豆瓣信息
"""
bangumiinfo = self.bangumi_info(bangumiid=bangumiid)
if bangumiinfo:
# 优先使用中文标题匹配
if bangumiinfo.get("name_cn"):
meta = MetaInfo(title=bangumiinfo.get("name_cn"))
else:
meta = MetaInfo(title=bangumiinfo.get("name"))
# 年份
release_date = bangumiinfo.get("date") or bangumiinfo.get("air_date")
if release_date:
year = release_date[:4]
else:
year = None
# 使用名称识别豆瓣媒体信息
return self.match_doubaninfo(
name=meta.name,
year=year,
mtype=MediaType.TV,
season=meta.begin_season
)
return None

View File

@@ -1,6 +1,6 @@
import json import json
import threading import threading
from typing import List, Union from typing import List, Union, Optional
from app import schemas from app import schemas
from app.chain import ChainBase from app.chain import ChainBase
@@ -20,11 +20,11 @@ class MediaServerChain(ChainBase):
super().__init__() super().__init__()
self.dboper = MediaServerOper() 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]: def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
""" """
@@ -44,22 +44,40 @@ class MediaServerChain(ChainBase):
""" """
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id) 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): def sync(self):
""" """
同步媒体库所有数据到本地数据库 同步媒体库所有数据到本地数据库
""" """
# 设置的媒体服务器
if not settings.MEDIASERVER:
return
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
mediaservers = settings.MEDIASERVER.split(",")
with lock: with lock:
# 汇总统计 # 汇总统计
total_count = 0 total_count = 0
# 清空登记薄 # 清空登记薄
self.dboper.empty() self.dboper.empty()
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
# 设置的媒体服务器
if not settings.MEDIASERVER:
return
mediaservers = settings.MEDIASERVER.split(",")
# 遍历媒体服务器 # 遍历媒体服务器
for mediaserver in mediaservers: for mediaserver in mediaservers:
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...") logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")

View File

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

View File

@@ -1,5 +1,5 @@
import pickle import pickle
import re import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime from datetime import datetime
from typing import Dict from typing import Dict
@@ -8,6 +8,7 @@ from typing import List, Optional
from app.chain import ChainBase from app.chain import ChainBase
from app.core.context import Context from app.core.context import Context
from app.core.context import MediaInfo, TorrentInfo from app.core.context import MediaInfo, TorrentInfo
from app.core.event import eventmanager, Event
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.helper.progress import ProgressHelper from app.helper.progress import ProgressHelper
@@ -15,8 +16,7 @@ from app.helper.sites import SitesHelper
from app.helper.torrent import TorrentHelper from app.helper.torrent import TorrentHelper
from app.log import logger from app.log import logger
from app.schemas import NotExistMediaInfo from app.schemas import NotExistMediaInfo
from app.schemas.types import MediaType, ProgressKey, SystemConfigKey from app.schemas.types import MediaType, ProgressKey, SystemConfigKey, EventType
from app.utils.string import StringUtils
class SearchChain(ChainBase): class SearchChain(ChainBase):
@@ -32,19 +32,27 @@ class SearchChain(ChainBase):
self.torrenthelper = TorrentHelper() self.torrenthelper = TorrentHelper()
def search_by_id(self, tmdbid: int = None, doubanid: str = None, def search_by_id(self, tmdbid: int = None, doubanid: str = None,
mtype: MediaType = None, area: str = "title") -> List[Context]: mtype: MediaType = None, area: str = "title", season: int = None) -> List[Context]:
""" """
根据TMDBID/豆瓣ID搜索资源精确匹配但不不过滤本地存在的资源 根据TMDBID/豆瓣ID搜索资源精确匹配但不不过滤本地存在的资源
:param tmdbid: TMDB ID :param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID :param doubanid: 豆瓣 ID
:param mtype: 媒体,电影 or 电视剧 :param mtype: 媒体,电影 or 电视剧
:param area: 搜索范围title or imdbid :param area: 搜索范围title or imdbid
:param season: 季数
""" """
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype) mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if not mediainfo: if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!') logger.error(f'{tmdbid} 媒体信息识别失败!')
return [] return []
results = self.process(mediainfo=mediainfo, area=area) no_exists = None
if season:
no_exists = {
tmdbid or doubanid: {
season: NotExistMediaInfo(episodes=[])
}
}
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
# 保存眲结果 # 保存眲结果
bytes_results = pickle.dumps(results) bytes_results = pickle.dumps(results)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results) self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
@@ -74,7 +82,7 @@ class SearchChain(ChainBase):
try: try:
return pickle.loads(results) return pickle.loads(results)
except Exception as e: except Exception as e:
print(str(e)) logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
return [] return []
def process(self, mediainfo: MediaInfo, def process(self, mediainfo: MediaInfo,
@@ -122,10 +130,13 @@ class SearchChain(ChainBase):
# 搜索关键词 # 搜索关键词
if keyword: if keyword:
keywords = [keyword] keywords = [keyword]
elif mediainfo.original_title and mediainfo.title != mediainfo.original_title:
keywords = [mediainfo.title, mediainfo.original_title]
else: else:
keywords = [mediainfo.title] # 去重去空,但要保持顺序
keywords = list(dict.fromkeys([k for k in [mediainfo.title,
mediainfo.original_title,
mediainfo.en_title,
mediainfo.sg_title] if k]))
# 执行搜索 # 执行搜索
torrents: List[TorrentInfo] = self.__search_all_sites( torrents: List[TorrentInfo] = self.__search_all_sites(
mediainfo=mediainfo, mediainfo=mediainfo,
@@ -136,27 +147,8 @@ class SearchChain(ChainBase):
if not torrents: if not torrents:
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源') logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
return [] return []
# 过滤种子 # 开始新进度
if priority_rule is None: self.progress.start(ProgressKey.Search)
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始过滤资源,当前规则:{priority_rule} ...')
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
torrent_list=torrents,
season_episodes=season_episodes,
mediainfo=mediainfo)
if result is not None:
torrents = result
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
# 使用过滤规则再次过滤
torrents = self.filter_torrents_by_rule(torrents=torrents,
filter_rule=filter_rule)
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
# 匹配的资源 # 匹配的资源
_match_torrents = [] _match_torrents = []
# 总数 # 总数
@@ -164,87 +156,83 @@ class SearchChain(ChainBase):
# 已处理数 # 已处理数
_count = 0 _count = 0
if mediainfo: if mediainfo:
self.progress.start(ProgressKey.Search) # 英文标题应该在别名/原标题中,不需要再匹配
logger.info(f'开始匹配,总 {_total} 个资源 ...') logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search) self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
for torrent in torrents: for torrent in torrents:
_count += 1 _count += 1
self.progress.update(value=(_count / _total) * 100, self.progress.update(value=(_count / _total) * 96,
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...', text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
key=ProgressKey.Search) key=ProgressKey.Search)
if not torrent.title:
continue
# 比对IMDBID # 比对IMDBID
if torrent.imdbid \ if torrent.imdbid \
and mediainfo.imdb_id \ and mediainfo.imdb_id \
and torrent.imdbid == mediainfo.imdb_id: and torrent.imdbid == mediainfo.imdb_id:
logger.info(f'{mediainfo.title} 匹配到资源:{torrent.site_name} - {torrent.title}') logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent) _match_torrents.append(torrent)
continue continue
# 识别 # 识别
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description) torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
# 比对类型 if torrent.title != torrent_meta.org_string:
if (torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV) \ logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
or (torrent_meta.type != MediaType.TV and mediainfo.type == MediaType.TV): # 比对种子
logger.warn(f'{torrent.site_name} - {torrent.title} 类型不匹配') if self.torrenthelper.match_torrent(mediainfo=mediainfo,
continue torrent_meta=torrent_meta,
# 比对年份 torrent=torrent):
if mediainfo.year: # 匹配成功
if mediainfo.type == MediaType.TV:
# 剧集年份,每季的年份可能不同
if torrent_meta.year and torrent_meta.year not in [year for year in
mediainfo.season_years.values()]:
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配')
continue
else:
# 电影年份上下浮动1年
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
mediainfo.year,
str(int(mediainfo.year) + 1)]:
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配')
continue
# 比对标题和原语种标题
meta_name = StringUtils.clear_upper(torrent_meta.name)
if meta_name in [
StringUtils.clear_upper(mediainfo.title),
StringUtils.clear_upper(mediainfo.original_title)
]:
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent) _match_torrents.append(torrent)
continue # 匹配完成
# 在副标题中判断是否存在标题与原语种标题 logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
if torrent.description: self.progress.update(value=97,
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)
and str(mediainfo.original_title) in subtitle):
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title}'
f'副标题:{torrent.description}')
_match_torrents.append(torrent)
continue
# 比对别名和译名
for name in mediainfo.names:
if StringUtils.clear_upper(name) == meta_name:
logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent)
break
else:
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
self.progress.update(value=100,
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源', text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
key=ProgressKey.Search) key=ProgressKey.Search)
self.progress.end(ProgressKey.Search)
else: else:
_match_torrents = torrents _match_torrents = torrents
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源") # 开始过滤
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
key=ProgressKey.Search)
# 过滤种子
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始优先级规则过滤,当前规则:{priority_rule} ...')
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
torrent_list=_match_torrents,
season_episodes=season_episodes,
mediainfo=mediainfo)
if result is not None:
_match_torrents = result
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
# 使用过滤规则再次过滤
if _match_torrents:
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
mediainfo=mediainfo,
filter_rule=filter_rule)
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
# 去掉mediainfo中多余的数据 # 去掉mediainfo中多余的数据
mediainfo.clear() mediainfo.clear()
# 组装上下文 # 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
media_info=mediainfo, media_info=mediainfo,
torrent_info=torrent) for torrent in _match_torrents] torrent_info=torrent) for torrent in _match_torrents]
logger.info(f"过滤完成,剩余 {len(contexts)} 个资源")
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
# 排序 # 排序
self.progress.update(value=100,
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
key=ProgressKey.Search)
contexts = self.torrenthelper.sort_torrents(contexts) contexts = self.torrenthelper.sort_torrents(contexts)
# 结束进度
self.progress.end(ProgressKey.Search)
# 返回 # 返回
return contexts return contexts
@@ -336,12 +324,14 @@ class SearchChain(ChainBase):
def filter_torrents_by_rule(self, def filter_torrents_by_rule(self,
torrents: List[TorrentInfo], torrents: List[TorrentInfo],
filter_rule: Dict[str, str] = None mediainfo: MediaInfo,
filter_rule: Dict[str, str] = None,
) -> List[TorrentInfo]: ) -> List[TorrentInfo]:
""" """
使用过滤规则过滤种子 使用过滤规则过滤种子
:param torrents: 种子列表 :param torrents: 种子列表
:param filter_rule: 过滤规则 :param filter_rule: 过滤规则
:param mediainfo: 媒体信息
""" """
if not filter_rule: if not filter_rule:
@@ -349,52 +339,34 @@ class SearchChain(ChainBase):
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules) filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
if not filter_rule: if not filter_rule:
return torrents 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")
def __filter_torrent(t: TorrentInfo) -> bool:
"""
过滤种子
"""
# 包含
if include:
if not re.search(r"%s" % include,
f"{t.title} {t.description}", re.I):
logger.info(f"{t.title} 不匹配包含规则 {include}")
return False
# 排除
if exclude:
if re.search(r"%s" % exclude,
f"{t.title} {t.description}", re.I):
logger.info(f"{t.title} 匹配排除规则 {exclude}")
return False
# 质量
if quality:
if not re.search(r"%s" % quality, t.title, re.I):
logger.info(f"{t.title} 不匹配质量规则 {quality}")
return False
# 分辨率
if resolution:
if not re.search(r"%s" % resolution, t.title, re.I):
logger.info(f"{t.title} 不匹配分辨率规则 {resolution}")
return False
# 特效
if effect:
if not re.search(r"%s" % effect, t.title, re.I):
logger.info(f"{t.title} 不匹配特效规则 {effect}")
return False
return True
# 使用默认过滤规则再次过滤 # 使用默认过滤规则再次过滤
return list(filter(lambda t: __filter_torrent(t), torrents)) return list(filter(
lambda t: self.torrenthelper.filter_torrent(
torrent_info=t,
filter_rule=filter_rule,
mediainfo=mediainfo
),
torrents
))
@eventmanager.register(EventType.SiteDeleted)
def remove_site(self, event: Event):
"""
从搜索站点中移除与已删除站点相关的设置
"""
if not event:
return
event_data = event.event_data or {}
site_id = event_data.get("site_id")
if not site_id:
return
if site_id == "*":
# 清空搜索站点
SystemConfigOper().set(SystemConfigKey.IndexerSites, [])
return
# 从选中的rss站点中移除
selected_sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
if site_id in selected_sites:
selected_sites.remove(site_id)
SystemConfigOper().set(SystemConfigKey.IndexerSites, selected_sites)

View File

@@ -1,16 +1,28 @@
import base64
import re import re
from typing import Union, Tuple from typing import Tuple, Optional
from typing import Union
from urllib.parse import urljoin
from lxml import etree
from app.chain import ChainBase from app.chain import ChainBase
from app.core.config import settings from app.core.config import settings
from app.core.event import eventmanager, Event, EventManager
from app.db.models.site import Site from app.db.models.site import Site
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
from app.db.siteicon_oper import SiteIconOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.browser import PlaywrightHelper from app.helper.browser import PlaywrightHelper
from app.helper.cloudflare import under_challenge from app.helper.cloudflare import under_challenge
from app.helper.cookie import CookieHelper from app.helper.cookie import CookieHelper
from app.helper.cookiecloud import CookieCloudHelper
from app.helper.message import MessageHelper from app.helper.message import MessageHelper
from app.helper.rss import RssHelper
from app.helper.sites import SitesHelper
from app.log import logger from app.log import logger
from app.schemas import MessageChannel, Notification from app.schemas import MessageChannel, Notification
from app.schemas.types import EventType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.site import SiteUtils from app.utils.site import SiteUtils
from app.utils.string import StringUtils from app.utils.string import StringUtils
@@ -24,15 +36,28 @@ class SiteChain(ChainBase):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.siteoper = SiteOper() self.siteoper = SiteOper()
self.siteiconoper = SiteIconOper()
self.siteshelper = SitesHelper()
self.rsshelper = RssHelper()
self.cookiehelper = CookieHelper() self.cookiehelper = CookieHelper()
self.message = MessageHelper() self.message = MessageHelper()
self.cookiecloud = CookieCloudHelper()
self.systemconfig = SystemConfigOper()
# 特殊站点登录验证 # 特殊站点登录验证
self.special_site_test = { self.special_site_test = {
"zhuque.in": self.__zhuque_test, "zhuque.in": self.__zhuque_test,
# "m-team.io": self.__mteam_test, "m-team.io": self.__mteam_test,
"m-team.cc": self.__mteam_test,
"ptlsp.com": self.__ptlsp_test,
} }
def is_special_site(self, domain: str) -> bool:
"""
判断是否特殊站点
"""
return domain in self.special_site_test
@staticmethod @staticmethod
def __zhuque_test(site: Site) -> Tuple[bool, str]: def __zhuque_test(site: Site) -> Tuple[bool, str]:
""" """
@@ -40,8 +65,9 @@ class SiteChain(ChainBase):
""" """
# 获取token # 获取token
token = None token = None
user_agent = site.ua or settings.USER_AGENT
res = RequestUtils( res = RequestUtils(
ua=site.ua, ua=user_agent,
cookies=site.cookie, cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None, proxies=settings.PROXY if site.proxy else None,
timeout=15 timeout=15
@@ -57,7 +83,7 @@ class SiteChain(ChainBase):
headers={ headers={
'X-CSRF-TOKEN': token, 'X-CSRF-TOKEN': token,
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
"User-Agent": f"{site.ua}" "User-Agent": f"{user_agent}"
}, },
cookies=site.cookie, cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None, proxies=settings.PROXY if site.proxy else None,
@@ -74,9 +100,10 @@ class SiteChain(ChainBase):
""" """
判断站点是否已经登陆m-team 判断站点是否已经登陆m-team
""" """
user_agent = site.ua or settings.USER_AGENT
url = f"{site.url}api/member/profile" url = f"{site.url}api/member/profile"
res = RequestUtils( res = RequestUtils(
ua=site.ua, ua=user_agent,
cookies=site.cookie, cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None, proxies=settings.PROXY if site.proxy else None,
timeout=15 timeout=15
@@ -84,9 +111,232 @@ class SiteChain(ChainBase):
if res and res.status_code == 200: if res and res.status_code == 200:
user_info = res.json() user_info = res.json()
if user_info and user_info.get("data"): if user_info and user_info.get("data"):
return True, "连接成功" # 更新最后访问时间
res = RequestUtils(cookies=site.cookie,
ua=user_agent,
timeout=60,
proxies=settings.PROXY if site.proxy else None,
referer=f"{site.url}index"
).post_res(url=urljoin(url, "api/member/updateLastBrowse"))
if res:
return True, "连接成功"
else:
return True, f"连接成功,但更新状态失败"
return False, "Cookie已失效" return False, "Cookie已失效"
@staticmethod
def __ptlsp_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆ptlsp
"""
site.url = f"{site.url}index.php"
return __test(site)
@staticmethod
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
"""
解析站点favicon,返回base64 fav图标
:param url: 站点地址
:param cookie: Cookie
:param ua: User-Agent
:return:
"""
favicon_url = urljoin(url, "favicon.ico")
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
if res:
html_text = res.text
else:
logger.error(f"获取站点页面失败:{url}")
return favicon_url, None
html = etree.HTML(html_text)
if html:
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
if fav_link:
favicon_url = urljoin(url, fav_link[0])
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
logger.error(f"获取站点图标失败:{favicon_url}")
return favicon_url, None
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
"""
通过CookieCloud同步站点Cookie
"""
def __indexer_domain(inx: dict, sub_domain: str) -> str:
"""
根据主域名获取索引器地址
"""
if StringUtils.get_url_domain(inx.get("domain")) == sub_domain:
return inx.get("domain")
for ext_d in inx.get("ext_domains"):
if StringUtils.get_url_domain(ext_d) == sub_domain:
return ext_d
return sub_domain
logger.info("开始同步CookieCloud站点 ...")
cookies, msg = self.cookiecloud.download()
if not cookies:
logger.error(f"CookieCloud同步失败{msg}")
if manual:
self.message.put(f"CookieCloud同步失败 {msg}")
return False, msg
# 保存Cookie或新增站点
_update_count = 0
_add_count = 0
_fail_count = 0
for domain, cookie in cookies.items():
# 索引器信息
indexer = self.siteshelper.get_indexer(domain)
# 数据库的站点信息
site_info = self.siteoper.get_by_domain(domain)
if site_info:
# 站点已存在,检查站点连通性
status, msg = self.test(domain)
# 更新站点Cookie
if status:
logger.info(f"站点【{site_info.name}】连通性正常不同步CookieCloud数据")
# 更新站点rss地址
if not site_info.public and not site_info.rss:
# 自动生成rss地址
rss_url, errmsg = self.rsshelper.get_rss_link(
url=site_info.url,
cookie=cookie,
ua=site_info.ua or settings.USER_AGENT,
proxy=True if site_info.proxy else False
)
if rss_url:
logger.info(f"更新站点 {domain} RSS地址 ...")
self.siteoper.update_rss(domain=domain, rss=rss_url)
else:
logger.warn(errmsg)
continue
# 更新站点Cookie
logger.info(f"更新站点 {domain} Cookie ...")
self.siteoper.update_cookie(domain=domain, cookies=cookie)
_update_count += 1
elif indexer:
# 新增站点
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
res = RequestUtils(cookies=cookie,
ua=settings.USER_AGENT
).get_res(url=domain_url)
if res and res.status_code in [200, 500, 403]:
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
_fail_count += 1
if under_challenge(res.text):
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护无法登录无法添加站点")
continue
logger.warn(
f"站点 {indexer.get('name')} 登录失败没有该站点账号或Cookie已失效无法添加站点")
continue
elif res is not None:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
continue
else:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
continue
# 获取rss地址
rss_url = None
if not indexer.get("public") and domain_url:
# 自动生成rss地址
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
cookie=cookie,
ua=settings.USER_AGENT)
if errmsg:
logger.warn(errmsg)
# 插入数据库
logger.info(f"新增站点 {indexer.get('name')} ...")
self.siteoper.add(name=indexer.get("name"),
url=domain_url,
domain=domain,
cookie=cookie,
rss=rss_url,
public=1 if indexer.get("public") else 0)
_add_count += 1
# 通知站点更新
if indexer:
EventManager().send_event(EventType.SiteUpdated, {
"domain": domain,
})
# 处理完成
ret_msg = f"更新了{_update_count}个站点,新增了{_add_count}个站点"
if _fail_count > 0:
ret_msg += f"{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.message.put(f"CookieCloud同步成功, {ret_msg}")
logger.info(f"CookieCloud同步成功{ret_msg}")
return True, ret_msg
@eventmanager.register(EventType.SiteUpdated)
def cache_site_icon(self, event: Event):
"""
缓存站点图标
"""
if not event:
return
event_data = event.event_data or {}
# 主域名
domain = event_data.get("domain")
if not domain:
return
if str(domain).startswith("http"):
domain = StringUtils.get_url_domain(domain)
# 站点信息
siteinfo = self.siteoper.get_by_domain(domain)
if not siteinfo:
logger.warn(f"未维护站点 {domain} 信息!")
return
# Cookie
cookie = siteinfo.cookie
# 索引器
indexer = self.siteshelper.get_indexer(domain)
if not indexer:
logger.warn(f"站点 {domain} 索引器不存在!")
return
# 查询站点图标
site_icon = self.siteiconoper.get_by_domain(domain)
if not site_icon or not site_icon.base64:
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
cookie=cookie,
ua=settings.USER_AGENT)
if icon_url:
self.siteiconoper.update_icon(name=indexer.get("name"),
domain=domain,
icon_url=icon_url,
icon_base64=icon_base64)
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
else:
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
@eventmanager.register(EventType.SiteUpdated)
def clear_site_data(self, event: Event):
"""
清理站点数据
"""
if not event:
return
event_data = event.event_data or {}
# 主域名
domain = event_data.get("domain")
if not domain:
return
# 获取主域名中间那段
domain_host = StringUtils.get_url_host(domain)
# 查询以"site.domain_host"开头的配置项,并清除
site_keys = self.systemconfig.all().keys()
for key in site_keys:
if key.startswith(f"site.{domain_host}"):
logger.info(f"清理站点配置:{key}")
self.systemconfig.delete(key)
def test(self, url: str) -> Tuple[bool, str]: def test(self, url: str) -> Tuple[bool, str]:
""" """
测试站点是否可用 测试站点是否可用
@@ -99,53 +349,61 @@ class SiteChain(ChainBase):
if not site_info: if not site_info:
return False, f"站点【{url}】不存在" return False, f"站点【{url}】不存在"
# 特殊站点测试 # 模拟登录
if self.special_site_test.get(domain): try:
return self.special_site_test[domain](site_info) # 特殊站点测试
if self.special_site_test.get(domain):
return self.special_site_test[domain](site_info)
# 通用站点测试 # 通用站点测试
return self.__test(site_info)
except Exception as e:
return False, f"{str(e)}"
@staticmethod
def __test(site_info: Site) -> Tuple[bool, str]:
"""
通用站点测试
"""
site_url = site_info.url site_url = site_info.url
site_cookie = site_info.cookie site_cookie = site_info.cookie
ua = site_info.ua ua = site_info.ua or settings.USER_AGENT
render = site_info.render render = site_info.render
public = site_info.public public = site_info.public
proxies = settings.PROXY if site_info.proxy else None proxies = settings.PROXY if site_info.proxy else None
proxy_server = settings.PROXY_SERVER if site_info.proxy else None proxy_server = settings.PROXY_SERVER if site_info.proxy else None
# 模拟登录
try: # 访问链接
# 访问链接 if render:
if render: page_source = PlaywrightHelper().get_page_source(url=site_url,
page_source = PlaywrightHelper().get_page_source(url=site_url, cookies=site_cookie,
cookies=site_cookie, ua=ua,
ua=ua, proxies=proxy_server)
proxies=proxy_server) if not public and not SiteUtils.is_logged_in(page_source):
if not public and not SiteUtils.is_logged_in(page_source): if under_challenge(page_source):
if under_challenge(page_source): return False, f"无法通过Cloudflare"
return False, f"无法通过Cloudflare" return False, f"仿真登录失败Cookie已失效"
return False, f"仿真登录失败Cookie已失效" else:
else: res = RequestUtils(cookies=site_cookie,
res = RequestUtils(cookies=site_cookie, ua=ua,
ua=ua, proxies=proxies
proxies=proxies ).get_res(url=site_url)
).get_res(url=site_url) # 判断登录状态
# 判断登录状态 if res and res.status_code in [200, 500, 403]:
if res and res.status_code in [200, 500, 403]: if not public and not SiteUtils.is_logged_in(res.text):
if not public and not SiteUtils.is_logged_in(res.text): if under_challenge(res.text):
if under_challenge(res.text): msg = "站点被Cloudflare防护请打开站点浏览器仿真"
msg = "站点被Cloudflare防护请打开站点浏览器仿真" elif res.status_code == 200:
elif res.status_code == 200: msg = "Cookie已失效"
msg = "Cookie已失效" else:
else: msg = f"状态码:{res.status_code}"
msg = f"状态码:{res.status_code}" return False, f"{msg}"
return False, f"{msg}" elif public and res.status_code != 200:
elif public and res.status_code != 200:
return False, f"状态码:{res.status_code}"
elif res is not None:
return False, f"状态码:{res.status_code}" return False, f"状态码:{res.status_code}"
else: elif res is not None:
return False, f"无法打开网站" return False, f"状态码:{res.status_code}"
except Exception as e: else:
return False, f"{str(e)}" return False, f"无法打开网站"
return True, "连接成功" return True, "连接成功"
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None): def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
@@ -161,7 +419,7 @@ class SiteChain(ChainBase):
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \ title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
f"\n- 禁用站点:/site_disable [id]" \ f"\n- 禁用站点:/site_disable [id]" \
f"\n- 启用站点:/site_enable [id]" \ f"\n- 启用站点:/site_enable [id]" \
f"\n- 更新站点Cookie/site_cookie [id] [username] [password]" f"\n- 更新站点Cookie/site_cookie [id] [username] [password] [2fa_code/secret]"
messages = [] messages = []
for site in site_list: for site in site_list:
if site.render: if site.render:
@@ -169,9 +427,9 @@ class SiteChain(ChainBase):
else: else:
render_str = "" render_str = ""
if site.is_active: if site.is_active:
messages.append(f"{site.id}. [{site.name}]({site.url}){render_str}") messages.append(f"{site.id}. {site.name} {render_str}")
else: else:
messages.append(f"{site.id}. {site.name}") messages.append(f"{site.id}. {site.name} ⚠️")
# 发送列表 # 发送列表
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
@@ -227,12 +485,13 @@ class SiteChain(ChainBase):
self.remote_list(channel, userid) self.remote_list(channel, userid)
def update_cookie(self, site_info: Site, def update_cookie(self, site_info: Site,
username: str, password: str) -> Tuple[bool, str]: username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
""" """
根据用户名密码更新站点Cookie 根据用户名密码更新站点Cookie
:param site_info: 站点信息 :param site_info: 站点信息
:param username: 用户名 :param username: 用户名
:param password: 密码 :param password: 密码
:param two_step_code: 二步验证码或密钥
:return: (是否成功, 错误信息) :return: (是否成功, 错误信息)
""" """
# 更新站点Cookie # 更新站点Cookie
@@ -240,6 +499,7 @@ class SiteChain(ChainBase):
url=site_info.url, url=site_info.url,
username=username, username=username,
password=password, password=password,
two_step_code=two_step_code,
proxies=settings.PROXY_HOST if site_info.proxy else None proxies=settings.PROXY_HOST if site_info.proxy else None
) )
if result: if result:
@@ -257,8 +517,8 @@ class SiteChain(ChainBase):
""" """
使用用户名密码更新站点Cookie 使用用户名密码更新站点Cookie
""" """
err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password]" \ err_title = "请输入正确的命令格式:/site_cookie [id] [username] [password] [2fa_code/secret]" \
"[id]为站点编号,[uername]为站点用户名,[password]为站点密码" "[id]为站点编号,[uername]为站点用户名,[password]为站点密码[2fa_code/secret]为站点二步验证码或密钥"
if not arg_str: if not arg_str:
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
@@ -266,7 +526,11 @@ class SiteChain(ChainBase):
return return
arg_str = str(arg_str).strip() arg_str = str(arg_str).strip()
args = arg_str.split() args = arg_str.split()
if len(args) != 3: # 二步验证码
two_step_code = None
if len(args) == 4:
two_step_code = args[3]
elif len(args) != 3:
self.post_message(Notification( self.post_message(Notification(
channel=channel, channel=channel,
title=err_title, userid=userid)) title=err_title, userid=userid))
@@ -296,7 +560,8 @@ class SiteChain(ChainBase):
# 更新Cookie # 更新Cookie
status, msg = self.update_cookie(site_info=site_info, status, msg = self.update_cookie(site_info=site_info,
username=username, username=username,
password=password) password=password,
two_step_code=two_step_code)
if not status: if not status:
logger.error(msg) logger.error(msg)
self.post_message(Notification( self.post_message(Notification(

View File

@@ -1,6 +1,5 @@
import json import json
import random import random
import re
import time import time
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Union, Tuple from typing import Dict, List, Optional, Union, Tuple
@@ -12,15 +11,17 @@ from app.chain.search import SearchChain
from app.chain.torrents import TorrentsChain from app.chain.torrents import TorrentsChain
from app.core.config import settings from app.core.config import settings
from app.core.context import TorrentInfo, Context, MediaInfo from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.event import eventmanager, Event, EventManager
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.models.subscribe import Subscribe from app.db.models.subscribe import Subscribe
from app.db.subscribe_oper import SubscribeOper from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper from app.helper.message import MessageHelper
from app.helper.torrent import TorrentHelper
from app.log import logger from app.log import logger
from app.schemas import NotExistMediaInfo, Notification from app.schemas import NotExistMediaInfo, Notification
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType
class SubscribeChain(ChainBase): class SubscribeChain(ChainBase):
@@ -37,11 +38,13 @@ class SubscribeChain(ChainBase):
self.mediachain = MediaChain() self.mediachain = MediaChain()
self.message = MessageHelper() self.message = MessageHelper()
self.systemconfig = SystemConfigOper() self.systemconfig = SystemConfigOper()
self.torrenthelper = TorrentHelper()
def add(self, title: str, year: str, def add(self, title: str, year: str,
mtype: MediaType = None, mtype: MediaType = None,
tmdbid: int = None, tmdbid: int = None,
doubanid: str = None, doubanid: str = None,
bangumiid: int = None,
season: int = None, season: int = None,
channel: MessageChannel = None, channel: MessageChannel = None,
userid: str = None, userid: str = None,
@@ -71,11 +74,11 @@ class SubscribeChain(ChainBase):
if tmdbinfo: if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo) mediainfo = MediaInfo(tmdb_info=tmdbinfo)
else: else:
# 识别TMDB信息 # 识别TMDB信息,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid) mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, cache=False)
else: else:
# 豆瓣识别模式 # 豆瓣识别模式,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid) mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
if mediainfo: if mediainfo:
# 豆瓣标题处理 # 豆瓣标题处理
meta = MetaInfo(mediainfo.title) meta = MetaInfo(mediainfo.title)
@@ -96,7 +99,9 @@ class SubscribeChain(ChainBase):
# 补充媒体信息 # 补充媒体信息
mediainfo = self.recognize_media(mtype=mediainfo.type, mediainfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id, tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id) doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
cache=False)
if not mediainfo: if not mediainfo:
logger.error(f"媒体信息识别失败!") logger.error(f"媒体信息识别失败!")
return None, "媒体信息识别失败" return None, "媒体信息识别失败"
@@ -120,8 +125,21 @@ class SubscribeChain(ChainBase):
# 合并信息 # 合并信息
if doubanid: if doubanid:
mediainfo.douban_id = doubanid mediainfo.douban_id = doubanid
if bangumiid:
mediainfo.bangumi_id = bangumiid
# 添加订阅 # 添加订阅
sid, err_msg = self.subscribeoper.add(mediainfo, season=season, username=username, **kwargs) kwargs.update({
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality"),
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution"),
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect"),
'include': self.__get_default_subscribe_config(mediainfo.type, "include"),
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude"),
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version"),
'search_imdbid': self.__get_default_subscribe_config(mediainfo.type, "search_imdbid"),
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None,
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path"),
})
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
if not sid: if not sid:
logger.error(f'{mediainfo.title_year} {err_msg}') logger.error(f'{mediainfo.title_year} {err_msg}')
if not exist_ok and message: if not exist_ok and message:
@@ -135,16 +153,21 @@ class SubscribeChain(ChainBase):
userid=userid)) userid=userid))
elif message: elif message:
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功') logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
if username or userid: if username:
text = f"评分:{mediainfo.vote_average},来自用户:{username or userid}" text = f"评分:{mediainfo.vote_average},来自用户:{username}"
else: else:
text = f"评分:{mediainfo.vote_average}" text = f"评分:{mediainfo.vote_average}"
# 广而告之 # 群发
self.post_message(Notification(channel=channel, self.post_message(Notification(mtype=NotificationType.Subscribe,
mtype=NotificationType.Subscribe,
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅", title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
text=text, text=text,
image=mediainfo.get_message_image())) image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeAdded, {
"subscribe_id": sid,
"username": username,
"mediainfo": mediainfo.to_dict(),
})
# 返回结果 # 返回结果
return sid, "" return sid, ""
@@ -197,7 +220,8 @@ class SubscribeChain(ChainBase):
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid, tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid) doubanid=subscribe.doubanid,
cache=False)
if not mediainfo: if not mediainfo:
logger.warn( logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}') f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -257,10 +281,7 @@ class SubscribeChain(ChainBase):
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}') logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
# 站点范围 # 站点范围
if subscribe.sites: sites = self.get_sub_sites(subscribe)
sites = json.loads(subscribe.sites)
else:
sites = None
# 优先级过滤规则 # 优先级过滤规则
if subscribe.best_version: if subscribe.best_version:
@@ -277,7 +298,8 @@ class SubscribeChain(ChainBase):
no_exists=no_exists, no_exists=no_exists,
sites=sites, sites=sites,
priority_rule=priority_rule, priority_rule=priority_rule,
filter_rule=filter_rule) filter_rule=filter_rule,
area="imdbid" if subscribe.search_imdbid else "title")
if not contexts: if not contexts:
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源') logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
@@ -320,6 +342,7 @@ class SubscribeChain(ChainBase):
downloads, lefts = self.downloadchain.batch_download( downloads, lefts = self.downloadchain.batch_download(
contexts=matched_contexts, contexts=matched_contexts,
no_exists=no_exists, no_exists=no_exists,
userid=subscribe.username,
username=subscribe.username, username=subscribe.username,
save_path=subscribe.save_path save_path=subscribe.save_path
) )
@@ -353,6 +376,11 @@ class SubscribeChain(ChainBase):
self.post_message(Notification(mtype=NotificationType.Subscribe, 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())) image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,
"mediainfo": mediainfo.to_dict(),
})
else: else:
# 正在洗版,更新资源优先级 # 正在洗版,更新资源优先级
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}') logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
@@ -383,6 +411,11 @@ class SubscribeChain(ChainBase):
self.post_message(Notification(mtype=NotificationType.Subscribe, 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())) image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,
"mediainfo": mediainfo.to_dict(),
})
elif downloads and meta.type == MediaType.TV: elif downloads and meta.type == MediaType.TV:
# 电视剧更新已下载集数 # 电视剧更新已下载集数
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads) self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
@@ -416,6 +449,15 @@ class SubscribeChain(ChainBase):
self.torrentschain.refresh(sites=sites) self.torrentschain.refresh(sites=sites)
) )
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
"""
获取订阅中涉及的站点清单
"""
if subscribe.sites:
return json.loads(subscribe.sites)
# 默认站点
return self.systemconfig.get(SystemConfigKey.RssSites) or []
def get_subscribed_sites(self) -> Optional[List[int]]: def get_subscribed_sites(self) -> Optional[List[int]]:
""" """
获取订阅中涉及的所有站点清单(节约资源) 获取订阅中涉及的所有站点清单(节约资源)
@@ -428,13 +470,8 @@ class SubscribeChain(ChainBase):
ret_sites = [] ret_sites = []
# 刷新订阅选中的Rss站点 # 刷新订阅选中的Rss站点
for subscribe in subscribes: for subscribe in subscribes:
# 如果有一个订阅没有选择站点,则刷新所有订阅站点
if not subscribe.sites:
return []
# 刷新选中的站点 # 刷新选中的站点
sub_sites = json.loads(subscribe.sites) ret_sites.extend(self.get_sub_sites(subscribe))
if sub_sites:
ret_sites.extend(sub_sites)
# 去重 # 去重
if ret_sites: if ret_sites:
ret_sites = list(set(ret_sites)) ret_sites = list(set(ret_sites))
@@ -443,64 +480,20 @@ class SubscribeChain(ChainBase):
def get_filter_rule(self, subscribe: Subscribe): def get_filter_rule(self, subscribe: Subscribe):
""" """
获取订阅过滤规则,没有则返回默认规则 获取订阅过滤规则,同时组合默认规则
""" """
# 默认过滤规则 # 默认过滤规则
if (subscribe.include default_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
or subscribe.exclude return {
or subscribe.quality "include": subscribe.include or default_rule.get("include"),
or subscribe.resolution "exclude": subscribe.exclude or default_rule.get("exclude"),
or subscribe.effect): "quality": subscribe.quality or default_rule.get("quality"),
return { "resolution": subscribe.resolution or default_rule.get("resolution"),
"include": subscribe.include, "effect": subscribe.effect or default_rule.get("effect"),
"exclude": subscribe.exclude, "tv_size": default_rule.get("tv_size"),
"quality": subscribe.quality, "movie_size": default_rule.get("movie_size"),
"resolution": subscribe.resolution, "min_seeders": default_rule.get("min_seeders"),
"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]]): def match(self, torrents: Dict[str, List[Context]]):
""" """
@@ -523,7 +516,8 @@ class SubscribeChain(ChainBase):
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid, tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid) doubanid=subscribe.doubanid,
cache=False)
if not mediainfo: if not mediainfo:
logger.warn( logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}') f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -587,15 +581,32 @@ class SubscribeChain(ChainBase):
# 遍历缓存种子 # 遍历缓存种子
_match_context = [] _match_context = []
for domain, contexts in torrents.items(): for domain, contexts in torrents.items():
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
for context in contexts: for context in contexts:
# 检查是否匹配 # 检查是否匹配
torrent_meta = context.meta_info torrent_meta = context.meta_info
torrent_mediainfo = context.media_info torrent_mediainfo = context.media_info
torrent_info = context.torrent_info torrent_info = context.torrent_info
# 比对TMDBID和类型 # 如果识别了媒体信息,则比对TMDBID和类型
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \ if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
or torrent_mediainfo.type != mediainfo.type: # 直接比对媒体信息
continue if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
continue
if torrent_mediainfo.douban_id \
and torrent_mediainfo.douban_id != mediainfo.douban_id:
continue
logger.info(
f'{mediainfo.title_year} 通过媒体信ID匹配到资源{torrent_info.site_name} - {torrent_info.title}')
else:
# 没有torrent_mediainfo媒体信息按标题匹配
if not self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info,
logerror=False):
continue
# 优先级过滤规则 # 优先级过滤规则
if subscribe.best_version: if subscribe.best_version:
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules) priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
@@ -609,12 +620,13 @@ class SubscribeChain(ChainBase):
# 不符合过滤规则 # 不符合过滤规则
logger.info(f"{torrent_info.title} 不匹配当前过滤规则") logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
continue continue
# 不在订阅站点范围的不处理 # 不在订阅站点范围的不处理
if subscribe.sites: sub_sites = self.get_sub_sites(subscribe)
sub_sites = json.loads(subscribe.sites) if sub_sites and torrent_info.site not in sub_sites:
if sub_sites and torrent_info.site not in sub_sites: logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
logger.info(f"{torrent_info.title} 不符合 {torrent_mediainfo.title_year} 订阅站点要求") continue
continue
# 如果是电视剧 # 如果是电视剧
if torrent_mediainfo.type == MediaType.TV: if torrent_mediainfo.type == MediaType.TV:
# 有多季的不要 # 有多季的不要
@@ -658,8 +670,9 @@ class SubscribeChain(ChainBase):
continue continue
# 过滤规则 # 过滤规则
if not self.check_filter_rule(torrent_info=torrent_info, if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
filter_rule=filter_rule): filter_rule=filter_rule,
mediainfo=torrent_mediainfo):
continue continue
# 洗版时,优先级小于已下载优先级的不要 # 洗版时,优先级小于已下载优先级的不要
@@ -684,6 +697,7 @@ class SubscribeChain(ChainBase):
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源') logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context, downloads, lefts = self.downloadchain.batch_download(contexts=_match_context,
no_exists=no_exists, no_exists=no_exists,
userid=subscribe.username,
username=subscribe.username, username=subscribe.username,
save_path=subscribe.save_path) save_path=subscribe.save_path)
# 判断是否要完成订阅 # 判断是否要完成订阅
@@ -701,7 +715,7 @@ class SubscribeChain(ChainBase):
return return
# 遍历订阅 # 遍历订阅
for subscribe in subscribes: for subscribe in subscribes:
logger.info(f'开始检查订阅{subscribe.name} ...') logger.info(f'开始更新订阅元数据{subscribe.name} ...')
# 生成元数据 # 生成元数据
meta = MetaInfo(subscribe.name) meta = MetaInfo(subscribe.name)
meta.year = subscribe.year meta.year = subscribe.year
@@ -709,14 +723,16 @@ class SubscribeChain(ChainBase):
meta.type = MediaType(subscribe.type) meta.type = MediaType(subscribe.type)
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type, mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid) tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo: if not mediainfo:
logger.warn( logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}') f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue continue
# 对于电视剧,获取当前季的总集数 # 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or [] episodes = mediainfo.seasons.get(subscribe.season) or []
if len(episodes) > (subscribe.total_episode or 0): if not subscribe.manual_total_episode and len(episodes):
total_episode = len(episodes) total_episode = len(episodes)
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode) lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
logger.info( logger.info(
@@ -737,7 +753,7 @@ class SubscribeChain(ChainBase):
"total_episode": total_episode, "total_episode": total_episode,
"lack_episode": lack_episode "lack_episode": lack_episode
}) })
logger.info(f'订阅 {subscribe.name} 更新完成') logger.info(f'{subscribe.name} 订阅元数据更新完成')
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]): def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
""" """
@@ -832,20 +848,12 @@ class SubscribeChain(ChainBase):
messages = [] messages = []
for subscribe in subscribes: for subscribe in subscribes:
if subscribe.type == MediaType.MOVIE.value: if subscribe.type == MediaType.MOVIE.value:
if subscribe.tmdbid: messages.append(f"{subscribe.id}. {subscribe.name}{subscribe.year}")
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: else:
if subscribe.tmdbid: messages.append(f"{subscribe.id}. {subscribe.name}{subscribe.year}"
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.season}"
f"_{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}" f"[{subscribe.total_episode - (subscribe.lack_episode or subscribe.total_episode)}"
f"/{subscribe.total_episode}_") f"/{subscribe.total_episode}]")
# 发送列表 # 发送列表
self.post_message(Notification(channel=channel, self.post_message(Notification(channel=channel,
title=title, text='\n'.join(messages), userid=userid)) title=title, text='\n'.join(messages), userid=userid))
@@ -933,3 +941,62 @@ class SubscribeChain(ChainBase):
start_episode=start_episode start_episode=start_episode
) )
return no_exists return no_exists
@eventmanager.register(EventType.SiteDeleted)
def remove_site(self, event: Event):
"""
从订阅中移除与站点相关的设置
"""
if not event:
return
event_data = event.event_data or {}
site_id = event_data.get("site_id")
if not site_id:
return
if site_id == "*":
# 站点被重置
SystemConfigOper().set(SystemConfigKey.RssSites, [])
for subscribe in self.subscribeoper.list():
if not subscribe.sites:
continue
self.subscribeoper.update(subscribe.id, {
"sites": ""
})
return
# 从选中的rss站点中移除
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
if site_id in selected_sites:
selected_sites.remove(site_id)
SystemConfigOper().set(SystemConfigKey.RssSites, selected_sites)
# 查询所有订阅
for subscribe in self.subscribeoper.list():
if not subscribe.sites:
continue
sites = json.loads(subscribe.sites) or []
if site_id not in sites:
continue
sites.remove(site_id)
self.subscribeoper.update(subscribe.id, {
"sites": json.dumps(sites)
})
@staticmethod
def __get_default_subscribe_config(mtype: MediaType, default_config_key: str):
"""
获取默认订阅配置
"""
default_subscribe_key = None
if mtype == MediaType.TV:
default_subscribe_key = "DefaultTvSubscribeConfig"
if mtype == MediaType.MOVIE:
default_subscribe_key = "DefaultMovieSubscribeConfig"
# 默认订阅规则
if hasattr(settings, default_subscribe_key):
value = getattr(settings, default_subscribe_key)
else:
value = SystemConfigOper().get(default_subscribe_key)
if not value:
return None
return value.get(default_config_key) or None

View File

@@ -1,4 +1,5 @@
import re import re
import traceback
from typing import Dict, List, Union from typing import Dict, List, Union
from cachetools import cached, TTLCache from cachetools import cached, TTLCache
@@ -15,7 +16,7 @@ from app.helper.sites import SitesHelper
from app.helper.torrent import TorrentHelper from app.helper.torrent import TorrentHelper
from app.log import logger from app.log import logger
from app.schemas import Notification from app.schemas import Notification
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
from app.utils.string import StringUtils from app.utils.string import StringUtils
@@ -98,7 +99,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
if not site.get("rss"): if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址') logger.error(f'站点 {domain} 未配置RSS地址')
return [] return []
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False) rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
if rss_items is None: if rss_items is None:
# rss过期尝试保留原配置生成新的rss # rss过期尝试保留原配置生成新的rss
self.__renew_rss_url(domain=domain, site=site) self.__renew_rss_url(domain=domain, site=site)
@@ -183,10 +184,16 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
logger.info(f'处理资源:{torrent.title} ...') logger.info(f'处理资源:{torrent.title} ...')
# 识别 # 识别
meta = MetaInfo(title=torrent.title, subtitle=torrent.description) meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != meta.org_string:
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
# 使用站点种子分类,校正类型识别
if meta.type != MediaType.TV \
and torrent.category == MediaType.TV.value:
meta.type = MediaType.TV
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta) mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
if not mediainfo: if not mediainfo:
logger.warn(f'未识别到媒体信息,标题:{torrent.title}') logger.warn(f'{torrent.title} 未识别到媒体信息')
# 存储空的媒体信息 # 存储空的媒体信息
mediainfo = MediaInfo() mediainfo = MediaInfo()
# 清理多余数据 # 清理多余数据
@@ -246,5 +253,5 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
self.post_message( self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期")) Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
except Exception as e: 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链接已过期")) self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))

View File

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

View File

@@ -267,7 +267,10 @@ class Command(metaclass=Singleton):
停止事件处理线程 停止事件处理线程
""" """
self._event.set() self._event.set()
self._thread.join() try:
self._thread.join()
except Exception as e:
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
def get_commands(self): def get_commands(self):
""" """
@@ -315,8 +318,7 @@ class Command(metaclass=Singleton):
else: else:
logger.info(f"{command.get('description')} 执行完成") logger.info(f"{command.get('description')} 执行完成")
except Exception as err: except Exception as err:
logger.error(f"执行命令 {cmd} 出错:{str(err)}") logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
traceback.print_exc()
@staticmethod @staticmethod
def send_plugin_event(etype: EventType, data: dict) -> None: def send_plugin_event(etype: EventType, data: dict) -> None:

View File

@@ -3,7 +3,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
from pydantic import BaseSettings from pydantic import BaseSettings, validator
from app.utils.system import SystemUtils from app.utils.system import SystemUtils
@@ -32,17 +32,15 @@ class Settings(BaseSettings):
# 是否开发模式 # 是否开发模式
DEV: bool = False DEV: bool = False
# 配置文件目录 # 配置文件目录
CONFIG_DIR: str = None CONFIG_DIR: Optional[str] = None
# 超级管理员 # 超级管理员
SUPERUSER: str = "admin" SUPERUSER: str = "admin"
# 超级管理员初始密码
SUPERUSER_PASSWORD: str = "password"
# API密钥需要更换 # API密钥需要更换
API_TOKEN: str = "moviepilot" API_TOKEN: str = "moviepilot"
# 登录页面电影海报,tmdb/bing # 登录页面电影海报,tmdb/bing
WALLPAPER: str = "tmdb" WALLPAPER: str = "tmdb"
# 网络代理 IP:PORT # 网络代理 IP:PORT
PROXY_HOST: str = None PROXY_HOST: Optional[str] = None
# 媒体识别来源 themoviedb/douban # 媒体识别来源 themoviedb/douban
RECOGNIZE_SOURCE: str = "themoviedb" RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban # 刮削来源 themoviedb/douban
@@ -59,6 +57,8 @@ class Settings(BaseSettings):
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381" TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
# TVDB API Key # TVDB API Key
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02" TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
# Fanart开关
FANART_ENABLE: bool = True
# Fanart API Key # Fanart API Key
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f" FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
# 支持的后缀格式 # 支持的后缀格式
@@ -66,9 +66,11 @@ class Settings(BaseSettings):
'.rmvb', '.avi', '.mov', '.mpeg', '.rmvb', '.avi', '.mov', '.mpeg',
'.mpg', '.wmv', '.3gp', '.asf', '.mpg', '.wmv', '.3gp', '.asf',
'.m4v', '.flv', '.m2ts', '.strm', '.m4v', '.flv', '.m2ts', '.strm',
'.tp'] '.tp', '.f4v']
# 支持的字幕文件后缀格式 # 支持的字幕文件后缀格式
RMT_SUBEXT: list = ['.srt', '.ass', '.ssa'] RMT_SUBEXT: list = ['.srt', '.ass', '.ssa', '.sup']
# 下载器临时文件后缀
DOWNLOAD_TMPEXT: list = ['.!qB', '.part']
# 支持的音轨文件后缀格式 # 支持的音轨文件后缀格式
RMT_AUDIO_TRACK_EXT: list = ['.mka'] RMT_AUDIO_TRACK_EXT: list = ['.mka']
# 索引器 # 索引器
@@ -82,27 +84,27 @@ class Settings(BaseSettings):
# 用户认证站点 # 用户认证站点
AUTH_SITE: str = "" AUTH_SITE: str = ""
# 交互搜索自动下载用户ID使用,分割 # 交互搜索自动下载用户ID使用,分割
AUTO_DOWNLOAD_USER: str = None AUTO_DOWNLOAD_USER: Optional[str] = None
# 消息通知渠道 telegram/wechat/slack多个通知渠道用,分隔 # 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔
MESSAGER: str = "telegram" MESSAGER: str = "telegram"
# WeChat企业ID # WeChat企业ID
WECHAT_CORPID: str = None WECHAT_CORPID: Optional[str] = None
# WeChat应用Secret # WeChat应用Secret
WECHAT_APP_SECRET: str = None WECHAT_APP_SECRET: Optional[str] = None
# WeChat应用ID # WeChat应用ID
WECHAT_APP_ID: str = None WECHAT_APP_ID: Optional[str] = None
# WeChat代理服务器 # WeChat代理服务器
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com" WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
# WeChat Token # WeChat Token
WECHAT_TOKEN: str = None WECHAT_TOKEN: Optional[str] = None
# WeChat EncodingAESKey # WeChat EncodingAESKey
WECHAT_ENCODING_AESKEY: str = None WECHAT_ENCODING_AESKEY: Optional[str] = None
# WeChat 管理员 # WeChat 管理员
WECHAT_ADMINS: str = None WECHAT_ADMINS: Optional[str] = None
# Telegram Bot Token # Telegram Bot Token
TELEGRAM_TOKEN: str = None TELEGRAM_TOKEN: Optional[str] = None
# Telegram Chat ID # Telegram Chat ID
TELEGRAM_CHAT_ID: str = None TELEGRAM_CHAT_ID: Optional[str] = None
# Telegram 用户ID使用,分隔 # Telegram 用户ID使用,分隔
TELEGRAM_USERS: str = "" TELEGRAM_USERS: str = ""
# Telegram 管理员ID使用,分隔 # Telegram 管理员ID使用,分隔
@@ -117,16 +119,22 @@ class Settings(BaseSettings):
SYNOLOGYCHAT_WEBHOOK: str = "" SYNOLOGYCHAT_WEBHOOK: str = ""
# SynologyChat Token # SynologyChat Token
SYNOLOGYCHAT_TOKEN: str = "" SYNOLOGYCHAT_TOKEN: str = ""
# 下载器 qbittorrent/transmission # VoceChat地址
VOCECHAT_HOST: str = ""
# VoceChat ApiKey
VOCECHAT_API_KEY: str = ""
# VoceChat 频道ID
VOCECHAT_CHANNEL_ID: str = ""
# 下载器 qbittorrent/transmission启用多个下载器时使用,分隔,只有第一个会被默认使用
DOWNLOADER: str = "qbittorrent" DOWNLOADER: str = "qbittorrent"
# 下载器监控开关 # 下载器监控开关
DOWNLOADER_MONITOR: bool = True DOWNLOADER_MONITOR: bool = True
# Qbittorrent地址IP:PORT # Qbittorrent地址IP:PORT
QB_HOST: str = None QB_HOST: Optional[str] = None
# Qbittorrent用户名 # Qbittorrent用户名
QB_USER: str = None QB_USER: Optional[str] = None
# Qbittorrent密码 # Qbittorrent密码
QB_PASSWORD: str = None QB_PASSWORD: Optional[str] = None
# Qbittorrent分类自动管理 # Qbittorrent分类自动管理
QB_CATEGORY: bool = False QB_CATEGORY: bool = False
# Qbittorrent按顺序下载 # Qbittorrent按顺序下载
@@ -134,21 +142,21 @@ class Settings(BaseSettings):
# Qbittorrent忽略队列限制强制继续 # Qbittorrent忽略队列限制强制继续
QB_FORCE_RESUME: bool = False QB_FORCE_RESUME: bool = False
# Transmission地址IP:PORT # Transmission地址IP:PORT
TR_HOST: str = None TR_HOST: Optional[str] = None
# Transmission用户名 # Transmission用户名
TR_USER: str = None TR_USER: Optional[str] = None
# Transmission密码 # Transmission密码
TR_PASSWORD: str = None TR_PASSWORD: Optional[str] = None
# 种子标签 # 种子标签
TORRENT_TAG: str = "MOVIEPILOT" TORRENT_TAG: str = "MOVIEPILOT"
# 下载保存目录,容器内映射路径需要一致 # 下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: str = None DOWNLOAD_PATH: Optional[str] = None
# 电影下载保存目录,容器内映射路径需要一致 # 电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: str = None DOWNLOAD_MOVIE_PATH: Optional[str] = None
# 电视剧下载保存目录,容器内映射路径需要一致 # 电视剧下载保存目录,容器内映射路径需要一致
DOWNLOAD_TV_PATH: str = None DOWNLOAD_TV_PATH: Optional[str] = None
# 动漫下载保存目录,容器内映射路径需要一致 # 动漫下载保存目录,容器内映射路径需要一致
DOWNLOAD_ANIME_PATH: str = None DOWNLOAD_ANIME_PATH: Optional[str] = None
# 下载目录二级分类 # 下载目录二级分类
DOWNLOAD_CATEGORY: bool = False DOWNLOAD_CATEGORY: bool = False
# 下载站点字幕 # 下载站点字幕
@@ -158,27 +166,35 @@ class Settings(BaseSettings):
# 媒体服务器同步间隔(小时) # 媒体服务器同步间隔(小时)
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6 MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
# 媒体服务器同步黑名单,多个媒体库名称,分割 # 媒体服务器同步黑名单,多个媒体库名称,分割
MEDIASERVER_SYNC_BLACKLIST: str = None MEDIASERVER_SYNC_BLACKLIST: Optional[str] = None
# EMBY服务器地址IP:PORT # EMBY服务器地址IP:PORT
EMBY_HOST: str = None EMBY_HOST: Optional[str] = None
# EMBY外网地址http(s)://DOMAIN:PORT未设置时使用EMBY_HOST
EMBY_PLAY_HOST: Optional[str] = None
# EMBY Api Key # EMBY Api Key
EMBY_API_KEY: str = None EMBY_API_KEY: Optional[str] = None
# Jellyfin服务器地址IP:PORT # Jellyfin服务器地址IP:PORT
JELLYFIN_HOST: str = None JELLYFIN_HOST: Optional[str] = None
# Jellyfin外网地址http(s)://DOMAIN:PORT未设置时使用JELLYFIN_HOST
JELLYFIN_PLAY_HOST: Optional[str] = None
# Jellyfin Api Key # Jellyfin Api Key
JELLYFIN_API_KEY: str = None JELLYFIN_API_KEY: Optional[str] = None
# Plex服务器地址IP:PORT # Plex服务器地址IP:PORT
PLEX_HOST: str = None PLEX_HOST: Optional[str] = None
# Plex外网地址http(s)://DOMAIN:PORT未设置时使用PLEX_HOST
PLEX_PLAY_HOST: Optional[str] = None
# Plex Token # Plex Token
PLEX_TOKEN: str = None PLEX_TOKEN: Optional[str] = None
# 转移方式 link/copy/move/softlink # 转移方式 link/copy/move/softlink
TRANSFER_TYPE: str = "copy" TRANSFER_TYPE: str = "copy"
# CookieCloud是否启动本地服务
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
# CookieCloud服务器地址 # CookieCloud服务器地址
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud" COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
# CookieCloud用户KEY # CookieCloud用户KEY
COOKIECLOUD_KEY: str = None COOKIECLOUD_KEY: Optional[str] = None
# CookieCloud端对端加密密码 # CookieCloud端对端加密密码
COOKIECLOUD_PASSWORD: str = None COOKIECLOUD_PASSWORD: Optional[str] = None
# CookieCloud同步间隔分钟 # CookieCloud同步间隔分钟
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24 COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
# OCR服务器地址 # OCR服务器地址
@@ -186,13 +202,13 @@ class Settings(BaseSettings):
# CookieCloud对应的浏览器UA # 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" USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
# 媒体库目录,多个目录使用,分隔 # 媒体库目录,多个目录使用,分隔
LIBRARY_PATH: str = None LIBRARY_PATH: Optional[str] = None
# 电影媒体库目录名 # 电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影" LIBRARY_MOVIE_NAME: str = "电影"
# 电视剧媒体库目录名 # 电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧" LIBRARY_TV_NAME: str = "电视剧"
# 动漫媒体库目录名,不设置时使用电视剧目录 # 动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: str = None LIBRARY_ANIME_NAME: Optional[str] = None
# 二级分类 # 二级分类
LIBRARY_CATEGORY: bool = True LIBRARY_CATEGORY: bool = True
# 电视剧动漫的分类genre_ids # 电视剧动漫的分类genre_ids
@@ -213,9 +229,28 @@ class Settings(BaseSettings):
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾 # 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins" PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
# Github token提高请求api限流阈值 ghp_**** # Github token提高请求api限流阈值 ghp_****
GITHUB_TOKEN: str = None GITHUB_TOKEN: Optional[str] = None
# 自动检查和更新站点资源包(站点索引、认证等) # 自动检查和更新站点资源包(站点索引、认证等)
AUTO_UPDATE_RESOURCE: bool = True AUTO_UPDATE_RESOURCE: bool = True
# 元数据识别缓存过期时间(小时)
META_CACHE_EXPIRE: int = 0
# 是否启用DOH解析域名
DOH_ENABLE: bool = True
# 搜索多个名称
SEARCH_MULTIPLE_NAME: bool = False
@validator("SUBSCRIBE_RSS_INTERVAL",
"COOKIECLOUD_INTERVAL",
"MEDIASERVER_SYNC_INTERVAL",
"META_CACHE_EXPIRE",
pre=True, always=True)
def convert_int(cls, value):
if not value:
return 0
try:
return int(value)
except (ValueError, TypeError):
raise ValueError(f"{value} 格式错误,不是有效数字!")
@property @property
def INNER_CONFIG_PATH(self): def INNER_CONFIG_PATH(self):
@@ -246,6 +281,10 @@ class Settings(BaseSettings):
@property @property
def LOG_PATH(self): def LOG_PATH(self):
return self.CONFIG_PATH / "logs" return self.CONFIG_PATH / "logs"
@property
def COOKIE_PATH(self):
return self.CONFIG_PATH / "cookies"
@property @property
def CACHE_CONF(self): def CACHE_CONF(self):
@@ -256,7 +295,7 @@ class Settings(BaseSettings):
"torrents": 100, "torrents": 100,
"douban": 512, "douban": 512,
"fanart": 512, "fanart": 512,
"meta": 15 * 24 * 3600 "meta": (self.META_CACHE_EXPIRE or 168) * 3600
} }
return { return {
"tmdb": 256, "tmdb": 256,
@@ -264,7 +303,7 @@ class Settings(BaseSettings):
"torrents": 50, "torrents": 50,
"douban": 256, "douban": 256,
"fanart": 128, "fanart": 128,
"meta": 7 * 24 * 3600 "meta": (self.META_CACHE_EXPIRE or 72) * 3600
} }
@property @property
@@ -336,6 +375,24 @@ class Settings(BaseSettings):
} }
return {} return {}
@property
def DEFAULT_DOWNLOADER(self):
"""
默认下载器
"""
if not self.DOWNLOADER:
return None
return self.DOWNLOADER.split(",")[0]
@property
def DOWNLOADERS(self):
"""
下载器列表
"""
if not self.DOWNLOADER:
return []
return self.DOWNLOADER.split(",")
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
with self.CONFIG_PATH as p: with self.CONFIG_PATH as p:
@@ -350,6 +407,9 @@ class Settings(BaseSettings):
with self.LOG_PATH as p: with self.LOG_PATH as p:
if not p.exists(): if not p.exists():
p.mkdir(parents=True, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
with self.COOKIE_PATH as p:
if not p.exists():
p.mkdir(parents=True, exist_ok=True)
class Config: class Config:
case_sensitive = True case_sensitive = True

View File

@@ -57,6 +57,8 @@ class TorrentInfo:
labels: list = field(default_factory=list) labels: list = field(default_factory=list)
# 种子优先级 # 种子优先级
pri_order: int = 0 pri_order: int = 0
# 种子分类 电影/电视剧
category: str = None
def __setattr__(self, name: str, value: Any): def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value self.__dict__[name] = value
@@ -135,6 +137,10 @@ class MediaInfo:
type: MediaType = None type: MediaType = None
# 媒体标题 # 媒体标题
title: str = None title: str = None
# 英文标题
en_title: str = None
# 新加坡标题
sg_title: str = None
# 年份 # 年份
year: str = None year: str = None
# 季 # 季
@@ -147,6 +153,8 @@ class MediaInfo:
tvdb_id: int = None tvdb_id: int = None
# 豆瓣ID # 豆瓣ID
douban_id: str = None douban_id: str = None
# Bangumi ID
bangumi_id: int = None
# 媒体原语种 # 媒体原语种
original_language: str = None original_language: str = None
# 媒体原发行标题 # 媒体原发行标题
@@ -160,7 +168,7 @@ class MediaInfo:
# LOGO # LOGO
logo_path: str = None logo_path: str = None
# 评分 # 评分
vote_average: int = 0 vote_average: float = 0
# 描述 # 描述
overview: str = None overview: str = None
# 风格ID # 风格ID
@@ -179,6 +187,8 @@ class MediaInfo:
tmdb_info: dict = field(default_factory=dict) tmdb_info: dict = field(default_factory=dict)
# 豆瓣 INFO # 豆瓣 INFO
douban_info: dict = field(default_factory=dict) douban_info: dict = field(default_factory=dict)
# Bangumi INFO
bangumi_info: dict = field(default_factory=dict)
# 导演 # 导演
directors: List[dict] = field(default_factory=list) directors: List[dict] = field(default_factory=list)
# 演员 # 演员
@@ -234,6 +244,8 @@ class MediaInfo:
self.set_tmdb_info(self.tmdb_info) self.set_tmdb_info(self.tmdb_info)
if self.douban_info: if self.douban_info:
self.set_douban_info(self.douban_info) self.set_douban_info(self.douban_info)
if self.bangumi_info:
self.set_bangumi_info(self.bangumi_info)
def __setattr__(self, name: str, value: Any): def __setattr__(self, name: str, value: Any):
self.__dict__[name] = value self.__dict__[name] = value
@@ -368,6 +380,10 @@ class MediaInfo:
self.genre_ids = info.get('genre_ids') or [] self.genre_ids = info.get('genre_ids') or []
# 原语种 # 原语种
self.original_language = info.get('original_language') self.original_language = info.get('original_language')
# 英文标题
self.en_title = info.get('en_title')
# 新加坡标题
self.sg_title = info.get('sg_title')
if self.type == MediaType.MOVIE: if self.type == MediaType.MOVIE:
# 标题 # 标题
self.title = info.get('title') self.title = info.get('title')
@@ -439,6 +455,9 @@ class MediaInfo:
# 标题 # 标题
if not self.title: if not self.title:
self.title = info.get("title") self.title = info.get("title")
# 英文标题,暂时不支持
if not self.en_title:
self.en_title = info.get('original_title')
# 原语种标题 # 原语种标题
if not self.original_title: if not self.original_title:
self.original_title = info.get("original_title") self.original_title = info.get("original_title")
@@ -474,7 +493,8 @@ class MediaInfo:
self.release_date = match.group() self.release_date = match.group()
# 海报 # 海报
if not self.poster_path: if not self.poster_path:
self.poster_path = info.get("pic", {}).get("large") if info.get("pic"):
self.poster_path = info.get("pic", {}).get("large")
if not self.poster_path and info.get("cover_url"): if not self.poster_path and info.get("cover_url"):
self.poster_path = info.get("cover_url") self.poster_path = info.get("cover_url")
if not self.poster_path and info.get("cover"): if not self.poster_path and info.get("cover"):
@@ -527,6 +547,70 @@ class MediaInfo:
if not hasattr(self, key): if not hasattr(self, key):
setattr(self, key, value) setattr(self, key, value)
def set_bangumi_info(self, info: dict):
"""
初始化Bangumi信息
"""
if not info:
return
# 本体
self.bangumi_info = info
# 豆瓣ID
self.bangumi_id = info.get("id")
# 类型
if not self.type:
self.type = MediaType.TV
# 标题
if not self.title:
self.title = info.get("name_cn") or info.get("name")
# 原语种标题
if not self.original_title:
self.original_title = info.get("name")
# 识别标题中的季
meta = MetaInfo(self.title)
# 季
if not self.season:
self.season = meta.begin_season
# 评分
if not self.vote_average:
rating = info.get("rating")
if rating:
vote_average = float(rating.get("score"))
else:
vote_average = 0
self.vote_average = vote_average
# 发行日期
if not self.release_date:
self.release_date = info.get("date") or info.get("air_date")
# 年份
if not self.year:
self.year = self.release_date[:4] if self.release_date else None
# 海报
if not self.poster_path:
if info.get("images"):
self.poster_path = info.get("images", {}).get("large")
# 简介
if not self.overview:
self.overview = info.get("summary")
# 别名
if not self.names:
infobox = info.get("infobox")
if infobox:
akas = [item.get("value") for item in infobox if item.get("key") == "别名"]
if akas:
self.names = [aka.get("v") for aka in akas[0]]
# 剧集
if self.type == MediaType.TV and not self.seasons:
meta = MetaInfo(self.title)
season = meta.begin_season or 1
episodes_count = info.get("total_episodes")
if episodes_count:
self.seasons[season] = list(range(1, episodes_count + 1))
# 演员
if not self.actors:
self.actors = info.get("actors") or []
@property @property
def title_year(self): def title_year(self):
if self.title: if self.title:
@@ -545,6 +629,8 @@ class MediaInfo:
return "https://www.themoviedb.org/tv/%s" % self.tmdb_id return "https://www.themoviedb.org/tv/%s" % self.tmdb_id
elif self.douban_id: elif self.douban_id:
return "https://movie.douban.com/subject/%s" % self.douban_id return "https://movie.douban.com/subject/%s" % self.douban_id
elif self.bangumi_id:
return "http://bgm.tv/subject/%s" % self.bangumi_id
return "" return ""
@property @property
@@ -606,6 +692,9 @@ class MediaInfo:
dicts["type"] = self.type.value if self.type else None dicts["type"] = self.type.value if self.type else None
dicts["detail_link"] = self.detail_link dicts["detail_link"] = self.detail_link
dicts["title_year"] = self.title_year dicts["title_year"] = self.title_year
dicts["tmdb_info"] = None
dicts["douban_info"] = None
dicts["bangumi_info"] = None
return dicts return dicts
def clear(self): def clear(self):
@@ -614,6 +703,7 @@ class MediaInfo:
""" """
self.tmdb_info = {} self.tmdb_info = {}
self.douban_info = {} self.douban_info = {}
self.bangumi_info = {}
self.seasons = {} self.seasons = {}
self.genres = [] self.genres = []
self.season_info = [] self.season_info = []

View File

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

View File

@@ -1,9 +1,12 @@
import re import re
import traceback
import zhconv import zhconv
import anitopy import anitopy
from app.core.meta.customization import CustomizationMatcher from app.core.meta.customization import CustomizationMatcher
from app.core.meta.metabase import MetaBase from app.core.meta.metabase import MetaBase
from app.core.meta.releasegroup import ReleaseGroupsMatcher from app.core.meta.releasegroup import ReleaseGroupsMatcher
from app.log import logger
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -13,7 +16,7 @@ class MetaAnime(MetaBase):
识别动漫 识别动漫
""" """
_anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL'] _anime_no_words = ['CHS&CHT', 'MP4', 'GB MP4', 'WEB-DL']
_name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" _name_nostring_re = r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}|\s+GB"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False): def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
super().__init__(title, subtitle, isfile) super().__init__(title, subtitle, isfile)
@@ -29,8 +32,6 @@ class MetaAnime(MetaBase):
if anitopy_info: if anitopy_info:
# 名称 # 名称
name = anitopy_info.get("anime_title") name = anitopy_info.get("anime_title")
if name and name.find("/") != -1:
name = name.split("/")[-1].strip()
if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)): if not name or name in self._anime_no_words or (len(name) < 5 and not StringUtils.is_chinese(name)):
anitopy_info = anitopy.parse("[ANIME]" + title) anitopy_info = anitopy.parse("[ANIME]" + title)
if anitopy_info: if anitopy_info:
@@ -41,23 +42,41 @@ class MetaAnime(MetaBase):
name = name_match.group(1).strip() name = name_match.group(1).strip()
# 拆份中英文名称 # 拆份中英文名称
if name: if name:
lastword_type = "" _split_flag = True
for word in name.split(): # 按/拆分中英文
if not word: if name.find("/") != -1:
continue names = name.split("/")
if word.endswith(']'): if StringUtils.is_chinese(names[0]):
word = word[:-1] self.cn_name = names[0]
if word.isdigit(): if len(names) > 1:
if lastword_type == "cn": self.en_name = names[1]
self.cn_name = "%s %s" % (self.cn_name or "", word) _split_flag = False
elif lastword_type == "en": elif StringUtils.is_chinese(names[-1]):
self.en_name = "%s %s" % (self.en_name or "", word) self.cn_name = names[-1]
elif StringUtils.is_chinese(word): if len(names) > 1:
self.cn_name = "%s %s" % (self.cn_name or "", word) self.en_name = names[0]
lastword_type = "cn" _split_flag = False
else: else:
self.en_name = "%s %s" % (self.en_name or "", word) name = names[-1]
lastword_type = "en" # 拆分中英文
if _split_flag:
lastword_type = ""
for word in name.split():
if not word:
continue
if word.endswith(']'):
word = word[:-1]
if word.isdigit():
if lastword_type == "cn":
self.cn_name = "%s %s" % (self.cn_name or "", word)
elif lastword_type == "en":
self.en_name = "%s %s" % (self.en_name or "", word)
elif StringUtils.is_chinese(word):
self.cn_name = "%s %s" % (self.cn_name or "", word)
lastword_type = "cn"
else:
self.en_name = "%s %s" % (self.en_name or "", word)
lastword_type = "en"
if self.cn_name: if self.cn_name:
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name) _, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
if self.cn_name: if self.cn_name:
@@ -117,7 +136,7 @@ class MetaAnime(MetaBase):
else: else:
self.total_episode = 1 self.total_episode = 1
except Exception as err: except Exception as err:
print(str(err)) logger.debug(f"解析集数失败:{str(err)} - {traceback.format_exc()}")
self.begin_episode = None self.begin_episode = None
self.end_episode = None self.end_episode = None
self.type = MediaType.TV self.type = MediaType.TV
@@ -162,7 +181,7 @@ class MetaAnime(MetaBase):
if not self.type: if not self.type:
self.type = MediaType.TV self.type = MediaType.TV
except Exception as e: except Exception as e:
print(str(e)) logger.error(f"解析动漫信息失败:{str(e)} - {traceback.format_exc()}")
@staticmethod @staticmethod
def __prepare_title(title: str): def __prepare_title(title: str):

View File

@@ -1,9 +1,11 @@
import traceback
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Union, Optional, List, Self from typing import Union, Optional, List, Self
import cn2an import cn2an
import regex as re import regex as re
from app.log import logger
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.schemas.types import MediaType from app.schemas.types import MediaType
@@ -65,16 +67,18 @@ class MetaBase(object):
# 副标题解析 # 副标题解析
_subtitle_flag = False _subtitle_flag = False
_title_episodel_re = r"Episode\s+(\d{1,4})"
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])" _subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全" _subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP\-]+)\s*[集话話期](?!\s*[全共])" _subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期](?!\s*[全共])"
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期]" _subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期]"
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False): def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
if not title: if not title:
return return
self.org_string = title self.org_string = title.strip() if title else None
self.subtitle = subtitle self.subtitle = subtitle.strip() if subtitle else None
self.isfile = isfile self.isfile = isfile
@property @property
@@ -108,7 +112,39 @@ class MetaBase(object):
if not title_text: if not title_text:
return return
title_text = f" {title_text} " title_text = f" {title_text} "
if re.search(r'[全第季集话話期]', title_text, re.IGNORECASE): if re.search(r"%s" % self._title_episodel_re, title_text, re.IGNORECASE):
episode_str = re.search(r'%s' % self._title_episodel_re, title_text, re.IGNORECASE)
if episode_str:
try:
episode = int(episode_str.group(1))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if episode >= 10000:
return
if self.begin_episode is None:
self.begin_episode = episode
self.total_episode = 1
self.type = MediaType.TV
self._subtitle_flag = True
elif re.search(r'[全第季集话話期幕]', title_text, re.IGNORECASE):
# 全x季 x季全
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
if season_all_str:
season_all = season_all_str.group(1)
if not season_all:
season_all = season_all_str.group(2)
if season_all and self.begin_season is None and self.begin_episode is None:
try:
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
except Exception as err:
logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
return
self.begin_season = 1
self.end_season = self.total_season
self.type = MediaType.TV
self._subtitle_flag = True
return
# 第x季 # 第x季
season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE) season_str = re.search(r'%s' % self._subtitle_season_re, title_text, re.IGNORECASE)
if season_str: if season_str:
@@ -127,7 +163,11 @@ class MetaBase(object):
else: else:
begin_season = int(cn2an.cn2an(seasons, mode='smart')) begin_season = int(cn2an.cn2an(seasons, mode='smart'))
except Exception as err: except Exception as err:
print(str(err)) logger.debug(f'识别季失败:{str(err)} - {traceback.format_exc()}')
return
if begin_season and begin_season > 100:
return
if end_season and end_season > 100:
return return
if self.begin_season is None and isinstance(begin_season, int): if self.begin_season is None and isinstance(begin_season, int):
self.begin_season = begin_season self.begin_season = begin_season
@@ -140,6 +180,37 @@ class MetaBase(object):
self.total_season = (self.end_season - self.begin_season) + 1 self.total_season = (self.end_season - self.begin_season) + 1
self.type = MediaType.TV self.type = MediaType.TV
self._subtitle_flag = True self._subtitle_flag = True
# 第x-x集 第x集-x集
episode_between_str = re.search(r'%s' % self._subtitle_episode_between_re, title_text, re.IGNORECASE)
if episode_between_str:
episodes = episode_between_str.groups()
if episodes:
begin_episode = episodes[0]
end_episode = episodes[1]
else:
return
try:
begin_episode = int(cn2an.cn2an(begin_episode.strip(), mode='smart'))
end_episode = int(cn2an.cn2an(end_episode.strip(), mode='smart'))
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode and begin_episode >= 10000:
return
if end_episode and end_episode >= 10000:
return
if self.begin_episode is None and isinstance(begin_episode, int):
self.begin_episode = begin_episode
self.total_episode = 1
if self.begin_episode is not None \
and self.end_episode is None \
and isinstance(end_episode, int) \
and end_episode != self.begin_episode:
self.end_episode = end_episode
self.total_episode = (self.end_episode - self.begin_episode) + 1
self.type = MediaType.TV
self._subtitle_flag = True
return
# 第x集 # 第x集
episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE) episode_str = re.search(r'%s' % self._subtitle_episode_re, title_text, re.IGNORECASE)
if episode_str: if episode_str:
@@ -158,7 +229,11 @@ class MetaBase(object):
else: else:
begin_episode = int(cn2an.cn2an(episodes, mode='smart')) begin_episode = int(cn2an.cn2an(episodes, mode='smart'))
except Exception as err: except Exception as err:
print(str(err)) logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
if begin_episode and begin_episode >= 10000:
return
if end_episode and end_episode >= 10000:
return return
if self.begin_episode is None and isinstance(begin_episode, int): if self.begin_episode is None and isinstance(begin_episode, int):
self.begin_episode = begin_episode self.begin_episode = begin_episode
@@ -171,6 +246,7 @@ class MetaBase(object):
self.total_episode = (self.end_episode - self.begin_episode) + 1 self.total_episode = (self.end_episode - self.begin_episode) + 1
self.type = MediaType.TV self.type = MediaType.TV
self._subtitle_flag = True self._subtitle_flag = True
return
# x集全 # x集全
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE) episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
if episode_all_str: if episode_all_str:
@@ -181,28 +257,13 @@ class MetaBase(object):
try: try:
self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart')) self.total_episode = int(cn2an.cn2an(episode_all.strip(), mode='smart'))
except Exception as err: except Exception as err:
print(str(err)) logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return return
self.begin_episode = None self.begin_episode = None
self.end_episode = None self.end_episode = None
self.type = MediaType.TV self.type = MediaType.TV
self._subtitle_flag = True self._subtitle_flag = True
# 全x季 x季全 return
season_all_str = re.search(r"%s" % self._subtitle_season_all_re, title_text, re.IGNORECASE)
if season_all_str:
season_all = season_all_str.group(1)
if not season_all:
season_all = season_all_str.group(2)
if season_all and self.begin_season is None and self.begin_episode is None:
try:
self.total_season = int(cn2an.cn2an(season_all.strip(), mode='smart'))
except Exception as err:
print(str(err))
return
self.begin_season = 1
self.end_season = self.total_season
self.type = MediaType.TV
self._subtitle_flag = True
@property @property
def season(self) -> str: def season(self) -> str:

View File

@@ -1,13 +1,16 @@
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional
from Pinyin2Hanzi import is_pinyin
from app.core.config import settings from app.core.config import settings
from app.core.meta.customization import CustomizationMatcher from app.core.meta.customization import CustomizationMatcher
from app.core.meta.metabase import MetaBase from app.core.meta.metabase import MetaBase
from app.core.meta.releasegroup import ReleaseGroupsMatcher from app.core.meta.releasegroup import ReleaseGroupsMatcher
from app.schemas.types import MediaType
from app.utils.string import StringUtils from app.utils.string import StringUtils
from app.utils.tokens import Tokens from app.utils.tokens import Tokens
from app.schemas.types import MediaType
class MetaVideo(MetaBase): class MetaVideo(MetaBase):
@@ -31,7 +34,7 @@ class MetaVideo(MetaBase):
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$" _source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$"
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$" _effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re) _resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^\[.+?]" _name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕" _name_no_chinese_re = r".*版|.*字幕"
_name_se_words = ['', '', '', '', '', '', ''] _name_se_words = ['', '', '', '', '', '', '']
_name_movie_words = ['剧场版', '劇場版', '电影版', '電影版'] _name_movie_words = ['剧场版', '劇場版', '电影版', '電影版']
@@ -39,19 +42,25 @@ class MetaVideo(MetaBase):
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"|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]+季" \ r"|[第\s共]+[0-9一二三四五六七八九十\-\s]+季" \
r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \ r"|[第\s共]+[0-9一二三四五六七八九十百零\-\s]+[集话話]" \
r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \ r"|连载|日剧|美剧|电视剧|动画片|动漫|欧美|西德|日韩|超高清|高清|无水印|下载|蓝光|翡翠台|梦幻天堂·龙网|★?\d*月?新番" \
r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组" \ r"|最终季|合集|[多中国英葡法俄日韩德意西印泰台港粤双文语简繁体特效内封官译外挂]+字幕|版本|出品|台版|港版|\w+字幕组|\w+字幕社" \
r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \ r"|未删减版|UNCUT$|UNRATE$|WITH EXTRAS$|RERIP$|SUBBED$|PROPER$|REPACK$|SEASON$|EPISODE$|Complete$|Extended$|Extended Version$" \
r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \ r"|S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}" \
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \ r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" \
r"|[248]K|\d{3,4}[PIX]+" \ r"|[248]K|\d{3,4}[PIX]+" \
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]" r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})" _resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
_resources_pix_re2 = r"(^[248]+K)" _resources_pix_re2 = r"(^[248]+K)"
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$" _video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$" _audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False): def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""
初始化
:param title: 标题
:param subtitle: 副标题
:param isfile: 是否是文件名
"""
super().__init__(title, subtitle, isfile) super().__init__(title, subtitle, isfile)
if not title: if not title:
return return
@@ -130,12 +139,47 @@ class MetaVideo(MetaBase):
# 处理part # 处理part
if self.part and self.part.upper() == "PART": if self.part and self.part.upper() == "PART":
self.part = None self.part = None
# 没有中文标题时,偿试中描述中获取中文名
if not self.cn_name and self.en_name and self.subtitle:
if self.__is_pinyin(self.en_name):
# 英文名是拼音
cn_name = self.__get_title_from_description(self.subtitle)
if cn_name and len(cn_name) == len(self.en_name.split()):
# 中文名和拼音单词数相同,认为是中文名
self.cn_name = cn_name
# 制作组/字幕组 # 制作组/字幕组
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
# 自定义占位符 # 自定义占位符
self.customization = CustomizationMatcher().match(title=original_title) or None self.customization = CustomizationMatcher().match(title=original_title) or None
@staticmethod
def __get_title_from_description(description: str) -> Optional[str]:
"""
从描述中提取标题
"""
if not description:
return None
titles = re.split(r'[\s/|]+', description)
if StringUtils.is_chinese(titles[0]):
return titles[0]
return None
@staticmethod
def __is_pinyin(name_str: str) -> bool:
"""
判断是否拼音
"""
if not name_str:
return False
for n in name_str.lower().split():
if not is_pinyin(n):
return False
return True
def __fix_name(self, name: str): def __fix_name(self, name: str):
"""
去掉名字中不需要的干扰字符
"""
if not name: if not name:
return name return name
name = re.sub(r'%s' % self._name_nostring_re, '', name, name = re.sub(r'%s' % self._name_nostring_re, '', name,
@@ -157,6 +201,9 @@ class MetaVideo(MetaBase):
return name return name
def __init_name(self, token: str): def __init_name(self, token: str):
"""
识别名称
"""
if not token: if not token:
return return
# 回收标题 # 回收标题
@@ -250,6 +297,9 @@ class MetaVideo(MetaBase):
self._last_token_type = "enname" self._last_token_type = "enname"
def __init_part(self, token: str): def __init_part(self, token: str):
"""
识别Part
"""
if not self.name: if not self.name:
return return
if not self.year \ if not self.year \
@@ -270,9 +320,12 @@ class MetaVideo(MetaBase):
self.tokens.get_next() self.tokens.get_next()
self._last_token_type = "part" self._last_token_type = "part"
self._continue_flag = False self._continue_flag = False
self._stop_name_flag = False # self._stop_name_flag = False
def __init_year(self, token: str): def __init_year(self, token: str):
"""
识别年份
"""
if not self.name: if not self.name:
return return
if not token.isdigit(): if not token.isdigit():
@@ -295,6 +348,9 @@ class MetaVideo(MetaBase):
self._stop_name_flag = True self._stop_name_flag = True
def __init_resource_pix(self, token: str): def __init_resource_pix(self, token: str):
"""
识别分辨率
"""
if not self.name: if not self.name:
return return
re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE) re_res = re.findall(r"%s" % self._resources_pix_re, token, re.IGNORECASE)
@@ -331,6 +387,9 @@ class MetaVideo(MetaBase):
self.resource_pix = re_res.group(1).lower() self.resource_pix = re_res.group(1).lower()
def __init_season(self, token: str): def __init_season(self, token: str):
"""
识别季
"""
re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE) re_res = re.findall(r"%s" % self._season_re, token, re.IGNORECASE)
if re_res: if re_res:
self._last_token_type = "season" self._last_token_type = "season"
@@ -380,6 +439,9 @@ class MetaVideo(MetaBase):
self.begin_season = 1 self.begin_season = 1
def __init_episode(self, token: str): def __init_episode(self, token: str):
"""
识别集
"""
re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE) re_res = re.findall(r"%s" % self._episode_re, token, re.IGNORECASE)
if re_res: if re_res:
self._last_token_type = "episode" self._last_token_type = "episode"
@@ -450,6 +512,9 @@ class MetaVideo(MetaBase):
self._last_token_type = "EPISODE" self._last_token_type = "EPISODE"
def __init_resource_type(self, token): def __init_resource_type(self, token):
"""
识别资源类型
"""
if not self.name: if not self.name:
return return
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE) source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
@@ -488,6 +553,9 @@ class MetaVideo(MetaBase):
self._last_token = effect.upper() self._last_token = effect.upper()
def __init_video_encode(self, token: str): def __init_video_encode(self, token: str):
"""
识别视频编码
"""
if not self.name: if not self.name:
return return
if not self.year \ if not self.year \
@@ -528,6 +596,9 @@ class MetaVideo(MetaBase):
self.video_encode = f"{self.video_encode} 10bit" self.video_encode = f"{self.video_encode} 10bit"
def __init_audio_encode(self, token: str): def __init_audio_encode(self, token: str):
"""
识别音频编码
"""
if not self.name: if not self.name:
return return
if not self.year \ if not self.year \

View File

@@ -4,6 +4,7 @@ import cn2an
import regex as re import regex as re
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey from app.schemas.types import SystemConfigKey
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
@@ -24,7 +25,7 @@ class WordsMatcher(metaclass=Singleton):
# 读取自定义识别词 # 读取自定义识别词
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or [] words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
for word in words: for word in words:
if not word: if not word or word.startswith("#"):
continue continue
try: try:
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "): if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
@@ -52,17 +53,18 @@ class WordsMatcher(metaclass=Singleton):
strings = word.split(" <> ") strings = word.split(" <> ")
offsets = strings[1].split(" >> ") offsets = strings[1].split(" >> ")
strings[1] = offsets[0] strings[1] = offsets[0]
title, message, state = self.__episode_offset(title, strings[0], strings[1], title, message, state = self.__episode_offset(title, strings[0], strings[1], offsets[1])
offsets[1])
else: else:
# 屏蔽词 # 屏蔽词
if not word.strip():
continue
title, message, state = self.__replace_regex(title, word, "") title, message, state = self.__replace_regex(title, word, "")
if state: if state:
appley_words.append(word) appley_words.append(word)
except Exception as err: except Exception as err:
print(str(err)) logger.warn(f"自定义识别词 {word} 预处理标题失败:{str(err)} - 标题:{title}")
return title, appley_words return title, appley_words
@@ -77,7 +79,7 @@ class WordsMatcher(metaclass=Singleton):
else: else:
return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True return re.sub(r'%s' % replaced, r'%s' % replace, title), "", True
except Exception as err: except Exception as err:
print(str(err)) logger.warn(f"自定义识别词正则替换失败:{str(err)} - 标题:{title},被替换词:{replaced},替换词:{replace}")
return title, str(err), False return title, str(err), False
@staticmethod @staticmethod
@@ -129,5 +131,5 @@ class WordsMatcher(metaclass=Singleton):
title = re.sub(episode_offset_re, r'%s' % episode_num[1], title) title = re.sub(episode_offset_re, r'%s' % episode_num[1], title)
return title, "", True return title, "", True
except Exception as err: except Exception as err:
print(str(err)) logger.warn(f"自定义识别词集数偏移失败:{str(err)} - 标题:{title},前定位词:{front},后定位词:{back},偏移量:{offset}")
return title, str(err), False return title, str(err), False

View File

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

View File

@@ -1,6 +1,9 @@
import concurrent
import concurrent.futures
import traceback import traceback
from typing import List, Any, Dict, Tuple from typing import List, Any, Dict, Tuple, Optional
from app import schemas
from app.core.config import settings from app.core.config import settings
from app.core.event import eventmanager from app.core.event import eventmanager
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
@@ -41,9 +44,10 @@ class PluginManager(metaclass=Singleton):
# 启动插件 # 启动插件
self.start() self.start()
def start(self): def start(self, pid: str = None):
""" """
启动加载插件 启动加载插件
:param pid: 插件ID为空加载所有插件
""" """
# 扫描插件目录 # 扫描插件目录
plugins = ModuleHelper.load( plugins = ModuleHelper.load(
@@ -54,10 +58,10 @@ class PluginManager(metaclass=Singleton):
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or [] installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 排序 # 排序
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0) plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
self._running_plugins = {}
self._plugins = {}
for plugin in plugins: for plugin in plugins:
plugin_id = plugin.__name__ plugin_id = plugin.__name__
if pid and plugin_id != pid:
continue
try: try:
# 存储Class # 存储Class
self._plugins[plugin_id] = plugin self._plugins[plugin_id] = plugin
@@ -72,35 +76,82 @@ class PluginManager(metaclass=Singleton):
plugin_obj.init_plugin(self.get_plugin_config(plugin_id)) plugin_obj.init_plugin(self.get_plugin_config(plugin_id))
# 存储运行实例 # 存储运行实例
self._running_plugins[plugin_id] = plugin_obj self._running_plugins[plugin_id] = plugin_obj
logger.info(f"Plugin Loaded{plugin_id}") logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
# 设置事件注册状态可用 # 启用的插件才设置事件注册状态可用
eventmanager.enable_events_hander(plugin_id) if plugin_obj.get_state():
eventmanager.enable_events_hander(plugin_id)
else:
eventmanager.disable_events_hander(plugin_id)
except Exception as err: except Exception as err:
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}") logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
def reload_plugin(self, plugin_id: str, conf: dict): def init_plugin(self, plugin_id: str, conf: dict):
""" """
重新加载插件 初始化插件
:param plugin_id: 插件ID
:param conf: 插件配置
""" """
if not self._running_plugins.get(plugin_id): if not self._running_plugins.get(plugin_id):
return return
self._running_plugins[plugin_id].init_plugin(conf) self._running_plugins[plugin_id].init_plugin(conf)
if self._running_plugins[plugin_id].get_state():
# 设置启用的插件事件注册状态可用
eventmanager.enable_events_hander(plugin_id)
else:
# 设置事件状态为不可用
eventmanager.disable_events_hander(plugin_id)
def stop(self): def stop(self, pid: str = None):
""" """
停止 停止插件服务
:param pid: 插件ID为空停止所有插件
""" """
# 停止所有插件 # 停止插件
for plugin in self._running_plugins.values(): for plugin_id, plugin in self._running_plugins.items():
# 关闭数据库 if pid and plugin_id != pid:
if hasattr(plugin, "close"): continue
plugin.close() self.__stop_plugin(plugin)
# 关闭插件
if hasattr(plugin, "stop_service"):
plugin.stop_service()
# 清空对像 # 清空对像
self._plugins = {} if pid:
self._running_plugins = {} # 清空指定插件
if pid in self._running_plugins:
self._running_plugins.pop(pid)
if pid in self._plugins:
self._plugins.pop(pid)
else:
# 清空
self._plugins = {}
self._running_plugins = {}
@staticmethod
def __stop_plugin(plugin: Any):
"""
停止插件
:param plugin: 插件实例
"""
# 关闭数据库
if hasattr(plugin, "close"):
plugin.close()
# 关闭插件
if hasattr(plugin, "stop_service"):
plugin.stop_service()
def remove_plugin(self, plugin_id: str):
"""
从内存中移除一个插件
:param plugin_id: 插件ID
"""
self.stop(plugin_id)
def reload_plugin(self, plugin_id: str):
"""
将一个插件重新加载到内存
:param plugin_id: 插件ID
"""
# 先移除
self.stop(plugin_id)
# 重新加载
self.start(plugin_id)
def install_online_plugin(self): def install_online_plugin(self):
""" """
@@ -108,40 +159,47 @@ class PluginManager(metaclass=Singleton):
""" """
if SystemUtils.is_frozen(): if SystemUtils.is_frozen():
return return
logger.info("开始安装在线插件...") logger.info("开始安装第三方插件...")
# 已安装插件 # 已安装插件
install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or [] install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 在线插件 # 在线插件
online_plugins = self.get_online_plugins() online_plugins = self.get_online_plugins()
if not online_plugins: if not online_plugins:
logger.error("未获取到在线插件") logger.error("未获取到第三方插件")
return return
# 支持更新的插件自动更新 # 支持更新的插件自动更新
for plugin in online_plugins: for plugin in online_plugins:
# 只处理已安装的插件 # 只处理已安装的插件
if plugin.get("id") in install_plugins and not self.is_plugin_exists(plugin.get("id")): if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id):
# 下载安装 # 下载安装
state, msg = self.pluginhelper.install(pid=plugin.get("id"), state, msg = self.pluginhelper.install(pid=plugin.id,
repo_url=plugin.get("repo_url")) repo_url=plugin.repo_url)
# 安装失败 # 安装失败
if not state: if not state:
logger.error( logger.error(
f"插件 {plugin.get('plugin_name')} v{plugin.get('plugin_version')} 安装失败:{msg}") f"插件 {plugin.plugin_name} v{plugin.plugin_version} 安装失败:{msg}")
continue continue
logger.info(f"插件 {plugin.get('plugin_name')} 安装成功,版本:{plugin.get('plugin_version')}") logger.info(f"插件 {plugin.plugin_name} 安装成功,版本:{plugin.plugin_version}")
logger.info("在线插件安装完成") logger.info("第三方插件安装完成")
def get_plugin_config(self, pid: str) -> dict: def get_plugin_config(self, pid: str) -> dict:
""" """
获取插件配置 获取插件配置
:param pid: 插件ID
""" """
if not self._plugins.get(pid): if not self._plugins.get(pid):
return {} 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: def save_plugin_config(self, pid: str, conf: dict) -> bool:
""" """
保存插件配置 保存插件配置
:param pid: 插件ID
:param conf: 配置
""" """
if not self._plugins.get(pid): if not self._plugins.get(pid):
return False return False
@@ -150,6 +208,7 @@ class PluginManager(metaclass=Singleton):
def delete_plugin_config(self, pid: str) -> bool: def delete_plugin_config(self, pid: str) -> bool:
""" """
删除插件配置 删除插件配置
:param pid: 插件ID
""" """
if not self._plugins.get(pid): if not self._plugins.get(pid):
return False return False
@@ -158,6 +217,7 @@ class PluginManager(metaclass=Singleton):
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]: def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
""" """
获取插件表单 获取插件表单
:param pid: 插件ID
""" """
if not self._running_plugins.get(pid): if not self._running_plugins.get(pid):
return [], {} return [], {}
@@ -168,6 +228,7 @@ class PluginManager(metaclass=Singleton):
def get_plugin_page(self, pid: str) -> List[dict]: def get_plugin_page(self, pid: str) -> List[dict]:
""" """
获取插件页面 获取插件页面
:param pid: 插件ID
""" """
if not self._running_plugins.get(pid): if not self._running_plugins.get(pid):
return [] return []
@@ -189,7 +250,10 @@ class PluginManager(metaclass=Singleton):
for _, plugin in self._running_plugins.items(): for _, plugin in self._running_plugins.items():
if hasattr(plugin, "get_command") \ if hasattr(plugin, "get_command") \
and ObjectUtils.check_method(plugin.get_command): and ObjectUtils.check_method(plugin.get_command):
ret_commands += plugin.get_command() or [] try:
ret_commands += plugin.get_command() or []
except Exception as e:
logger.error(f"获取插件命令出错:{str(e)}")
return ret_commands return ret_commands
def get_plugin_apis(self) -> List[Dict[str, Any]]: def get_plugin_apis(self) -> List[Dict[str, Any]]:
@@ -207,15 +271,57 @@ class PluginManager(metaclass=Singleton):
for pid, plugin in self._running_plugins.items(): for pid, plugin in self._running_plugins.items():
if hasattr(plugin, "get_api") \ if hasattr(plugin, "get_api") \
and ObjectUtils.check_method(plugin.get_api): and ObjectUtils.check_method(plugin.get_api):
apis = plugin.get_api() or [] try:
for api in apis: apis = plugin.get_api() or []
api["path"] = f"/{pid}{api['path']}" for api in apis:
ret_apis.extend(apis) api["path"] = f"/{pid}{api['path']}"
ret_apis.extend(apis)
except Exception as e:
logger.error(f"获取插件 {pid} API出错{str(e)}")
return ret_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):
try:
services = plugin.get_service()
if services:
ret_services.extend(services)
except Exception as e:
logger.error(f"获取插件 {pid} 服务出错:{str(e)}")
return ret_services
def get_plugin_attr(self, pid: str, attr: str) -> Any:
"""
获取插件属性
:param pid: 插件ID
:param attr: 属性名
"""
if not self._running_plugins.get(pid):
return None
if not hasattr(self._running_plugins[pid], attr):
return None
return getattr(self._running_plugins[pid], attr)
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any: def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
""" """
运行插件方法 运行插件方法
:param pid: 插件ID
:param method: 方法名
:param args: 参数
:param kwargs: 关键字参数
""" """
if not self._running_plugins.get(pid): if not self._running_plugins.get(pid):
return None return None
@@ -229,41 +335,47 @@ class PluginManager(metaclass=Singleton):
""" """
return list(self._plugins.keys()) return list(self._plugins.keys())
def get_online_plugins(self) -> List[dict]: def get_running_plugin_ids(self) -> List[str]:
"""
获取所有运行态插件ID
"""
return list(self._running_plugins.keys())
def get_online_plugins(self) -> List[schemas.Plugin]:
""" """
获取所有在线插件信息 获取所有在线插件信息
""" """
# 返回值
all_confs = [] def __get_plugin_info(market: str) -> Optional[List[schemas.Plugin]]:
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 {} online_plugins = self.pluginhelper.get_plugins(market) or {}
for pid, plugin in online_plugins.items(): if not online_plugins:
logger.warn(f"获取插件库失败:{market}")
return
ret_plugins = []
for pid, plugin_info in online_plugins.items():
# 运行状插件 # 运行状插件
plugin_obj = self._running_plugins.get(pid) plugin_obj = self._running_plugins.get(pid)
# 非运行态插件 # 非运行态插件
plugin_static = self._plugins.get(pid) plugin_static = self._plugins.get(pid)
# 基本属性 # 基本属性
conf = {} plugin = schemas.Plugin()
# ID # ID
conf.update({"id": pid}) plugin.id = pid
# 安装状态 # 安装状态
if pid in installed_apps and plugin_static: if pid in installed_apps and plugin_static:
conf.update({"installed": True}) plugin.installed = True
else: else:
conf.update({"installed": False}) plugin.installed = False
# 是否有新版本 # 是否有新版本
conf.update({"has_update": False}) plugin.has_update = False
if plugin_static: if plugin_static:
installed_version = getattr(plugin_static, "plugin_version") installed_version = getattr(plugin_static, "plugin_version")
if StringUtils.compare_version(installed_version, plugin.get("version")) < 0: if StringUtils.compare_version(installed_version, plugin_info.get("version")) < 0:
# 需要更新 # 需要更新
conf.update({"has_update": True}) plugin.has_update = True
# 运行状态 # 运行状态
if plugin_obj and hasattr(plugin_obj, "get_state"): if plugin_obj and hasattr(plugin_obj, "get_state"):
try: try:
@@ -271,65 +383,96 @@ class PluginManager(metaclass=Singleton):
except Exception as e: except Exception as e:
logger.error(f"获取插件 {pid} 状态出错:{str(e)}") logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
state = False state = False
conf.update({"state": state}) plugin.state = state
else: else:
conf.update({"state": False}) plugin.state = False
# 是否有详情页面 # 是否有详情页面
conf.update({"has_page": False}) plugin.has_page = False
if plugin_obj and hasattr(plugin_obj, "get_page"): if plugin_obj and hasattr(plugin_obj, "get_page"):
if ObjectUtils.check_method(plugin_obj.get_page): if ObjectUtils.check_method(plugin_obj.get_page):
conf.update({"has_page": True}) plugin.has_page = True
# 权限 # 权限
if plugin.get("level"): if plugin_info.get("level"):
conf.update({"auth_level": plugin.get("level")}) plugin.auth_level = plugin_info.get("level")
if self.siteshelper.auth_level < plugin.get("level"): if self.siteshelper.auth_level < plugin.auth_level:
continue continue
# 名称 # 名称
if plugin.get("name"): if plugin_info.get("name"):
conf.update({"plugin_name": plugin.get("name")}) plugin.plugin_name = plugin_info.get("name")
# 描述 # 描述
if plugin.get("description"): if plugin_info.get("description"):
conf.update({"plugin_desc": plugin.get("description")}) plugin.plugin_desc = plugin_info.get("description")
# 版本 # 版本
if plugin.get("version"): if plugin_info.get("version"):
conf.update({"plugin_version": plugin.get("version")}) plugin.plugin_version = plugin_info.get("version")
# 图标 # 图标
if plugin.get("icon"): if plugin_info.get("icon"):
conf.update({"plugin_icon": plugin.get("icon")}) plugin.plugin_icon = plugin_info.get("icon")
# 作者 # 作者
if plugin.get("author"): if plugin_info.get("author"):
conf.update({"plugin_author": plugin.get("author")}) plugin.plugin_author = plugin_info.get("author")
# 更新历史
if plugin_info.get("history"):
plugin.history = plugin_info.get("history")
# 仓库链接 # 仓库链接
conf.update({"repo_url": market}) plugin.repo_url = market
# 本地标志 # 本地标志
conf.update({"is_local": False}) plugin.is_local = False
# 汇总 # 汇总
all_confs.append(conf) ret_plugins.append(plugin)
# 按插件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]: return ret_plugins
if not settings.PLUGIN_MARKET:
return []
# 返回值
all_plugins = []
# 已安装插件
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 使用多线程获取线上插件
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for m in settings.PLUGIN_MARKET.split(","):
futures.append(executor.submit(__get_plugin_info, m))
for future in concurrent.futures.as_completed(futures):
plugins = future.result()
if plugins:
all_plugins.extend(plugins)
# 所有插件按repo在设置中的顺序排序
all_plugins.sort(
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
)
# 按插件ID和版本号去重相同插件以前面的为准
result = []
_dup = []
for p in all_plugins:
key = f"{p.id}v{p.plugin_version}"
if key not in _dup:
_dup.append(key)
result.append(p)
logger.info(f"共获取到 {len(result)} 个第三方插件")
return result
def get_local_plugins(self) -> List[schemas.Plugin]:
""" """
获取所有本地已下载的插件信息 获取所有本地已下载的插件信息
""" """
# 返回值 # 返回值
all_confs = [] plugins = []
# 已安装插件 # 已安装插件
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or [] installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
for pid, plugin in self._plugins.items(): for pid, plugin_class in self._plugins.items():
# 运行状插件 # 运行状插件
plugin_obj = self._running_plugins.get(pid) plugin_obj = self._running_plugins.get(pid)
# 基本属性 # 基本属性
conf = {} plugin = schemas.Plugin()
# ID # ID
conf.update({"id": pid}) plugin.id = pid
# 安装状态 # 安装状态
if pid in installed_apps: if pid in installed_apps:
conf.update({"installed": True}) plugin.installed = True
else: else:
conf.update({"installed": False}) plugin.installed = False
# 运行状态 # 运行状态
if plugin_obj and hasattr(plugin_obj, "get_state"): if plugin_obj and hasattr(plugin_obj, "get_state"):
try: try:
@@ -337,50 +480,51 @@ class PluginManager(metaclass=Singleton):
except Exception as e: except Exception as e:
logger.error(f"获取插件 {pid} 状态出错:{str(e)}") logger.error(f"获取插件 {pid} 状态出错:{str(e)}")
state = False state = False
conf.update({"state": state}) plugin.state = state
else: else:
conf.update({"state": False}) plugin.state = False
# 是否有详情页面 # 是否有详情页面
if hasattr(plugin, "get_page"): if hasattr(plugin_class, "get_page"):
if ObjectUtils.check_method(plugin.get_page): if ObjectUtils.check_method(plugin_class.get_page):
conf.update({"has_page": True}) plugin.has_page = True
else: else:
conf.update({"has_page": False}) plugin.has_page = False
# 权限 # 权限
if hasattr(plugin, "auth_level"): if hasattr(plugin_class, "auth_level"):
conf.update({"auth_level": plugin.auth_level}) plugin.auth_level = plugin_class.auth_level
if self.siteshelper.auth_level < plugin.auth_level: if self.siteshelper.auth_level < plugin.auth_level:
continue continue
# 名称 # 名称
if hasattr(plugin, "plugin_name"): if hasattr(plugin_class, "plugin_name"):
conf.update({"plugin_name": plugin.plugin_name}) plugin.plugin_name = plugin_class.plugin_name
# 描述 # 描述
if hasattr(plugin, "plugin_desc"): if hasattr(plugin_class, "plugin_desc"):
conf.update({"plugin_desc": plugin.plugin_desc}) plugin.plugin_desc = plugin_class.plugin_desc
# 版本 # 版本
if hasattr(plugin, "plugin_version"): if hasattr(plugin_class, "plugin_version"):
conf.update({"plugin_version": plugin.plugin_version}) plugin.plugin_version = plugin_class.plugin_version
# 图标 # 图标
if hasattr(plugin, "plugin_icon"): if hasattr(plugin_class, "plugin_icon"):
conf.update({"plugin_icon": plugin.plugin_icon}) plugin.plugin_icon = plugin_class.plugin_icon
# 作者 # 作者
if hasattr(plugin, "plugin_author"): if hasattr(plugin_class, "plugin_author"):
conf.update({"plugin_author": plugin.plugin_author}) plugin.plugin_author = plugin_class.plugin_author
# 作者链接 # 作者链接
if hasattr(plugin, "author_url"): if hasattr(plugin_class, "author_url"):
conf.update({"author_url": plugin.author_url}) plugin.author_url = plugin_class.author_url
# 是否需要更新 # 是否需要更新
conf.update({"has_update": False}) plugin.has_update = False
# 本地标志 # 本地标志
conf.update({"is_local": True}) plugin.is_local = True
# 汇总 # 汇总
all_confs.append(conf) plugins.append(plugin)
return all_confs return plugins
@staticmethod @staticmethod
def is_plugin_exists(pid: str) -> bool: def is_plugin_exists(pid: str) -> bool:
""" """
判断插件是否存在 判断插件是否在本地文件系统存在
:param pid: 插件ID
""" """
if not pid: if not pid:
return False return False

View File

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

View File

@@ -1,4 +1,3 @@
from pathlib import Path
from typing import List from typing import List
from app.db import DbOper from app.db import DbOper
@@ -10,12 +9,12 @@ class DownloadHistoryOper(DbOper):
下载历史管理 下载历史管理
""" """
def get_by_path(self, path: Path) -> DownloadHistory: def get_by_path(self, path: str) -> DownloadHistory:
""" """
按路径查询下载记录 按路径查询下载记录
:param path: 数据key :param path: 数据key
""" """
return DownloadHistory.get_by_path(self._db, str(path)) return DownloadHistory.get_by_path(self._db, path)
def get_by_hash(self, download_hash: str) -> DownloadHistory: def get_by_hash(self, download_hash: str) -> DownloadHistory:
""" """
@@ -132,3 +131,11 @@ class DownloadHistoryOper(DbOper):
type=type, type=type,
tmdbid=tmdbid, tmdbid=tmdbid,
seasons=seasons) seasons=seasons)
def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]:
"""
获取指定类型的下载历史
"""
return DownloadHistory.list_by_type(db=self._db,
mtype=mtype,
days=days)

View File

@@ -1,3 +1,6 @@
import random
import string
from alembic.command import upgrade from alembic.command import upgrade
from alembic.config import Config from alembic.config import Config
@@ -14,13 +17,24 @@ def init_db():
""" """
# 全量建表 # 全量建表
Base.metadata.create_all(bind=Engine) Base.metadata.create_all(bind=Engine)
def init_super_user():
"""
初始化超级管理员
"""
# 初始化超级管理员 # 初始化超级管理员
with SessionFactory() as db: with SessionFactory() as db:
_user = User.get_by_name(db=db, name=settings.SUPERUSER) _user = User.get_by_name(db=db, name=settings.SUPERUSER)
if not _user: 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( _user = User(
name=settings.SUPERUSER, name=settings.SUPERUSER,
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD), hashed_password=get_password_hash(random_password),
is_superuser=True, is_superuser=True,
) )
_user.create(db) _user.create(db)

61
app/db/message_oper.py Normal file
View File

@@ -0,0 +1,61 @@
import json
import time
from typing import Optional, Union
from sqlalchemy.orm import Session
from app.db import DbOper
from app.db.models.message import Message
from app.schemas import MessageChannel, NotificationType
class MessageOper(DbOper):
"""
消息数据管理
"""
def __init__(self, db: Session = None):
super().__init__(db)
def add(self,
channel: MessageChannel = None,
mtype: NotificationType = None,
title: str = None,
text: str = None,
image: str = None,
link: str = None,
userid: str = None,
action: int = 1,
note: Union[list, dict] = None,
**kwargs):
"""
新增媒体服务器数据
:param channel: 消息渠道
:param mtype: 消息类型
:param title: 标题
:param text: 文本内容
:param image: 图片
:param link: 链接
:param userid: 用户ID
:param action: 消息方向0-接收息1-发送消息
:param note: 附件json
"""
kwargs.update({
"channel": channel.value if channel else '',
"mtype": mtype.value if mtype else '',
"title": title,
"text": text,
"image": image,
"link": link,
"userid": userid,
"action": action,
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": json.dumps(note) if note else ''
})
Message(**kwargs).create(self._db)
def list_by_page(self, page: int = 1, count: int = 30) -> Optional[str]:
"""
获取媒体服务器数据ID
"""
return Message.list_by_page(self._db, page, count)

View File

@@ -1,3 +1,5 @@
import time
from sqlalchemy import Column, Integer, String, Sequence from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -140,6 +142,16 @@ class DownloadHistory(Base):
DownloadHistory.tmdbid == tmdbid).order_by( DownloadHistory.tmdbid == tmdbid).order_by(
DownloadHistory.id.desc()).all() DownloadHistory.id.desc()).all()
@staticmethod
@db_query
def list_by_type(db: Session, mtype: str, days: int):
result = db.query(DownloadHistory) \
.filter(DownloadHistory.type == mtype,
DownloadHistory.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
).all()
return list(result)
class DownloadFiles(Base): class DownloadFiles(Base):
""" """
@@ -188,6 +200,7 @@ class DownloadFiles(Base):
result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all() result = db.query(DownloadFiles).filter(DownloadFiles.savepath == savepath).all()
return list(result) return list(result)
@staticmethod
@db_update @db_update
def delete_by_fullpath(db: Session, fullpath: str): def delete_by_fullpath(db: Session, fullpath: str):
db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath, db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath,

39
app/db/models/message.py Normal file
View File

@@ -0,0 +1,39 @@
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db import db_query, Base
class Message(Base):
"""
消息表
"""
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 消息渠道
channel = Column(String)
# 消息类型
mtype = Column(String)
# 标题
title = Column(String)
# 文本内容
text = Column(String)
# 图片
image = Column(String)
# 链接
link = Column(String)
# 用户ID
userid = Column(String)
# 登记时间
reg_time = Column(String, index=True)
# 消息方向0-接收息1-发送消息
action = Column(Integer)
# 附件json
note = Column(String)
@staticmethod
@db_query
def list_by_page(db: Session, page: int = 1, count: int = 30):
result = db.query(Message).order_by(Message.reg_time.desc()).offset((page - 1) * count).limit(
count).all()
result.sort(key=lambda x: x.reg_time, reverse=False)
return list(result)

View File

@@ -1,4 +1,6 @@
from sqlalchemy import Column, Integer, String, Sequence import time
from sqlalchemy import Column, Integer, String, Sequence, Float
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import db_query, db_update, Base from app.db import db_query, db_update, Base
@@ -21,14 +23,15 @@ class Subscribe(Base):
imdbid = Column(String) imdbid = Column(String)
tvdbid = Column(Integer) tvdbid = Column(Integer)
doubanid = Column(String, index=True) doubanid = Column(String, index=True)
bangumiid = Column(Integer, index=True)
# 季号 # 季号
season = Column(Integer) season = Column(Integer)
# 海报 # 海报
poster = Column(String) poster = Column(String)
# 背景图 # 背景图
backdrop = Column(String) backdrop = Column(String)
# 评分 # 评分float
vote = Column(Integer) vote = Column(Float)
# 简介 # 简介
description = Column(String) description = Column(String)
# 过滤规则 # 过滤规则
@@ -67,6 +70,10 @@ class Subscribe(Base):
current_priority = Column(Integer) current_priority = Column(Integer)
# 保存路径 # 保存路径
save_path = Column(String) save_path = Column(String)
# 是否使用 imdbid 搜索
search_imdbid = Column(Integer, default=0)
# 是否手动修改过总集数 0否 1是
manual_total_episode = Column(Integer, default=0)
@staticmethod @staticmethod
@db_query @db_query
@@ -109,6 +116,11 @@ class Subscribe(Base):
def get_by_doubanid(db: Session, doubanid: str): def get_by_doubanid(db: Session, doubanid: str):
return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first() return db.query(Subscribe).filter(Subscribe.doubanid == doubanid).first()
@staticmethod
@db_query
def get_by_bangumiid(db: Session, bangumiid: int):
return db.query(Subscribe).filter(Subscribe.bangumiid == bangumiid).first()
@db_update @db_update
def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int): def delete_by_tmdbid(self, db: Session, tmdbid: int, season: int):
subscrbies = self.get_by_tmdbid(db, tmdbid, season) subscrbies = self.get_by_tmdbid(db, tmdbid, season)
@@ -122,3 +134,13 @@ class Subscribe(Base):
if subscribe: if subscribe:
subscribe.delete(db, subscribe.id) subscribe.delete(db, subscribe.id)
return True return True
@staticmethod
@db_query
def list_by_type(db: Session, mtype: str, days: int):
result = db.query(Subscribe) \
.filter(Subscribe.type == mtype,
Subscribe.date >= time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - 86400 * int(days)))
).all()
return list(result)

View File

@@ -1,8 +1,12 @@
from typing import Tuple, Optional
from sqlalchemy import Boolean, Column, Integer, String, Sequence from sqlalchemy import Boolean, Column, Integer, String, Sequence
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.security import verify_password from app.core.security import verify_password
from app.db import db_query, db_update, Base from app.db import db_query, db_update, Base
from app.schemas import User
from app.utils.otp import OtpUtils
class User(Base): class User(Base):
@@ -23,16 +27,23 @@ class User(Base):
is_superuser = Column(Boolean(), default=False) is_superuser = Column(Boolean(), default=False)
# 头像 # 头像
avatar = Column(String) avatar = Column(String)
# 是否启用otp二次验证
is_otp = Column(Boolean(), default=False)
# otp秘钥
otp_secret = Column(String, default=None)
@staticmethod @staticmethod
@db_query @db_query
def authenticate(db: Session, name: str, password: str): def authenticate(db: Session, name: str, password: str, otp_password: str) -> Tuple[bool, Optional[User]]:
user = db.query(User).filter(User.name == name).first() user = db.query(User).filter(User.name == name).first()
if not user: if not user:
return None return False, None
if not verify_password(password, str(user.hashed_password)): if not verify_password(password, str(user.hashed_password)):
return None return False, user
return user if user.is_otp:
if not otp_password or not OtpUtils.check(user.otp_secret, otp_password):
return False, user
return True, user
@staticmethod @staticmethod
@db_query @db_query
@@ -45,3 +56,14 @@ class User(Base):
if user: if user:
user.delete(db, user.id) user.delete(db, user.id)
return True return True
@db_update
def update_otp_by_name(self, db: Session, name: str, otp: bool, secret: str):
user = self.get_by_name(db, name)
if user:
user.update(db, {
'is_otp': otp,
'otp_secret': secret
})
return True
return False

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import json
import time import time
from typing import Tuple, List from typing import Tuple, List
@@ -20,6 +21,9 @@ class SubscribeOper(DbOper):
doubanid=mediainfo.douban_id, doubanid=mediainfo.douban_id,
season=kwargs.get('season')) season=kwargs.get('season'))
if not subscribe: if not subscribe:
if kwargs.get("sites") and not isinstance(kwargs.get("sites"), str):
kwargs["sites"] = json.dumps(kwargs.get("sites"))
subscribe = Subscribe(name=mediainfo.title, subscribe = Subscribe(name=mediainfo.title,
year=mediainfo.year, year=mediainfo.year,
type=mediainfo.type.value, type=mediainfo.type.value,
@@ -27,6 +31,7 @@ class SubscribeOper(DbOper):
imdbid=mediainfo.imdb_id, imdbid=mediainfo.imdb_id,
tvdbid=mediainfo.tvdb_id, tvdbid=mediainfo.tvdb_id,
doubanid=mediainfo.douban_id, doubanid=mediainfo.douban_id,
bangumiid=mediainfo.bangumi_id,
poster=mediainfo.get_poster_image(), poster=mediainfo.get_poster_image(),
backdrop=mediainfo.get_backdrop_image(), backdrop=mediainfo.get_backdrop_image(),
vote=mediainfo.vote_average, vote=mediainfo.vote_average,
@@ -83,3 +88,9 @@ class SubscribeOper(DbOper):
subscribe = self.get(sid) subscribe = self.get(sid)
subscribe.update(self._db, payload) subscribe.update(self._db, payload)
return subscribe return subscribe
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
"""
获取指定类型的订阅
"""
return Subscribe.list_by_type(self._db, mtype=mtype, days=days)

View File

@@ -56,6 +56,12 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
return self.__SYSTEMCONF return self.__SYSTEMCONF
return self.__SYSTEMCONF.get(key) return self.__SYSTEMCONF.get(key)
def all(self):
"""
获取所有系统设置
"""
return self.__SYSTEMCONF or {}
def delete(self, key: Union[str, SystemConfigKey]): def delete(self, key: Union[str, SystemConfigKey]):
""" """
删除系统设置 删除系统设置

View File

@@ -0,0 +1,2 @@
from .doh import doh_query_json
from .cloudflare import under_challenge

View File

@@ -61,7 +61,7 @@ class PlaywrightHelper:
ua: str = None, ua: str = None,
proxies: dict = None, proxies: dict = None,
headless: bool = False, headless: bool = False,
timeout: int = 30) -> str: timeout: int = 20) -> str:
""" """
获取网页源码 获取网页源码
:param url: 网页地址 :param url: 网页地址

View File

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

View File

@@ -1,68 +1,126 @@
from typing import Tuple, Optional import json
from hashlib import md5
from typing import Any, Dict, Tuple, Optional
from app.core.config import settings
from app.utils.common import decrypt
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.string import StringUtils from app.utils.string import StringUtils
class CookieCloudHelper: class CookieCloudHelper:
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"] _ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
def __init__(self, server, key, password): def __init__(self):
self._server = server self._sync_setting()
self._key = key
self._password = password
self._req = RequestUtils(content_type="application/json") self._req = RequestUtils(content_type="application/json")
def _sync_setting(self):
self._server = settings.COOKIECLOUD_HOST
self._key = settings.COOKIECLOUD_KEY
self._password = settings.COOKIECLOUD_PASSWORD
self._enable_local = settings.COOKIECLOUD_ENABLE_LOCAL
self._local_path = settings.COOKIE_PATH
def download(self) -> Tuple[Optional[dict], str]: def download(self) -> Tuple[Optional[dict], str]:
""" """
从CookieCloud下载数据 从CookieCloud下载数据
:return: Cookie数据、错误信息 :return: Cookie数据、错误信息
""" """
if not self._server or not self._key or not self._password: # 更新为最新设置
self._sync_setting()
if ((not self._server and not self._enable_local)
or not self._key
or not self._password):
return None, "CookieCloud参数不正确" return None, "CookieCloud参数不正确"
req_url = "%s/get/%s" % (self._server, self._key)
ret = self._req.post_res(url=req_url, json={"password": self._password}) if self._enable_local:
if ret and ret.status_code == 200: # 开启本地服务时,从本地直接读取数据
result = ret.json() result = self._load_local_encrypt_data(self._key)
if not result: if not result:
return {}, "下载到数据" return {}, "从本地CookieCloud服务加载到cookie数据请检查服务器设置、用户KEY及加密密码是否正确"
if result.get("cookie_data"):
contents = result.get("cookie_data")
else:
contents = result
# 整理数据,使用domain域名的最后两级作为分组依据
domain_groups = {}
for site, cookies in contents.items():
for cookie in cookies:
domain_key = StringUtils.get_url_domain(cookie.get("domain"))
if not domain_groups.get(domain_key):
domain_groups[domain_key] = [cookie]
else:
domain_groups[domain_key].append(cookie)
# 返回错误
ret_cookies = {}
# 索引器
for domain, content_list in domain_groups.items():
if not content_list:
continue
# 只有cf的cookie过滤掉
cloudflare_cookie = True
for content in content_list:
if content["name"] != "cf_clearance":
cloudflare_cookie = False
break
if cloudflare_cookie:
continue
# 站点Cookie
cookie_str = ";".join(
[f"{content.get('name')}={content.get('value')}"
for content in content_list
if content.get("name") and content.get("name") not in self._ignore_cookies]
)
ret_cookies[domain] = cookie_str
return ret_cookies, ""
elif ret:
return None, f"同步CookieCloud失败错误码{ret.status_code}"
else: else:
return None, "CookieCloud请求失败请检查服务器地址、用户KEY及加密密码是否正确" req_url = "%s/get/%s" % (self._server, str(self._key).strip())
ret = self._req.get_res(url=req_url)
if ret and ret.status_code == 200:
try:
result = ret.json()
if not result:
return {}, f"未从{self._server}下载到cookie数据"
except Exception as err:
return {}, f"{self._server}下载cookie数据错误{str(err)}"
elif ret:
return None, f"远程同步CookieCloud失败错误码{ret.status_code}"
else:
return None, "CookieCloud请求失败请检查服务器地址、用户KEY及加密密码是否正确"
encrypted = result.get("encrypted")
if not encrypted:
return {}, "未获取到cookie密文"
else:
crypt_key = self._get_crypt_key()
try:
decrypted_data = decrypt(encrypted, crypt_key).decode('utf-8')
result = json.loads(decrypted_data)
except Exception as e:
return {}, "cookie解密失败" + str(e)
if not result:
return {}, "cookie解密为空"
if result.get("cookie_data"):
contents = result.get("cookie_data")
else:
contents = result
# 整理数据,使用domain域名的最后两级作为分组依据
domain_groups = {}
for site, cookies in contents.items():
for cookie in cookies:
domain_key = StringUtils.get_url_domain(cookie.get("domain"))
if not domain_groups.get(domain_key):
domain_groups[domain_key] = [cookie]
else:
domain_groups[domain_key].append(cookie)
# 返回错误
ret_cookies = {}
# 索引器
for domain, content_list in domain_groups.items():
if not content_list:
continue
# 只有cf的cookie过滤掉
cloudflare_cookie = True
for content in content_list:
if content["name"] != "cf_clearance":
cloudflare_cookie = False
break
if cloudflare_cookie:
continue
# 站点Cookie
cookie_str = ";".join(
[f"{content.get('name')}={content.get('value')}"
for content in content_list
if content.get("name") and content.get("name") not in self._ignore_cookies]
)
ret_cookies[domain] = cookie_str
return ret_cookies, ""
def _get_crypt_key(self) -> bytes:
"""
使用UUID和密码生成CookieCloud的加解密密钥
"""
md5_generator = md5()
md5_generator.update((str(self._key).strip() + '-' + str(self._password).strip()).encode('utf-8'))
return (md5_generator.hexdigest()[:16]).encode('utf-8')
def _load_local_encrypt_data(self, uuid: str) -> Dict[str, Any]:
file_path = self._local_path / f"{uuid}.json"
# 检查文件是否存在
if not file_path.exists():
return {}
# 读取文件
with open(file_path, encoding="utf-8", mode="r") as file:
read_content = file.read()
data = json.loads(read_content.encode("utf-8"))
return data

156
app/helper/doh.py Normal file
View File

@@ -0,0 +1,156 @@
"""
doh函数的实现。
author: https://github.com/C5H12O5/syno-videoinfo-plugin
"""
import base64
import concurrent
import concurrent.futures
import json
import socket
import struct
import urllib
import urllib.request
from typing import Dict, Optional
from app.core.config import settings
from app.log import logger
# 定义一个全局集合来存储注册的主机
_registered_hosts = {
'api.themoviedb.org',
'api.tmdb.org',
'webservice.fanart.tv',
'api.github.com',
'github.com',
'raw.githubusercontent.com',
'api.telegram.org'
}
# 定义一个全局线程池执行器
_executor = concurrent.futures.ThreadPoolExecutor()
# 定义默认的DoH配置
_doh_timeout = 5
_doh_cache: Dict[str, str] = {}
_doh_resolvers = [
# https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https
"1.0.0.1",
"1.1.1.1",
# https://support.quad9.net/hc/en-us
"9.9.9.9",
"149.112.112.112"
]
def _patched_getaddrinfo(host, *args, **kwargs):
"""
socket.getaddrinfo的补丁版本。
"""
if host not in _registered_hosts:
return _orig_getaddrinfo(host, *args, **kwargs)
# 检查主机是否已解析
if host in _doh_cache:
ip = _doh_cache[host]
logger.info("已解析 [%s] 为 [%s] (缓存)", host, ip)
return _orig_getaddrinfo(ip, *args, **kwargs)
# 使用DoH解析主机
futures = []
for resolver in _doh_resolvers:
futures.append(_executor.submit(_doh_query, resolver, host))
for future in concurrent.futures.as_completed(futures):
ip = future.result()
if ip is not None:
logger.info("已解析 [%s] 为 [%s]", host, ip)
_doh_cache[host] = ip
host = ip
break
return _orig_getaddrinfo(host, *args, **kwargs)
# 对 socket.getaddrinfo 进行补丁
if settings.DOH_ENABLE:
_orig_getaddrinfo = socket.getaddrinfo
socket.getaddrinfo = _patched_getaddrinfo
def _doh_query(resolver: str, host: str) -> Optional[str]:
"""
使用给定的DoH解析器查询给定主机的IP地址。
"""
# 构造DNS查询消息RFC 1035
header = b"".join(
[
b"\x00\x00", # ID: 0
b"\x01\x00", # FLAGS: 标准递归查询
b"\x00\x01", # QDCOUNT: 1
b"\x00\x00", # ANCOUNT: 0
b"\x00\x00", # NSCOUNT: 0
b"\x00\x00", # ARCOUNT: 0
]
)
question = b"".join(
[
b"".join(
[
struct.pack("B", len(item)) + item.encode("utf-8")
for item in host.split(".")
]
)
+ b"\x00", # QNAME: 域名序列
b"\x00\x01", # QTYPE: A
b"\x00\x01", # QCLASS: IN
]
)
message = header + question
try:
# 发送GET请求到DoH解析器RFC 8484
b64message = base64.b64encode(message).decode("utf-8").rstrip("=")
url = f"https://{resolver}/dns-query?dns={b64message}"
headers = {"Content-Type": "application/dns-message"}
logger.debug("DoH请求: %s", url)
request = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(request, timeout=_doh_timeout) as response:
logger.debug("解析器(%s)响应: %s", resolver, response.status)
if response.status != 200:
return None
resp_body = response.read()
# 解析DNS响应消息RFC 1035
# name压缩:2 + type:2 + class:2 + ttl:4 + rdlength:2 = 12字节
first_rdata_start = len(header) + len(question) + 12
# rdataA记录= 4字节
first_rdata_end = first_rdata_start + 4
# 将rdata转换为IP地址
return socket.inet_ntoa(resp_body[first_rdata_start:first_rdata_end])
except Exception as e:
logger.error("解析器(%s)请求错误: %s", resolver, e)
return None
def doh_query_json(resolver: str, host: str) -> Optional[str]:
"""
使用给定的DoH解析器查询给定主机的IP地址。
"""
url = f"https://{resolver}/dns-query?name={host}&type=A"
headers = {"Accept": "application/dns-json"}
logger.debug("DoH请求: %s", url)
try:
request = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(request, timeout=_doh_timeout) as response:
logger.debug("解析器(%s)响应: %s", resolver, response.status)
if response.status != 200:
return None
response_body = response.read().decode("utf-8")
logger.debug("<== body: %s", response_body)
answer = json.loads(response_body)["Answer"]
return answer[0]["data"]
except Exception as e:
logger.error("解析器(%s)请求错误: %s", resolver, e)
return None

View File

@@ -1,19 +1,46 @@
import json
import queue import queue
import time
from typing import Optional, Any, Union
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
class MessageHelper(metaclass=Singleton): class MessageHelper(metaclass=Singleton):
""" """
消息队列管理器 消息队列管理器,包括系统消息和用户消息
""" """
def __init__(self): def __init__(self):
self.queue = queue.Queue() self.sys_queue = queue.Queue()
self.user_queue = queue.Queue()
def put(self, message: str): def put(self, message: Any, role: str = "sys", note: Union[list, dict] = None):
self.queue.put(message) """
存消息
:param message: 消息
:param role: 消息通道 sys/user
:param note: 附件json
"""
if role == "sys":
self.sys_queue.put(message)
else:
if isinstance(message, str):
self.user_queue.put(message)
elif hasattr(message, "to_dict"):
content = message.to_dict()
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
content['note'] = json.dumps(note) if note else None
self.user_queue.put(json.dumps(content))
def get(self): def get(self, role: str = "sys") -> Optional[str]:
if not self.queue.empty(): """
return self.queue.get(block=False) 取消息
:param role: 消息通道 sys/user
"""
if role == "sys":
if not self.sys_queue.empty():
return self.sys_queue.get(block=False)
else:
if not self.user_queue.empty():
return self.user_queue.get(block=False)
return None return None

View File

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

View File

@@ -1,11 +1,15 @@
import json import json
import shutil import shutil
import traceback
from pathlib import Path from pathlib import Path
from typing import Dict, Tuple, Optional, List from typing import Dict, Tuple, Optional, List
from cachetools import TTLCache, cached from cachetools import TTLCache, cached
from app.core.config import settings from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
from app.utils.system import SystemUtils from app.utils.system import SystemUtils
@@ -18,7 +22,19 @@ class PluginHelper(metaclass=Singleton):
_base_url = "https://raw.githubusercontent.com/%s/%s/main/" _base_url = "https://raw.githubusercontent.com/%s/%s/main/"
@cached(cache=TTLCache(maxsize=10, ttl=1800)) _install_reg = "https://movie-pilot.org/plugin/install/%s"
_install_report = "https://movie-pilot.org/plugin/install"
_install_statistic = "https://movie-pilot.org/plugin/statistic"
def __init__(self):
self.systemconfig = SystemConfigOper()
if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):
if self.install_report():
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
def get_plugins(self, repo_url: str) -> Dict[str, dict]: def get_plugins(self, repo_url: str) -> Dict[str, dict]:
""" """
获取Github所有最新插件列表 获取Github所有最新插件列表
@@ -33,7 +49,11 @@ class PluginHelper(metaclass=Singleton):
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=10).get_res(f"{raw_url}package.json") timeout=10).get_res(f"{raw_url}package.json")
if res: if res:
return json.loads(res.text) try:
return json.loads(res.text)
except json.JSONDecodeError:
logger.error(f"插件包数据解析失败:{res.text}")
return {}
return {} return {}
@staticmethod @staticmethod
@@ -51,10 +71,49 @@ class PluginHelper(metaclass=Singleton):
try: try:
user, repo = repo_url.split("/")[-4:-2] user, repo = repo_url.split("/")[-4:-2]
except Exception as e: except Exception as e:
print(str(e)) logger.error(f"解析Github仓库地址失败{str(e)} - {traceback.format_exc()}")
return None, None return None, None
return user, repo return user, repo
@cached(cache=TTLCache(maxsize=1, ttl=1800))
def get_statistic(self) -> Dict:
"""
获取插件安装统计
"""
res = RequestUtils(timeout=10).get_res(self._install_statistic)
if res and res.status_code == 200:
return res.json()
return {}
def install_reg(self, pid: str) -> bool:
"""
安装插件统计
"""
if not pid:
return False
res = RequestUtils(timeout=5).get_res(self._install_reg % pid)
if res and res.status_code == 200:
return True
return False
def install_report(self) -> bool:
"""
上报存量插件安装统计
"""
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
if not plugins:
return False
res = RequestUtils(content_type="application/json",
timeout=5).post(self._install_report,
json={
"plugins": [
{
"plugin_id": plugin,
} for plugin in plugins
]
})
return True if res else False
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]: def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
""" """
安装插件 安装插件
@@ -73,7 +132,9 @@ class PluginHelper(metaclass=Singleton):
""" """
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p.lower()}" 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) r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
if not r or r.status_code != 200: if r is None:
return None, "连接仓库失败"
elif r.status_code != 200:
return None, f"连接仓库失败:{r.status_code} - {r.reason}" return None, f"连接仓库失败:{r.status_code} - {r.reason}"
ret = r.json() ret = r.json()
if ret and ret[0].get("message") == "Not Found": if ret and ret[0].get("message") == "Not Found":
@@ -147,5 +208,8 @@ class PluginHelper(metaclass=Singleton):
# 插件目录下如有requirements.txt则安装依赖 # 插件目录下如有requirements.txt则安装依赖
requirements_file = plugin_dir / "requirements.txt" requirements_file = plugin_dir / "requirements.txt"
if requirements_file.exists(): if requirements_file.exists():
SystemUtils.execute(f"pip install -r {requirements_file}") SystemUtils.execute(f"pip install -r {requirements_file} > /dev/null 2>&1")
# 安装成功后统计
self.install_reg(pid)
return True, "" return True, ""

View File

@@ -34,7 +34,11 @@ class ResourceHelper(metaclass=Singleton):
logger.info("开始检测资源包版本...") logger.info("开始检测资源包版本...")
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo) res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
if res: if res:
resource_info = json.loads(res.text) try:
resource_info = json.loads(res.text)
except json.JSONDecodeError:
logger.error("资源包仓库数据解析失败!")
return
else: else:
logger.warn("无法连接资源包仓库!") logger.warn("无法连接资源包仓库!")
return return
@@ -51,11 +55,7 @@ class ResourceHelper(metaclass=Singleton):
target = resource.get("target") target = resource.get("target")
version = resource.get("version") version = resource.get("version")
# 判断平台 # 判断平台
if platform and platform != SystemUtils.platform: if platform and platform != SystemUtils.platform():
continue
# 判断本地是否存在
local_path = self._base_dir / target / rname
if not local_path.exists():
continue continue
# 判断版本号 # 判断版本号
if rtype == "auth": if rtype == "auth":

View File

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

View File

@@ -1,20 +1,22 @@
import datetime import datetime
import re import re
import traceback
from pathlib import Path from pathlib import Path
from typing import Tuple, Optional, List, Union from typing import Tuple, Optional, List, Union, Dict
from urllib.parse import unquote from urllib.parse import unquote
from requests import Response from requests import Response
from torrentool.api import Torrent from torrentool.api import Torrent
from app.core.config import settings from app.core.config import settings
from app.core.context import Context from app.core.context import Context, TorrentInfo, MediaInfo
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger from app.log import logger
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.schemas.types import MediaType, SystemConfigKey from app.schemas.types import MediaType, SystemConfigKey
from app.utils.singleton import Singleton from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class TorrentHelper(metaclass=Singleton): class TorrentHelper(metaclass=Singleton):
@@ -295,3 +297,188 @@ class TorrentHelper(metaclass=Singleton):
""" """
if url not in self._invalid_torrents: if url not in self._invalid_torrents:
self._invalid_torrents.append(url) self._invalid_torrents.append(url)
@staticmethod
def filter_torrent(torrent_info: TorrentInfo,
filter_rule: Dict[str, str],
mediainfo: MediaInfo) -> bool:
"""
检查种子是否匹配订阅过滤规则
"""
def __get_size_range(size_str: str) -> Tuple[float, float]:
"""
获取大小范围
"""
if not size_str:
return 0, 0
try:
size_range = size_str.split("-")
if len(size_range) == 1:
return 0, float(size_range[0])
elif len(size_range) == 2:
return float(size_range[0]), float(size_range[1])
except Exception as e:
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
return 0, 0
if not filter_rule:
return True
# 匹配内容
content = (f"{torrent_info.title} "
f"{torrent_info.description} "
f"{' '.join(torrent_info.labels or [])} "
f"{torrent_info.volume_factor}")
# 最少做种人数
min_seeders = filter_rule.get("min_seeders")
if min_seeders and torrent_info.seeders < int(min_seeders):
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
return False
# 包含
include = filter_rule.get("include")
if include:
if not re.search(r"%s" % include, content, re.I):
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
return False
# 排除
exclude = filter_rule.get("exclude")
if exclude:
if re.search(r"%s" % exclude, content, re.I):
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
return False
# 质量
quality = filter_rule.get("quality")
if quality:
if not re.search(r"%s" % quality, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
return False
# 分辨率
resolution = filter_rule.get("resolution")
if resolution:
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
return False
# 特效
effect = filter_rule.get("effect")
if effect:
if not re.search(r"%s" % effect, torrent_info.title, re.I):
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
return False
# 大小
tv_size = filter_rule.get("tv_size")
movie_size = filter_rule.get("movie_size")
if movie_size or tv_size:
if mediainfo.type == MediaType.TV:
size = tv_size
else:
size = movie_size
# 大小范围
begin_size, end_size = __get_size_range(size)
if begin_size or end_size:
meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description)
# 集数
if mediainfo.type == MediaType.TV:
# 电视剧
season = meta.begin_season or 1
if meta.total_episode:
# 识别的总集数
episodes_num = meta.total_episode
else:
# 整季集数
episodes_num = len(mediainfo.seasons.get(season) or [1])
# 比较大小
if not (begin_size * 1024 ** 3 <= (torrent_info.size / episodes_num) <= end_size * 1024 ** 3):
logger.info(f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} "
f"{episodes_num}集,不匹配大小规则 {size}")
return False
else:
# 电影比较大小
if not (begin_size * 1024 ** 3 <= torrent_info.size <= end_size * 1024 ** 3):
logger.info(
f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} 不匹配大小规则 {size}")
return False
return True
@staticmethod
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo,
torrent: TorrentInfo, logerror: bool = True) -> bool:
"""
检查种子是否匹配媒体信息
:param mediainfo: 需要匹配的媒体信息
:param torrent_meta: 种子识别信息
:param torrent: 种子信息
:param logerror: 是否记录错误日志
"""
# 要匹配的媒体标题、原标题
media_titles = {
StringUtils.clear_upper(mediainfo.title),
StringUtils.clear_upper(mediainfo.original_title)
} - {""}
# 要匹配的媒体别名、译名
media_names = {StringUtils.clear_upper(name) for name in mediainfo.names if name}
# 识别的种子中英文名
meta_names = {
StringUtils.clear_upper(torrent_meta.cn_name),
StringUtils.clear_upper(torrent_meta.en_name)
} - {""}
# 比对种子识别类型
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value}'
f'不匹配 {mediainfo.type.value}')
return False
# 比对种子在站点中的类型
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category}'
f'不匹配 {mediainfo.type.value}')
return False
# 比对年份
if mediainfo.year:
if mediainfo.type == MediaType.TV:
# 剧集年份,每季的年份可能不同
if torrent_meta.year and torrent_meta.year not in [year for year in
mediainfo.season_years.values()]:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')
return False
else:
# 电影年份上下浮动1年
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
mediainfo.year,
str(int(mediainfo.year) + 1)]:
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
return False
# 比对标题和原语种标题
if meta_names.intersection(media_titles):
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 比对别名和译名
if media_names:
if meta_names.intersection(media_names):
logger.info(f'{mediainfo.title} 通过别名或译名匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 标题拆分
titles = [StringUtils.clear_upper(t) for t in re.split(r'[\s/【】.\[\]\-]+',
torrent_meta.org_string) if t]
# 在标题中判断是否存在标题、原语种标题、别名、译名
if media_titles.intersection(titles) or media_names.intersection(titles):
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 在副标题中判断是否存在标题、原语种标题、别名、译名
if torrent.description:
subtitles = {StringUtils.clear_upper(t) for t in re.split(r'[\s/|]+',
torrent.description) if t}
if media_titles.intersection(subtitles) or media_names.intersection(subtitles):
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title}'
f'副标题:{torrent.description}')
return True
# 未匹配
if logerror:
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
return False

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

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

View File

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

View File

@@ -19,14 +19,15 @@ if SystemUtils.is_frozen():
from app.core.config import settings from app.core.config import settings
from app.core.module import ModuleManager from app.core.module import ModuleManager
from app.core.plugin import PluginManager 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.thread import ThreadHelper
from app.helper.display import DisplayHelper from app.helper.display import DisplayHelper
from app.helper.resource import ResourceHelper from app.helper.resource import ResourceHelper
from app.helper.sites import SitesHelper from app.helper.sites import SitesHelper
from app.helper.message import MessageHelper
from app.scheduler import Scheduler from app.scheduler import Scheduler
from app.command import Command from app.command import Command, CommandChian
from app.schemas import Notification, NotificationType
# App # App
App = FastAPI(title=settings.PROJECT_NAME, App = FastAPI(title=settings.PROJECT_NAME,
@@ -52,10 +53,13 @@ def init_routers():
""" """
from app.api.apiv1 import api_router from app.api.apiv1 import api_router
from app.api.servarr import arr_router from app.api.servarr import arr_router
from app.api.servcookie import cookie_router
# API路由 # API路由
App.include_router(api_router, prefix=settings.API_V1_STR) App.include_router(api_router, prefix=settings.API_V1_STR)
# Radarr、Sonarr路由 # Radarr、Sonarr路由
App.include_router(arr_router, prefix="/api/v3") App.include_router(arr_router, prefix="/api/v3")
# CookieCloud路由
App.include_router(cookie_router, prefix="/cookiecloud")
def start_frontend(): def start_frontend():
@@ -139,6 +143,22 @@ def start_tray():
threading.Thread(target=TrayIcon.run, daemon=True).start() 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") @App.on_event("shutdown")
def shutdown_server(): def shutdown_server():
""" """
@@ -165,6 +185,8 @@ def start_module():
""" """
启动模块 启动模块
""" """
# 初始化超级管理员
init_super_user()
# 虚拟显示 # 虚拟显示
DisplayHelper() DisplayHelper()
# 站点管理 # 站点管理
@@ -183,6 +205,8 @@ def start_module():
init_routers() init_routers()
# 启动前端服务 # 启动前端服务
start_frontend() start_frontend()
# 检查认证状态
check_auth()
if __name__ == '__main__': if __name__ == '__main__':

View File

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

View File

@@ -0,0 +1,93 @@
from typing import List, Optional, Tuple, Union
from app.core.context import MediaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
from app.utils.http import RequestUtils
class BangumiModule(_ModuleBase):
bangumiapi: BangumiApi = None
def init_module(self) -> None:
self.bangumiapi = BangumiApi()
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
with RequestUtils().get_res("https://api.bgm.tv/") as ret:
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接Bangumi错误码{ret.status_code}"
return False, "Bangumi网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param bangumiid: 识别的Bangumi ID
:return: 识别的媒体信息,包括剧集信息
"""
if not bangumiid:
return None
# 直接查询详情
info = self.bangumi_info(bangumiid=bangumiid)
if info:
# 赋值TMDB信息并返回
mediainfo = MediaInfo(bangumi_info=info)
logger.info(f"{bangumiid} Bangumi识别结果{mediainfo.type.value} "
f"{mediainfo.title_year}")
return mediainfo
else:
logger.info(f"{bangumiid} 未匹配到Bangumi媒体信息")
return None
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
获取Bangumi信息
:param bangumiid: BangumiID
:return: Bangumi信息
"""
if not bangumiid:
return None
logger.info(f"开始获取Bangumi信息{bangumiid} ...")
return self.bangumiapi.detail(bangumiid)
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
"""
获取Bangumi每日放送
:param page: 页码
:param count: 每页数量
"""
return self.bangumiapi.calendar(page, count)
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
"""
根据TMDBID查询电影演职员表
:param bangumiid: BangumiID
:param page: 页码
:param count: 数量
"""
persons = self.bangumiapi.persons(bangumiid) or []
if persons:
return persons[(page - 1) * count: page * count]
else:
return []
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
"""
根据BangumiID查询推荐电影
:param bangumiid: BangumiID
"""
return self.bangumiapi.subjects(bangumiid) or []

View File

@@ -0,0 +1,165 @@
from datetime import datetime
from functools import lru_cache
import requests
from app.utils.http import RequestUtils
class BangumiApi(object):
"""
https://bangumi.github.io/api/
"""
_urls = {
"calendar": "calendar",
"detail": "v0/subjects/%s",
"persons": "v0/subjects/%s/persons",
"subjects": "v0/subjects/%s/subjects",
"characters": "v0/subjects/%s/characters"
}
_base_url = "https://api.bgm.tv/"
_req = RequestUtils(session=requests.Session())
def __init__(self):
pass
@classmethod
@lru_cache(maxsize=128)
def __invoke(cls, url, **kwargs):
req_url = cls._base_url + url
params = {}
if kwargs:
params.update(kwargs)
resp = cls._req.get_res(url=req_url, params=params)
try:
return resp.json() if resp else None
except Exception as e:
print(e)
return None
def calendar(self, page: int = 1, count: int = 30):
"""
获取每日放送返回items
"""
"""
[
{
"weekday": {
"en": "Mon",
"cn": "星期一",
"ja": "月耀日",
"id": 1
},
"items": [
{
"id": 350235,
"url": "http://bgm.tv/subject/350235",
"type": 2,
"name": "月が導く異世界道中 第二幕",
"name_cn": "月光下的异世界之旅 第二幕",
"summary": "",
"air_date": "2024-01-08",
"air_weekday": 1,
"rating": {
"total": 257,
"count": {
"1": 1,
"2": 1,
"3": 4,
"4": 15,
"5": 51,
"6": 111,
"7": 49,
"8": 13,
"9": 5,
"10": 7
},
"score": 6.1
},
"rank": 6125,
"images": {
"large": "http://lain.bgm.tv/pic/cover/l/3c/a5/350235_A0USf.jpg",
"common": "http://lain.bgm.tv/pic/cover/c/3c/a5/350235_A0USf.jpg",
"medium": "http://lain.bgm.tv/pic/cover/m/3c/a5/350235_A0USf.jpg",
"small": "http://lain.bgm.tv/pic/cover/s/3c/a5/350235_A0USf.jpg",
"grid": "http://lain.bgm.tv/pic/cover/g/3c/a5/350235_A0USf.jpg"
},
"collection": {
"doing": 920
}
},
{
"id": 358561,
"url": "http://bgm.tv/subject/358561",
"type": 2,
"name": "大宇宙时代",
"name_cn": "大宇宙时代",
"summary": "",
"air_date": "2024-01-22",
"air_weekday": 1,
"rating": {
"total": 2,
"count": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
"5": 1,
"6": 1,
"7": 0,
"8": 0,
"9": 0,
"10": 0
},
"score": 5.5
},
"images": {
"large": "http://lain.bgm.tv/pic/cover/l/71/66/358561_UzsLu.jpg",
"common": "http://lain.bgm.tv/pic/cover/c/71/66/358561_UzsLu.jpg",
"medium": "http://lain.bgm.tv/pic/cover/m/71/66/358561_UzsLu.jpg",
"small": "http://lain.bgm.tv/pic/cover/s/71/66/358561_UzsLu.jpg",
"grid": "http://lain.bgm.tv/pic/cover/g/71/66/358561_UzsLu.jpg"
},
"collection": {
"doing": 9
}
}
]
}
]
"""
ret_list = []
result = self.__invoke(self._urls["calendar"], _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
if result:
for item in result:
ret_list.extend(item.get("items") or [])
return ret_list[(page - 1) * count: page * count]
def detail(self, bid: int):
"""
获取番剧详情
"""
return self.__invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
def persons(self, bid: int):
"""
获取番剧人物
"""
ret_list = []
result = self.__invoke(self._urls["characters"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
if result:
for item in result:
character_id = item.get("id")
actors = item.get("actors")
if character_id and actors and actors[0]:
actor_info = actors[0]
actor_info.update({'career': [item.get('name')]})
ret_list.append(actor_info)
return ret_list
def subjects(self, bid: int):
"""
获取关联条目信息
"""
return self.__invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))

View File

@@ -2,6 +2,8 @@ import re
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple, Union from typing import List, Optional, Tuple, Union
import cn2an
from app.core.config import settings from app.core.config import settings
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.meta import MetaBase from app.core.meta import MetaBase
@@ -13,6 +15,7 @@ from app.modules.douban.douban_cache import DoubanCache
from app.modules.douban.scraper import DoubanScraper from app.modules.douban.scraper import DoubanScraper
from app.schemas.types import MediaType from app.schemas.types import MediaType
from app.utils.common import retry from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils from app.utils.system import SystemUtils
@@ -27,7 +30,18 @@ class DoubanModule(_ModuleBase):
self.cache = DoubanCache() self.cache = DoubanCache()
def stop(self): def stop(self):
pass self.doubanapi.close()
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
with RequestUtils().get_res("https://movie.douban.com/") as ret:
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
return False, "豆瓣网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass pass
@@ -35,52 +49,71 @@ class DoubanModule(_ModuleBase):
def recognize_media(self, meta: MetaBase = None, def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None, mtype: MediaType = None,
doubanid: str = None, doubanid: str = None,
cache: bool = True,
**kwargs) -> Optional[MediaInfo]: **kwargs) -> Optional[MediaInfo]:
""" """
识别媒体信息 识别媒体信息
:param meta: 识别的元数据 :param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套 :param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID :param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息 :return: 识别的媒体信息,包括剧集信息
""" """
if settings.RECOGNIZE_SOURCE != "douban": if not doubanid and not meta:
return None
if meta and not doubanid \
and settings.RECOGNIZE_SOURCE != "douban":
return None return None
if not meta: if not meta:
# 未提供元数据时,直接查询豆瓣信息,不使用缓存
cache_info = {} cache_info = {}
elif not meta.name:
logger.error("识别媒体信息时未提供元数据名称")
return None
else: else:
# 读取缓存
if mtype: if mtype:
meta.type = mtype meta.type = mtype
if doubanid:
meta.doubanid = doubanid
cache_info = self.cache.get(meta) cache_info = self.cache.get(meta)
if not cache_info:
# 识别豆瓣信息
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存 # 缓存没有或者强制不使用缓存
if doubanid: if doubanid:
# 直接查询详情 # 直接查询详情
info = self.douban_info(doubanid=doubanid, mtype=mtype or meta.type) info = self.douban_info(doubanid=doubanid, mtype=mtype or meta.type)
elif meta: elif meta:
if meta.begin_season: info = {}
logger.info(f"正在识别 {meta.name}{meta.begin_season}季 ...") # 使用中英文名分别识别,去重去空,但要保持顺序
else: names = list(dict.fromkeys([k for k in [meta.cn_name, meta.en_name] if k]))
logger.info(f"正在识别 {meta.name} ...") for name in names:
# 匹配豆瓣信息 if meta.begin_season:
match_info = self.match_doubaninfo(name=meta.name, logger.info(f"正在识别 {name}{meta.begin_season}季 ...")
mtype=mtype or meta.type, else:
year=meta.year, logger.info(f"正在识别 {name} ...")
season=meta.begin_season) # 匹配豆瓣信息
if match_info: match_info = self.match_doubaninfo(name=name,
# 匹配到豆瓣信息 mtype=mtype or meta.type,
info = self.douban_info( year=meta.year,
doubanid=match_info.get("id"), season=meta.begin_season)
mtype=mtype or meta.type if match_info:
) # 匹配到豆瓣信息
else: info = self.douban_info(
logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息") doubanid=match_info.get("id"),
return None mtype=mtype or meta.type
)
if info:
break
else: else:
logger.error("识别媒体信息时未提供元数据或豆瓣ID") logger.error("识别媒体信息时未提供元数据或豆瓣ID")
return None return None
# 保存到缓存 # 保存到缓存
if meta: if meta and cache:
self.cache.update(meta, info) self.cache.update(meta, info)
else: else:
# 使用缓存信息 # 使用缓存信息
@@ -520,7 +553,14 @@ class DoubanModule(_ModuleBase):
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value): if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue continue
ret_medias.append(MediaInfo(douban_info=item_obj.get("target"))) ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
# 将搜索词中的季写入标题中
if ret_medias and meta.begin_season:
# 小写数据转大写
season_str = cn2an.an2cn(meta.begin_season, "low")
for media in ret_medias:
if media.type == MediaType.TV:
media.title = f"{media.title}{season_str}"
media.season = meta.begin_season
return ret_medias return ret_medias
@retry(Exception, 5, 3, 3, logger=logger) @retry(Exception, 5, 3, 3, logger=logger)

View File

@@ -210,7 +210,7 @@ class DoubanApi(metaclass=Singleton):
}, },
data={ data={
"apikey": "0ab215a8b1977939201640fa14c66bab", "apikey": "0ab215a8b1977939201640fa14c66bab",
}, }
) )
""" """
req_url = self._api_url + url req_url = self._api_url + url
@@ -481,6 +481,6 @@ class DoubanApi(metaclass=Singleton):
""" """
self.__invoke.cache_clear() self.__invoke.cache_clear()
def __del__(self): def close(self):
if self._session: if self._session:
self._session.close() self._session.close()

View File

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

View File

@@ -1,4 +1,3 @@
import time
from pathlib import Path from pathlib import Path
from typing import Union from typing import Union
from xml.dom import minidom from xml.dom import minidom
@@ -31,6 +30,9 @@ class DoubanScraper:
:param force_img: 强制生成图片 :param force_img: 强制生成图片
""" """
if not mediainfo or not file_path:
return
self._transfer_type = transfer_type self._transfer_type = transfer_type
self._force_nfo = force_nfo self._force_nfo = force_nfo
self._force_img = force_img self._force_img = force_img
@@ -83,10 +85,6 @@ class DoubanScraper:
@staticmethod @staticmethod
def __gen_common_nfo(mediainfo: MediaInfo, doc, root): def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
# 添加时间
DomUtils.add_node(doc, root, "dateadded",
time.strftime('%Y-%m-%d %H:%M:%S',
time.localtime(time.time())))
# 简介 # 简介
xplot = DomUtils.add_node(doc, root, "plot") xplot = DomUtils.add_node(doc, root, "plot")
xplot.appendChild(doc.createCDATASection(mediainfo.overview or "")) xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
@@ -166,8 +164,6 @@ class DoubanScraper:
logger.info(f"正在生成季NFO文件{season_path.name}") logger.info(f"正在生成季NFO文件{season_path.name}")
doc = minidom.Document() doc = minidom.Document()
root = DomUtils.add_node(doc, doc, "season") root = DomUtils.add_node(doc, doc, "season")
# 添加时间
DomUtils.add_node(doc, root, "dateadded", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
# 简介 # 简介
xplot = DomUtils.add_node(doc, root, "plot") xplot = DomUtils.add_node(doc, root, "plot")
xplot.appendChild(doc.createCDATASection(mediainfo.overview or "")) xplot.appendChild(doc.createCDATASection(mediainfo.overview or ""))
@@ -197,15 +193,15 @@ class DoubanScraper:
url = url.replace("/format/webp", "/format/jpg") url = url.replace("/format/webp", "/format/jpg")
file_path.with_suffix(".jpg") file_path.with_suffix(".jpg")
logger.info(f"正在下载{file_path.stem}图片:{url} ...") logger.info(f"正在下载{file_path.stem}图片:{url} ...")
r = RequestUtils().get_res(url=url) with RequestUtils().get_res(url=url) as r:
if r: if r:
if self._transfer_type in ['rclone_move', 'rclone_copy']: if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content) self.__save_remove_file(file_path, r.content)
else:
file_path.write_bytes(r.content)
logger.info(f"图片已保存:{file_path}")
else: else:
file_path.write_bytes(r.content) logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
logger.info(f"图片已保存:{file_path}")
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
except Exception as err: except Exception as err:
logger.error(f"{file_path.stem}图片下载失败:{str(err)}") logger.error(f"{file_path.stem}图片下载失败:{str(err)}")

View File

@@ -17,6 +17,16 @@ class EmbyModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.emby.is_inactive():
self.emby.reconnect()
if not self.emby.get_user():
return False, "无法连接Emby请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "emby" return "MEDIASERVER", "emby"
@@ -103,13 +113,13 @@ class EmbyModule(_ModuleBase):
media_statistic.user_count = self.emby.get_user_count() media_statistic.user_count = self.emby.get_user_count()
return [media_statistic] 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 None
return self.emby.get_librarys() return self.emby.get_librarys(username)
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]: def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
""" """
@@ -141,3 +151,29 @@ class EmbyModule(_ModuleBase):
season=season, season=season,
episodes=episodes episodes=episodes
) for season, episodes in seasoninfo.items()] ) 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 json
import re import re
import traceback
from pathlib import Path from pathlib import Path
from typing import List, Optional, Union, Dict, Generator, Tuple from typing import List, Optional, Union, Dict, Generator, Tuple
@@ -10,10 +11,9 @@ from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas.types import MediaType from app.schemas.types import MediaType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class Emby(metaclass=Singleton): class Emby:
def __init__(self): def __init__(self):
self._host = settings.EMBY_HOST self._host = settings.EMBY_HOST
@@ -22,9 +22,16 @@ class Emby(metaclass=Singleton):
self._host += "/" self._host += "/"
if not self._host.startswith("http"): if not self._host.startswith("http"):
self._host = "http://" + self._host 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._apikey = settings.EMBY_API_KEY
self.user = self.get_user(settings.SUPERUSER) self.user = self.get_user(settings.SUPERUSER)
self.folders = self.get_emby_folders() self.folders = self.get_emby_folders()
self.serverid = self.get_server_id()
def is_inactive(self) -> bool: def is_inactive(self) -> bool:
""" """
@@ -49,42 +56,84 @@ class Emby(metaclass=Singleton):
return [] return []
req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey) req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return res.json() return res.json()
else: else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据") logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return [] return []
except Exception as e: except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e)) logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return [] 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:
with RequestUtils().get_res(req_url) as res:
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媒体库列表 获取Emby媒体库列表
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return [] 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: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return res.json().get("Items") return res.json().get("Items")
else: else:
logger.error(f"User/Views 未获取到返回数据") logger.error(f"User/Views 未获取到返回数据")
return [] return []
except Exception as e: except Exception as e:
logger.error(f"连接User/Views 出错:" + str(e)) logger.error(f"连接User/Views 出错:" + str(e))
return [] 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: if not self._host or not self._apikey:
return [] return []
libraries = [] 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"): match library.get("CollectionType"):
case "movies": case "movies":
library_type = MediaType.MOVIE.value library_type = MediaType.MOVIE.value
@@ -92,13 +141,17 @@ class Emby(metaclass=Singleton):
library_type = MediaType.TV.value library_type = MediaType.TV.value
case _: case _:
continue continue
image = self.__get_local_image_by_id(library.get("Id"))
libraries.append( libraries.append(
schemas.MediaServerLibrary( schemas.MediaServerLibrary(
server="emby", server="emby",
id=library.get("Id"), id=library.get("Id"),
name=library.get("Name"), name=library.get("Name"),
path=library.get("Path"), 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 return libraries
@@ -111,20 +164,20 @@ class Emby(metaclass=Singleton):
return None return None
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey) req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
users = res.json() users = res.json()
# 先查询是否有与当前用户名称匹配的 # 先查询是否有与当前用户名称匹配的
if user_name: if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
# 查询管理员
for user in users: for user in users:
if user.get("Name") == user_name: if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id") return user.get("Id")
# 查询管理员 else:
for user in users: logger.error(f"Users 未获取到返回数据")
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users出错" + str(e)) logger.error(f"连接Users出错" + str(e))
return None return None
@@ -174,11 +227,11 @@ class Emby(metaclass=Singleton):
return None return None
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey) req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return res.json().get("Id") return res.json().get("Id")
else: else:
logger.error(f"System/Info 未获取到返回数据") logger.error(f"System/Info 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接System/Info出错" + str(e)) logger.error(f"连接System/Info出错" + str(e))
@@ -192,12 +245,12 @@ class Emby(metaclass=Singleton):
return 0 return 0
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey) req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return res.json().get("TotalRecordCount") return res.json().get("TotalRecordCount")
else: else:
logger.error(f"Users/Query 未获取到返回数据") logger.error(f"Users/Query 未获取到返回数据")
return 0 return 0
except Exception as e: except Exception as e:
logger.error(f"连接Users/Query出错" + str(e)) logger.error(f"连接Users/Query出错" + str(e))
return 0 return 0
@@ -211,17 +264,17 @@ class Emby(metaclass=Singleton):
return schemas.Statistic() return schemas.Statistic()
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey) req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
result = res.json() result = res.json()
return schemas.Statistic( return schemas.Statistic(
movie_count=result.get("MovieCount") or 0, movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0, tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0 episode_count=result.get("EpisodeCount") or 0
) )
else: else:
logger.error(f"Items/Counts 未获取到返回数据") logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic() return schemas.Statistic()
except Exception as e: except Exception as e:
logger.error(f"连接Items/Counts出错" + str(e)) logger.error(f"连接Items/Counts出错" + str(e))
return schemas.Statistic() return schemas.Statistic()
@@ -246,14 +299,14 @@ class Emby(metaclass=Singleton):
"&api_key=%s") % ( "&api_key=%s") % (
self._host, name, self._apikey) self._host, name, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
res_items = res.json().get("Items") res_items = res.json().get("Items")
if res_items: if res_items:
for res_item in res_items: for res_item in res_items:
if res_item.get('Name') == name and ( if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)): not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id') return res_item.get('Id')
except Exception as e: except Exception as e:
logger.error(f"连接Items出错" + str(e)) logger.error(f"连接Items出错" + str(e))
return None return None
@@ -276,36 +329,36 @@ class Emby(metaclass=Singleton):
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % ( "&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
self._host, title, self._apikey) self._host, title, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
res_items = res.json().get("Items") res_items = res.json().get("Items")
if res_items: if res_items:
ret_movies = [] ret_movies = []
for res_item in res_items: for res_item in res_items:
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb") item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem( mediaserver_item = schemas.MediaServerItem(
server="emby", server="emby",
library=res_item.get("ParentId"), library=res_item.get("ParentId"),
item_id=res_item.get("Id"), item_id=res_item.get("Id"),
item_type=res_item.get("Type"), item_type=res_item.get("Type"),
title=res_item.get("Name"), title=res_item.get("Name"),
original_title=res_item.get("OriginalTitle"), original_title=res_item.get("OriginalTitle"),
year=res_item.get("ProductionYear"), year=res_item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None, tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=res_item.get("ProviderIds", {}).get("Imdb"), imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"), tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
path=res_item.get("Path") path=res_item.get("Path")
) )
if tmdb_id and item_tmdbid: if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id): if str(item_tmdbid) != str(tmdb_id):
continue continue
else: else:
ret_movies.append(mediaserver_item)
continue
if (mediaserver_item.title == title
and (not year or str(mediaserver_item.year) == str(year))):
ret_movies.append(mediaserver_item) ret_movies.append(mediaserver_item)
continue return ret_movies
if (mediaserver_item.title == title
and (not year or str(mediaserver_item.year) == str(year))):
ret_movies.append(mediaserver_item)
return ret_movies
except Exception as e: except Exception as e:
logger.error(f"连接Items出错" + str(e)) logger.error(f"连接Items出错" + str(e))
return None return None
@@ -348,25 +401,25 @@ class Emby(metaclass=Singleton):
try: try:
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % ( req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
self._host, item_id, season, self._apikey) self._host, item_id, season, self._apikey)
res_json = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res_json:
if res_json: if res_json:
tv_item = res_json.json() tv_item = res_json.json()
res_items = tv_item.get("Items") res_items = tv_item.get("Items")
season_episodes = {} season_episodes = {}
for res_item in res_items: for res_item in res_items:
season_index = res_item.get("ParentIndexNumber") season_index = res_item.get("ParentIndexNumber")
if not season_index: if not season_index:
continue continue
if season and season != season_index: if season and season != season_index:
continue continue
episode_index = res_item.get("IndexNumber") episode_index = res_item.get("IndexNumber")
if not episode_index: if not episode_index:
continue continue
if season_index not in season_episodes: if season_index not in season_episodes:
season_episodes[season_index] = [] season_episodes[season_index] = []
season_episodes[season_index].append(episode_index) season_episodes[season_index].append(episode_index)
# 返回 # 返回
return item_id, season_episodes return item_id, season_episodes
except Exception as e: except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e)) logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None return None, None
@@ -383,20 +436,45 @@ class Emby(metaclass=Singleton):
return None return None
req_url = "%semby/Items/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey) req_url = "%semby/Items/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) res = RequestUtils(timeout=10).get_res(req_url)
if res: if res:
images = res.json().get("Images") images = res.json().get("Images")
for image in images: if images:
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type: for image in images:
return image.get("Url") logger.info(image)
else: if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
logger.error(f"Items/RemoteImages 未获取到返回数据") return image.get("Url")
return None # 数据为空
logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
return self.generate_external_image_link(item_id, image_type)
except Exception as e: except Exception as e:
logger.error(f"连接Items/Id/RemoteImages出错" + str(e)) logger.error(f"连接Items/Id/RemoteImages出错" + str(e))
return None
return None return None
def generate_external_image_link(self, item_id: str, image_type: str) -> Optional[str]:
"""
根据ItemId和imageType查询本地对应图片
:param item_id: 在Emby中的ID
:param image_type: 图片类型如Backdrop、Primary
:return: 图片对应在外网播放器中的URL
"""
if not self._playhost:
logger.error("Emby外网播放地址未能获取或为空")
return None
req_url = "%sItems/%s/Images/%s" % (self._playhost, item_id, image_type)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code != 404:
logger.info("影片图片链接:{}".format(res.url))
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
except Exception as e:
logger.error(f"连接Items/Id/Images出错" + str(e))
return None
def __refresh_emby_library_by_id(self, item_id: str) -> bool: def __refresh_emby_library_by_id(self, item_id: str) -> bool:
""" """
通知Emby刷新一个项目的媒体库 通知Emby刷新一个项目的媒体库
@@ -405,11 +483,11 @@ class Emby(metaclass=Singleton):
return False return False
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey) req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
try: try:
res = RequestUtils().post_res(req_url) with RequestUtils().post_res(req_url) as res:
if res: if res:
return True return True
else: else:
logger.info(f"刷新媒体库对象 {item_id} 失败无法连接Emby") logger.info(f"刷新媒体库对象 {item_id} 失败无法连接Emby")
except Exception as e: except Exception as e:
logger.error(f"连接Items/Id/Refresh出错" + str(e)) logger.error(f"连接Items/Id/Refresh出错" + str(e))
return False return False
@@ -423,11 +501,11 @@ class Emby(metaclass=Singleton):
return False return False
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey) req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().post_res(req_url) with RequestUtils().post_res(req_url) as res:
if res: if res:
return True return True
else: else:
logger.info(f"刷新媒体库失败无法连接Emby") logger.info(f"刷新媒体库失败无法连接Emby")
except Exception as e: except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e)) logger.error(f"连接Library/Refresh出错" + str(e))
return False return False
@@ -482,7 +560,7 @@ class Emby(metaclass=Singleton):
if item_path.is_relative_to(subfolder_path): if item_path.is_relative_to(subfolder_path):
return folder.get("Id") return folder.get("Id")
except Exception as err: except Exception as err:
print(str(err)) logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}")
# 如果找不到,只要路径中有分类目录名就命中 # 如果找不到,只要路径中有分类目录名就命中
for folder in self.folders: for folder in self.folders:
for subfolder in folder.get("SubFolders"): for subfolder in folder.get("SubFolders"):
@@ -502,23 +580,23 @@ class Emby(metaclass=Singleton):
return None return None
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey) req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200: if res and res.status_code == 200:
item = res.json() item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb") tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem( return schemas.MediaServerItem(
server="emby", server="emby",
library=item.get("ParentId"), library=item.get("ParentId"),
item_id=item.get("Id"), item_id=item.get("Id"),
item_type=item.get("Type"), item_type=item.get("Type"),
title=item.get("Name"), title=item.get("Name"),
original_title=item.get("OriginalTitle"), original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"), year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None, tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"), imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path") path=item.get("Path")
) )
except Exception as e: except Exception as e:
logger.error(f"连接Items/Id出错" + str(e)) logger.error(f"连接Items/Id出错" + str(e))
return None return None
@@ -533,17 +611,17 @@ class Emby(metaclass=Singleton):
yield None yield None
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey) req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200: if res and res.status_code == 200:
results = res.json().get("Items") or [] results = res.json().get("Items") or []
for result in results: for result in results:
if not result: if not result:
continue continue
if result.get("Type") in ["Movie", "Series"]: if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id")) yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"): elif "Folder" in result.get("Type"):
for item in self.get_items(parent=result.get('Id')): for item in self.get_items(parent=result.get('Id')):
yield item yield item
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items出错" + str(e)) logger.error(f"连接Users/Items出错" + str(e))
yield None yield None
@@ -879,9 +957,9 @@ class Emby(metaclass=Singleton):
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return None return None
url = url.replace("[HOST]", self._host) \ url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey) \ .replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user) .replace("[USER]", self.user or '')
try: try:
return RequestUtils(content_type="application/json").get_res(url=url) return RequestUtils(content_type="application/json").get_res(url=url)
except Exception as e: except Exception as e:
@@ -897,9 +975,9 @@ class Emby(metaclass=Singleton):
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return None return None
url = url.replace("[HOST]", self._host) \ url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey) \ .replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user) .replace("[USER]", self.user or '')
try: try:
return RequestUtils( return RequestUtils(
headers=headers, headers=headers,
@@ -907,3 +985,160 @@ class Emby(metaclass=Singleton):
except Exception as e: except Exception as e:
logger.error(f"连接Emby出错" + str(e)) logger.error(f"连接Emby出错" + str(e))
return None 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:
with RequestUtils().get_res(req_url) as res:
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:
with RequestUtils().get_res(req_url) as res:
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

@@ -317,6 +317,17 @@ class FanartModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
with RequestUtils().get_res("https://webservice.fanart.tv") as ret:
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接fanart错误码{ret.status_code}"
return False, "fanart网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FANART_API_KEY", True return "FANART_API_KEY", True
@@ -326,6 +337,8 @@ class FanartModule(_ModuleBase):
:param mediainfo: 识别的媒体信息 :param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息 :return: 更新后的媒体信息
""" """
if not settings.FANART_ENABLE:
return None
if not mediainfo.tmdb_id and not mediainfo.tvdb_id: if not mediainfo.tmdb_id and not mediainfo.tvdb_id:
return None return None
if mediainfo.type == MediaType.MOVIE: if mediainfo.type == MediaType.MOVIE:

View File

@@ -27,6 +27,40 @@ class FileTransferModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if not settings.DOWNLOAD_PATH:
return False, "下载目录未设置"
# 检查下载目录
download_paths: List[str] = []
for path in [settings.DOWNLOAD_PATH,
settings.DOWNLOAD_MOVIE_PATH,
settings.DOWNLOAD_TV_PATH,
settings.DOWNLOAD_ANIME_PATH]:
if not path:
continue
download_path = Path(path)
if not download_path.exists():
return False, f"下载目录 {download_path} 不存在"
download_paths.append(path)
# 下载目录的设备ID
download_devids = [Path(path).stat().st_dev for path in download_paths]
# 检查媒体库目录
if not settings.LIBRARY_PATH:
return False, "媒体库目录未设置"
# 比较媒体库目录的设备ID
for path in settings.LIBRARY_PATHS:
library_path = Path(path)
if not library_path.exists():
return False, f"媒体库目录不存在:{library_path}"
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
if library_path.stat().st_dev not in download_devids:
return False, f"媒体库目录 {library_path} " \
f"与下载目录 {','.join(download_paths)} 不在同一设备,将无法硬链接"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass pass
@@ -171,6 +205,8 @@ class FileTransferModule(_ModuleBase):
if (org_path.stem == Path(sub_file_name).stem) or \ if (org_path.stem == Path(sub_file_name).stem) or \
(sub_metainfo.cn_name and sub_metainfo.cn_name == metainfo.cn_name) \ (sub_metainfo.cn_name and sub_metainfo.cn_name == metainfo.cn_name) \
or (sub_metainfo.en_name and sub_metainfo.en_name == metainfo.en_name): or (sub_metainfo.en_name and sub_metainfo.en_name == metainfo.en_name):
if metainfo.part and metainfo.part != sub_metainfo.part:
continue
if metainfo.season \ if metainfo.season \
and metainfo.season != sub_metainfo.season: and metainfo.season != sub_metainfo.season:
continue continue
@@ -319,7 +355,7 @@ class FileTransferModule(_ModuleBase):
:param transfer_type: RmtMode转移方式 :param transfer_type: RmtMode转移方式
:param over_flag: 是否覆盖为True时会先删除再转移 :param over_flag: 是否覆盖为True时会先删除再转移
""" """
if new_file.exists(): if new_file.exists() or new_file.is_symlink():
if not over_flag: if not over_flag:
logger.warn(f"文件已存在:{new_file}") logger.warn(f"文件已存在:{new_file}")
return 0 return 0
@@ -486,37 +522,46 @@ class FileTransferModule(_ModuleBase):
# 判断是否要覆盖 # 判断是否要覆盖
overflag = False overflag = False
if new_file.exists(): target_file = new_file
# 目标文件已存在 if new_file.exists() or new_file.is_symlink():
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}") if new_file.is_symlink():
match settings.OVERWRITE_MODE: target_file = new_file.readlink()
case 'always': if not target_file.exists():
# 总是覆盖同名文件
overflag = True overflag = True
case 'size': if not overflag:
# 存在时大覆盖小 # 目标文件已存在
if new_file.stat().st_size < in_path.stat().st_size: logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
logger.info(f"目标文件文件大小更小,将被覆盖:{new_file}") match settings.OVERWRITE_MODE:
case 'always':
# 总是覆盖同名文件
overflag = True overflag = True
else: case 'size':
# 存在时大覆盖小
if target_file.stat().st_size < in_path.stat().st_size:
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
overflag = True
else:
return TransferInfo(success=False,
message=f"媒体库中已存在,且质量更好",
path=in_path,
target_path=new_file,
fail_list=[str(in_path)])
case 'never':
# 存在不覆盖
return TransferInfo(success=False, return TransferInfo(success=False,
message=f"媒体库中已存在,且质量更好", message=f"媒体库中已存在,当前设置为不覆盖",
path=in_path, path=in_path,
target_path=new_file, target_path=new_file,
fail_list=[str(in_path)]) fail_list=[str(in_path)])
case 'never': case 'latest':
# 存在不覆盖 # 仅保留最新版本
return TransferInfo(success=False, logger.info(f"仅保留最新版本,将覆盖:{new_file}")
message=f"媒体库中已存在,当前设置为不覆盖", overflag = True
path=in_path, else:
target_path=new_file, if settings.OVERWRITE_MODE == 'latest':
fail_list=[str(in_path)]) # 文件不存在,但仅保留最新版本
case 'latest': logger.info(f"转移覆盖模式:{settings.OVERWRITE_MODE},仅保留最新版本")
# 仅保留最新版本 self.delete_all_version_files(new_file)
self.delete_all_version_files(new_file)
overflag = True
case _:
pass
# 原文件大小 # 原文件大小
file_size = in_path.stat().st_size file_size = in_path.stat().st_size
# 转移文件 # 转移文件
@@ -552,6 +597,20 @@ class FileTransferModule(_ModuleBase):
:param file_ext: 文件扩展名 :param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息 :param episodes_info: 当前季的全部集信息
""" """
def __convert_invalid_characters(filename: str):
if not filename:
return filename
invalid_characters = r'\/:*?"<>|'
# 创建半角到全角字符的转换表
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
# 将不支持的字符替换为对应的全角字符
for char in invalid_characters:
filename = filename.replace(char, char.translate(translation_table))
return filename
# 获取集标题 # 获取集标题
episode_title = None episode_title = None
if meta.begin_episode and episodes_info: if meta.begin_episode and episodes_info:
@@ -562,9 +621,11 @@ class FileTransferModule(_ModuleBase):
return { return {
# 标题 # 标题
"title": mediainfo.title, "title": __convert_invalid_characters(mediainfo.title),
# 英文标题
"en_title": __convert_invalid_characters(mediainfo.en_title),
# 原语种标题 # 原语种标题
"original_title": mediainfo.original_title, "original_title": __convert_invalid_characters(mediainfo.original_title),
# 原文件名 # 原文件名
"original_name": f"{meta.org_string}{file_ext}", "original_name": f"{meta.org_string}{file_ext}",
# 识别名称(优先使用中文) # 识别名称(优先使用中文)
@@ -602,7 +663,7 @@ class FileTransferModule(_ModuleBase):
# 段/节 # 段/节
"part": meta.part, "part": meta.part,
# 剧集标题 # 剧集标题
"episode_title": episode_title, "episode_title": __convert_invalid_characters(episode_title),
# 文件后缀 # 文件后缀
"fileExt": file_ext, "fileExt": file_ext,
# 自定义占位符 # 自定义占位符
@@ -665,7 +726,11 @@ class FileTransferModule(_ModuleBase):
try: try:
# 计算in_path和path的公共字符串长度 # 计算in_path和path的公共字符串长度
relative = StringUtils.find_common_prefix(str(in_path), str(path)) relative = StringUtils.find_common_prefix(str(in_path), str(path))
if len(str(path)) == len(relative):
# 目录完整匹配的,直接返回
return path
if len(relative) > max_length: if len(relative) > max_length:
# 更新最大长度
max_length = len(relative) max_length = len(relative)
target_path = path target_path = path
except Exception as e: except Exception as e:

View File

@@ -39,12 +39,19 @@ class FilterModule(_ModuleBase):
# 中字 # 中字
"CNSUB": { "CNSUB": {
"include": [ "include": [
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'], r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]'
r'|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体'
r'|([\s,.-\[])(CHT|CHS|cht|chs)(|[\s,.-\]])'],
"exclude": [], "exclude": [],
"tmdb": { "tmdb": {
"original_language": "zh,cn" "original_language": "zh,cn"
} }
}, },
# 官种
"GZ": {
"include": [r'官方', r'官种'],
"match": ["labels"]
},
# 特效字幕 # 特效字幕
"SPECSUB": { "SPECSUB": {
"include": [r'特效'], "include": [r'特效'],
@@ -132,6 +139,9 @@ class FilterModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self):
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass pass
@@ -253,14 +263,30 @@ class FilterModule(_ModuleBase):
# 符合TMDB规则的直接返回True即不过滤 # 符合TMDB规则的直接返回True即不过滤
if tmdb and self.__match_tmdb(tmdb): if tmdb and self.__match_tmdb(tmdb):
return True return True
# 匹配项:标题、副标题、标签
content = f"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}"
# 只匹配指定关键字
match_content = []
matchs = self.rule_set[rule_name].get("match") or []
if matchs:
for match in matchs:
if not hasattr(torrent, match):
continue
match_value = getattr(torrent, match)
if not match_value:
continue
if isinstance(match_value, list):
match_content.extend(match_value)
else:
match_content.append(match_value)
if match_content:
content = " ".join(match_content)
# 包含规则项 # 包含规则项
includes = self.rule_set[rule_name].get("include") or [] includes = self.rule_set[rule_name].get("include") or []
# 排除规则项 # 排除规则项
excludes = self.rule_set[rule_name].get("exclude") or [] excludes = self.rule_set[rule_name].get("exclude") or []
# FREE规则 # FREE规则
downloadvolumefactor = self.rule_set[rule_name].get("downloadvolumefactor") downloadvolumefactor = self.rule_set[rule_name].get("downloadvolumefactor")
# 匹配项
content = f"{torrent.title} {torrent.description} {' '.join(torrent.labels or [])}"
for include in includes: for include in includes:
if not re.search(r"%s" % include, content, re.IGNORECASE): if not re.search(r"%s" % include, content, re.IGNORECASE):
# 未发现包含项 # 未发现包含项

View File

@@ -3,7 +3,9 @@ from typing import List, Optional, Tuple, Union
from ruamel.yaml import CommentedMap from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.core.context import TorrentInfo from app.core.context import TorrentInfo
from app.helper.sites import SitesHelper
from app.log import logger from app.log import logger
from app.modules import _ModuleBase from app.modules import _ModuleBase
from app.modules.indexer.mtorrent import MTorrentSpider from app.modules.indexer.mtorrent import MTorrentSpider
@@ -25,6 +27,15 @@ class IndexerModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
sites = SitesHelper().get_indexers()
if not sites:
return False, "未配置站点或未通过用户认证"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "INDEXER", "builtin" return "INDEXER", "builtin"
@@ -40,10 +51,22 @@ class IndexerModule(_ModuleBase):
:param page: 页码 :param page: 页码
:return: 资源列表 :return: 资源列表
""" """
def __remove_duplicate(_torrents: List[TorrentInfo]) -> List[TorrentInfo]:
"""
去除重复的种子
:param _torrents: 种子列表
:return: 去重后的种子列表
"""
if not settings.SEARCH_MULTIPLE_NAME:
return _torrents
# 通过encosure去重
return list({f"{t.title}_{t.description}": t for t in _torrents}.values())
# 确认搜索的名字 # 确认搜索的名字
if not keywords: if not keywords:
# 浏览种子页 # 浏览种子页
keywords = [None] keywords = ['']
# 开始索引 # 开始索引
result_array = [] result_array = []
@@ -66,30 +89,38 @@ class IndexerModule(_ModuleBase):
try: try:
if site.get('parser') == "TNodeSpider": if site.get('parser') == "TNodeSpider":
error_flag, result_array = TNodeSpider(site).search( error_flag, result = TNodeSpider(site).search(
keyword=search_word, keyword=search_word,
page=page page=page
) )
elif site.get('parser') == "TorrentLeech": elif site.get('parser') == "TorrentLeech":
error_flag, result_array = TorrentLeech(site).search( error_flag, result = TorrentLeech(site).search(
keyword=search_word, keyword=search_word,
page=page page=page
) )
elif site.get('parser') == "mTorrent": elif site.get('parser') == "mTorrent":
error_flag, result_array = MTorrentSpider(site).search( error_flag, result = MTorrentSpider(site).search(
keyword=search_word, keyword=search_word,
mtype=mtype, mtype=mtype,
page=page page=page
) )
else: else:
error_flag, result_array = self.__spider_search( error_flag, result = self.__spider_search(
search_word=search_word, search_word=search_word,
indexer=site, indexer=site,
mtype=mtype, mtype=mtype,
page=page page=page
) )
# 有结果后停止 if error_flag:
if result_array: break
if not result:
continue
if settings.SEARCH_MULTIPLE_NAME:
# 合并多个结果
result_array.extend(result)
else:
# 有结果就停止
result_array = result
break break
except Exception as err: except Exception as err:
logger.error(f"{site.get('name')} 搜索出错:{str(err)}") logger.error(f"{site.get('name')} 搜索出错:{str(err)}")
@@ -103,14 +134,16 @@ class IndexerModule(_ModuleBase):
return [] return []
else: else:
logger.info(f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}") logger.info(f"{site.get('name')} 搜索完成,耗时 {seconds} 秒,返回数据:{len(result_array)}")
# 合并站点信息,以TorrentInfo返回 # TorrentInfo
return [TorrentInfo(site=site.get("id"), torrents = [TorrentInfo(site=site.get("id"),
site_name=site.get("name"), site_name=site.get("name"),
site_cookie=site.get("cookie"), site_cookie=site.get("cookie"),
site_ua=site.get("ua"), site_ua=site.get("ua"),
site_proxy=site.get("proxy"), site_proxy=site.get("proxy"),
site_order=site.get("pri"), site_order=site.get("pri"),
**result) for result in result_array] **result) for result in result_array]
# 去重
return __remove_duplicate(torrents)
@staticmethod @staticmethod
def __spider_search(indexer: CommentedMap, def __spider_search(indexer: CommentedMap,

View File

@@ -6,6 +6,7 @@ from typing import Tuple, List
from ruamel.yaml import CommentedMap from ruamel.yaml import CommentedMap
from app.core.config import settings from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger from app.log import logger
from app.schemas import MediaType from app.schemas import MediaType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
@@ -13,6 +14,9 @@ from app.utils.string import StringUtils
class MTorrentSpider: class MTorrentSpider:
"""
mTorrent API需要缓存ApiKey
"""
_indexerid = None _indexerid = None
_domain = None _domain = None
_name = "" _name = ""
@@ -28,14 +32,23 @@ class MTorrentSpider:
_movie_category = ['401', '419', '420', '421', '439', '405', '404'] _movie_category = ['401', '419', '420', '421', '439', '405', '404']
_tv_category = ['403', '402', '435', '438', '404', '405'] _tv_category = ['403', '402', '435', '438', '404', '405']
# API KEY
_apikey = None
# 标签 # 标签
_labels = { _labels = {
0: "", "0": "",
4: "中字", "1": "DIY",
6: "国配", "2": "国配",
"3": "DIY 国配",
"4": "中字",
"5": "DIY 中字",
"6": "国配 中字",
"7": "DIY 国配 中字"
} }
def __init__(self, indexer: CommentedMap): def __init__(self, indexer: CommentedMap):
self.systemconfig = SystemConfigOper()
if indexer: if indexer:
self._indexerid = indexer.get('id') self._indexerid = indexer.get('id')
self._domain = indexer.get('domain') self._domain = indexer.get('domain')
@@ -46,7 +59,51 @@ class MTorrentSpider:
self._cookie = indexer.get('cookie') self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua') self._ua = indexer.get('ua')
def __get_apikey(self) -> str:
"""
获取ApiKey
"""
domain_host = StringUtils.get_url_host(self._domain)
self._apikey = self.systemconfig.get(f"site.{domain_host}.apikey")
if not self._apikey:
try:
res = RequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}"
},
cookies=self._cookie,
ua=self._ua,
proxies=self._proxy,
referer=f"{self._domain}usercp?tab=laboratory",
timeout=15
).post_res(url=f"{self._domain}api/apikey/getKeyList")
if res and res.status_code == 200:
api_keys = res.json().get('data')
if api_keys:
logger.info(f"{self._name} 获取ApiKey成功")
# 按lastModifiedDate倒序排序
api_keys.sort(key=lambda x: x.get('lastModifiedDate'), reverse=True)
self._apikey = api_keys[0].get('apiKey')
self.systemconfig.set(f"site.{domain_host}.apikey", self._apikey)
else:
logger.warn(f"{self._name} 获取ApiKey失败请先在`控制台`->`实验室`建立存取令牌")
else:
logger.warn(f"{self._name} 获取ApiKey失败请检查Cookie是否有效")
except Exception as e:
logger.error(f"{self._name} 获取ApiKey出错{e}")
return self._apikey
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]: def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
"""
搜索
"""
# 检查ApiKey
self.__get_apikey()
if not self._apikey:
return True, []
if not mtype: if not mtype:
categories = [] categories = []
elif mtype == MediaType.TV: elif mtype == MediaType.TV:
@@ -63,31 +120,45 @@ class MTorrentSpider:
res = RequestUtils( res = RequestUtils(
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"User-Agent": f"{self._ua}" "User-Agent": f"{self._ua}",
"x-api-key": self._apikey
}, },
cookies=self._cookie,
proxies=self._proxy, proxies=self._proxy,
referer=f"{self._domain}browse", referer=f"{self._domain}browse",
timeout=30 timeout=15
).post_res(url=self._searchurl, json=params) ).post_res(url=self._searchurl, json=params)
torrents = [] torrents = []
if res and res.status_code == 200: if res and res.status_code == 200:
results = res.json().get('data', {}).get("data") or [] results = res.json().get('data', {}).get("data") or []
for result in results: for result in results:
category_value = result.get('category')
if category_value in self._tv_category \
and category_value not in self._movie_category:
category = MediaType.TV.value
elif category_value in self._movie_category:
category = MediaType.MOVIE.value
else:
category = MediaType.UNKNOWN.value
labels_value = self._labels.get(result.get('labels') or "0") or ""
if labels_value:
labels = labels_value.split()
else:
labels = []
torrent = { torrent = {
'title': result.get('name'), 'title': result.get('name'),
'description': result.get('smallDescr'), 'description': result.get('smallDescr'),
'enclosure': self.__get_download_url(result.get('id')), 'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.format_timestamp(result.get('createdDate')), 'pubdate': StringUtils.format_timestamp(result.get('createdDate')),
'size': result.get('size'), 'size': int(result.get('size') or '0'),
'seeders': result.get('status', {}).get("seeders"), 'seeders': int(result.get('status', {}).get("seeders") or '0'),
'peers': result.get('status', {}).get("leechers"), 'peers': int(result.get('status', {}).get("leechers") or '0'),
'grabs': result.get('status', {}).get("timesCompleted"), 'grabs': int(result.get('status', {}).get("timesCompleted") or '0'),
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('status', {}).get("discount")), 'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('status', {}).get("discount")),
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('status', {}).get("discount")), 'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('status', {}).get("discount")),
'page_url': self._pageurl % (self._domain, result.get('id')), 'page_url': self._pageurl % (self._domain, result.get('id')),
'imdbid': self.__find_imdbid(result.get('imdb')), 'imdbid': self.__find_imdbid(result.get('imdb')),
'labels': [self._labels.get(result.get('labels') or 0)] if result.get('labels') else [] 'labels': labels,
'category': category
} }
torrents.append(torrent) torrents.append(torrent)
elif res is not None: elif res is not None:
@@ -100,6 +171,9 @@ class MTorrentSpider:
@staticmethod @staticmethod
def __find_imdbid(imdb: str) -> str: def __find_imdbid(imdb: str) -> str:
"""
从imdb链接中提取imdbid
"""
if imdb: if imdb:
m = re.search(r"tt\d+", imdb) m = re.search(r"tt\d+", imdb)
if m: if m:
@@ -108,6 +182,9 @@ class MTorrentSpider:
@staticmethod @staticmethod
def __get_downloadvolumefactor(discount: str) -> float: def __get_downloadvolumefactor(discount: str) -> float:
"""
获取下载系数
"""
discount_dict = { discount_dict = {
"FREE": 0, "FREE": 0,
"PERCENT_50": 0.5, "PERCENT_50": 0.5,
@@ -121,6 +198,9 @@ class MTorrentSpider:
@staticmethod @staticmethod
def __get_uploadvolumefactor(discount: str) -> float: def __get_uploadvolumefactor(discount: str) -> float:
"""
获取上传系数
"""
uploadvolumefactor_dict = { uploadvolumefactor_dict = {
"_2X": 2.0, "_2X": 2.0,
"_2X_FREE": 2.0, "_2X_FREE": 2.0,
@@ -131,12 +211,22 @@ class MTorrentSpider:
return 1 return 1
def __get_download_url(self, torrent_id: str) -> str: def __get_download_url(self, torrent_id: str) -> str:
"""
获取下载链接返回base64编码的json字符串及URL
"""
url = self._downloadurl % self._domain url = self._downloadurl % self._domain
params = { params = {
'method': 'post', 'method': 'post',
'cookie': False,
'params': { 'params': {
'id': torrent_id 'id': torrent_id
}, },
'header': {
'Content-Type': 'application/json',
'User-Agent': f'{self._ua}',
'Accept': 'application/json, text/plain, */*',
'x-api-key': self._apikey
},
'result': 'data' 'result': 'data'
} }
# base64编码 # base64编码

View File

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

View File

@@ -77,7 +77,7 @@ class TNodeSpider:
}, },
cookies=self._cookie, cookies=self._cookie,
proxies=self._proxy, proxies=self._proxy,
timeout=30 timeout=15
).post_res(url=self._searchurl, json=params) ).post_res(url=self._searchurl, json=params)
torrents = [] torrents = []
if res and res.status_code == 200: if res and res.status_code == 200:

View File

@@ -40,7 +40,7 @@ class TorrentLeech:
}, },
cookies=self._indexer.get('cookie'), cookies=self._indexer.get('cookie'),
proxies=self._proxy, proxies=self._proxy,
timeout=30 timeout=15
).get_res(url) ).get_res(url)
torrents = [] torrents = []
if res and res.status_code == 200: if res and res.status_code == 200:

View File

@@ -28,6 +28,16 @@ class JellyfinModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.jellyfin.is_inactive():
self.jellyfin.reconnect()
if not self.jellyfin.get_user():
return False, "无法连接Jellyfin请检查参数配置"
return True, ""
def user_authenticate(self, name: str, password: str) -> Optional[str]: def user_authenticate(self, name: str, password: str) -> Optional[str]:
""" """
使用Emby用户辅助完成用户认证 使用Emby用户辅助完成用户认证
@@ -101,13 +111,13 @@ class JellyfinModule(_ModuleBase):
media_statistic.user_count = self.jellyfin.get_user_count() media_statistic.user_count = self.jellyfin.get_user_count()
return [media_statistic] 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 None
return self.jellyfin.get_librarys() return self.jellyfin.get_librarys(username)
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]: def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
""" """
@@ -139,3 +149,29 @@ class JellyfinModule(_ModuleBase):
season=season, season=season,
episodes=episodes episodes=episodes
) for season, episodes in seasoninfo.items()] ) 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

@@ -8,10 +8,9 @@ from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas import MediaType from app.schemas import MediaType
from app.utils.http import RequestUtils from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class Jellyfin(metaclass=Singleton): class Jellyfin:
def __init__(self): def __init__(self):
self._host = settings.JELLYFIN_HOST self._host = settings.JELLYFIN_HOST
@@ -20,6 +19,12 @@ class Jellyfin(metaclass=Singleton):
self._host += "/" self._host += "/"
if not self._host.startswith("http"): if not self._host.startswith("http"):
self._host = "http://" + self._host 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._apikey = settings.JELLYFIN_API_KEY
self.user = self.get_user(settings.SUPERUSER) self.user = self.get_user(settings.SUPERUSER)
self.serverid = self.get_server_id() self.serverid = self.get_server_id()
@@ -39,32 +44,92 @@ class Jellyfin(metaclass=Singleton):
self.user = self.get_user() self.user = self.get_user()
self.serverid = self.get_server_id() 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 = "%sLibrary/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
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?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
library_items = res.json()
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 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/VirtualFolders 出错:" + str(e))
return []
def __get_jellyfin_librarys(self, username: str = None) -> List[dict]:
""" """
获取Jellyfin媒体库的信息 获取Jellyfin媒体库的信息
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return [] 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: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return res.json().get("Items") return res.json().get("Items")
else: else:
logger.error(f"Users/Views 未获取到返回数据") logger.error(f"Users/Views 未获取到返回数据")
return [] return []
except Exception as e: except Exception as e:
logger.error(f"连接Users/Views 出错:" + str(e)) logger.error(f"连接Users/Views 出错:" + str(e))
return [] return []
def get_librarys(self): def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
""" """
获取媒体服务器所有媒体库列表 获取媒体服务器所有媒体库列表
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return [] return []
libraries = [] 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"): match library.get("CollectionType"):
case "movies": case "movies":
library_type = MediaType.MOVIE.value library_type = MediaType.MOVIE.value
@@ -72,13 +137,21 @@ class Jellyfin(metaclass=Singleton):
library_type = MediaType.TV.value library_type = MediaType.TV.value
case _: case _:
continue 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( libraries.append(
schemas.MediaServerLibrary( schemas.MediaServerLibrary(
server="jellyfin", server="jellyfin",
id=library.get("Id"), id=library.get("Id"),
name=library.get("Name"), name=library.get("Name"),
path=library.get("Path"), path=library.get("Path"),
type=library_type type=library_type,
image=image,
link=link
)) ))
return libraries return libraries
@@ -90,12 +163,12 @@ class Jellyfin(metaclass=Singleton):
return 0 return 0
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey) req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return len(res.json()) return len(res.json())
else: else:
logger.error(f"Users 未获取到返回数据") logger.error(f"Users 未获取到返回数据")
return 0 return 0
except Exception as e: except Exception as e:
logger.error(f"连接Users出错" + str(e)) logger.error(f"连接Users出错" + str(e))
return 0 return 0
@@ -108,20 +181,20 @@ class Jellyfin(metaclass=Singleton):
return None return None
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey) req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
users = res.json() users = res.json()
# 先查询是否有与当前用户名称匹配的 # 先查询是否有与当前用户名称匹配的
if user_name: if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
# 查询管理员
for user in users: for user in users:
if user.get("Name") == user_name: if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id") return user.get("Id")
# 查询管理员 else:
for user in users: logger.error(f"Users 未获取到返回数据")
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接Users出错" + str(e)) logger.error(f"连接Users出错" + str(e))
return None return None
@@ -171,11 +244,11 @@ class Jellyfin(metaclass=Singleton):
return None return None
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey) req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
return res.json().get("Id") return res.json().get("Id")
else: else:
logger.error(f"System/Info 未获取到返回数据") logger.error(f"System/Info 未获取到返回数据")
except Exception as e: except Exception as e:
logger.error(f"连接System/Info出错" + str(e)) logger.error(f"连接System/Info出错" + str(e))
return None return None
@@ -189,17 +262,17 @@ class Jellyfin(metaclass=Singleton):
return schemas.Statistic() return schemas.Statistic()
req_url = "%sItems/Counts?api_key=%s" % (self._host, self._apikey) req_url = "%sItems/Counts?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
result = res.json() result = res.json()
return schemas.Statistic( return schemas.Statistic(
movie_count=result.get("MovieCount") or 0, movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0, tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0 episode_count=result.get("EpisodeCount") or 0
) )
else: else:
logger.error(f"Items/Counts 未获取到返回数据") logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic() return schemas.Statistic()
except Exception as e: except Exception as e:
logger.error(f"连接Items/Counts出错" + str(e)) logger.error(f"连接Items/Counts出错" + str(e))
return schemas.Statistic() return schemas.Statistic()
@@ -214,14 +287,14 @@ class Jellyfin(metaclass=Singleton):
"api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true") % ( "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: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
res_items = res.json().get("Items") res_items = res.json().get("Items")
if res_items: if res_items:
for res_item in res_items: for res_item in res_items:
if res_item.get('Name') == name and ( if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)): not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id') return res_item.get('Id')
except Exception as e: except Exception as e:
logger.error(f"连接Items出错" + str(e)) logger.error(f"连接Items出错" + str(e))
return None return None
@@ -244,36 +317,36 @@ class Jellyfin(metaclass=Singleton):
"api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true") % ( "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: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res: if res:
res_items = res.json().get("Items") res_items = res.json().get("Items")
if res_items: if res_items:
ret_movies = [] ret_movies = []
for item in res_items: for item in res_items:
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb") item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem( mediaserver_item = schemas.MediaServerItem(
server="jellyfin", server="jellyfin",
library=item.get("ParentId"), library=item.get("ParentId"),
item_id=item.get("Id"), item_id=item.get("Id"),
item_type=item.get("Type"), item_type=item.get("Type"),
title=item.get("Name"), title=item.get("Name"),
original_title=item.get("OriginalTitle"), original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"), year=item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None, tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"), imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path") path=item.get("Path")
) )
if tmdb_id and item_tmdbid: if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id): if str(item_tmdbid) != str(tmdb_id):
continue continue
else: else:
ret_movies.append(mediaserver_item)
continue
if mediaserver_item.title == title and (
not year or str(mediaserver_item.year) == str(year)):
ret_movies.append(mediaserver_item) ret_movies.append(mediaserver_item)
continue return ret_movies
if mediaserver_item.title == title and (
not year or str(mediaserver_item.year) == str(year)):
ret_movies.append(mediaserver_item)
return ret_movies
except Exception as e: except Exception as e:
logger.error(f"连接Items出错" + str(e)) logger.error(f"连接Items出错" + str(e))
return None return None
@@ -314,25 +387,25 @@ class Jellyfin(metaclass=Singleton):
try: try:
req_url = "%sShows/%s/Episodes?season=%s&&userId=%s&isMissing=false&api_key=%s" % ( req_url = "%sShows/%s/Episodes?season=%s&&userId=%s&isMissing=false&api_key=%s" % (
self._host, item_id, season, self.user, self._apikey) self._host, item_id, season, self.user, self._apikey)
res_json = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res_json:
if res_json: if res_json:
tv_info = res_json.json() tv_info = res_json.json()
res_items = tv_info.get("Items") res_items = tv_info.get("Items")
# 返回的季集信息 # 返回的季集信息
season_episodes = {} season_episodes = {}
for res_item in res_items: for res_item in res_items:
season_index = res_item.get("ParentIndexNumber") season_index = res_item.get("ParentIndexNumber")
if not season_index: if not season_index:
continue continue
if season and season != season_index: if season and season != season_index:
continue continue
episode_index = res_item.get("IndexNumber") episode_index = res_item.get("IndexNumber")
if not episode_index: if not episode_index:
continue continue
if not season_episodes.get(season_index): if not season_episodes.get(season_index):
season_episodes[season_index] = [] season_episodes[season_index] = []
season_episodes[season_index].append(episode_index) season_episodes[season_index].append(episode_index)
return item_id, season_episodes return item_id, season_episodes
except Exception as e: except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e)) logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None return None, None
@@ -349,20 +422,73 @@ class Jellyfin(metaclass=Singleton):
return None return None
req_url = "%sItems/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey) req_url = "%sItems/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) res = RequestUtils(timeout=10).get_res(req_url)
if res: if res:
images = res.json().get("Images") images = res.json().get("Images")
for image in images: for image in images:
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type: if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
return image.get("Url") return image.get("Url")
# return images[0].get("Url") # 首选无则返回第一张
else: else:
logger.error(f"Items/RemoteImages 未获取到返回数据") logger.info(f"Items/RemoteImages 未获取到返回数据,采用本地图片")
return None return self.generate_image_link(item_id, image_type, True)
except Exception as e: except Exception as e:
logger.error(f"连接Items/Id/RemoteImages出错" + str(e)) logger.error(f"连接Items/Id/RemoteImages出错" + str(e))
return None return None
return None return None
def generate_image_link(self, item_id: str, image_type: str, host_type: bool) -> Optional[str]:
"""
根据ItemId和imageType查询本地对应图片
:param item_id: 在Jellyfin中的ID
:param image_type: 图片类型如Backdrop、Primary
:param host_type: True为外网链接, False为内网链接
:return: 图片对应在host_type的播放器中的URL
"""
if not self._playhost:
logger.error("Jellyfin外网播放地址未能获取或为空")
return None
# 检测是否为TV
_parent_id = self.get_itemId_ancestors(item_id, 0, "ParentBackdropItemId")
if _parent_id:
item_id = _parent_id
_host = self._host
if host_type:
_host = self._playhost
req_url = "%sItems/%s/Images/%s" % (_host, item_id, image_type)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code != 404:
logger.info("影片图片链接:{}".format(res.url))
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
except Exception as e:
logger.error(f"连接Items/Id/Images出错" + str(e))
return None
def get_itemId_ancestors(self, item_id: str, index: int, key: str) -> Optional[Union[str, list, int, dict, bool]]:
"""
获得itemId的父item
:param item_id: 在Jellyfin中剧集的ID (S01E02的E02的item_id)
:param index: 第几个json对象
:param key: 需要得到父item中的键值对
:return key对应类型的值
"""
req_url = "%sItems/%s/Ancestors?api_key=%s" % (self._host, item_id, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json()[index].get(key)
else:
logger.error(f"Items/Id/Ancestors 未获取到返回数据")
return None
except Exception as e:
logger.error(f"连接Items/Id/Ancestors出错" + str(e))
return None
def refresh_root_library(self) -> bool: def refresh_root_library(self) -> bool:
""" """
通知Jellyfin刷新整个媒体库 通知Jellyfin刷新整个媒体库
@@ -371,11 +497,11 @@ class Jellyfin(metaclass=Singleton):
return False return False
req_url = "%sLibrary/Refresh?api_key=%s" % (self._host, self._apikey) req_url = "%sLibrary/Refresh?api_key=%s" % (self._host, self._apikey)
try: try:
res = RequestUtils().post_res(req_url) with RequestUtils().post_res(req_url) as res:
if res: if res:
return True return True
else: else:
logger.info(f"刷新媒体库失败无法连接Jellyfin") logger.info(f"刷新媒体库失败无法连接Jellyfin")
except Exception as e: except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e)) logger.error(f"连接Library/Refresh出错" + str(e))
return False return False
@@ -506,23 +632,23 @@ class Jellyfin(metaclass=Singleton):
req_url = "%sUsers/%s/Items/%s?api_key=%s" % ( req_url = "%sUsers/%s/Items/%s?api_key=%s" % (
self._host, self.user, itemid, self._apikey) self._host, self.user, itemid, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200: if res and res.status_code == 200:
item = res.json() item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb") tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem( return schemas.MediaServerItem(
server="jellyfin", server="jellyfin",
library=item.get("ParentId"), library=item.get("ParentId"),
item_id=item.get("Id"), item_id=item.get("Id"),
item_type=item.get("Type"), item_type=item.get("Type"),
title=item.get("Name"), title=item.get("Name"),
original_title=item.get("OriginalTitle"), original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"), year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None, tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"), imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"), tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path") path=item.get("Path")
) )
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items出错" + str(e)) logger.error(f"连接Users/Items出错" + str(e))
return None return None
@@ -537,17 +663,17 @@ class Jellyfin(metaclass=Singleton):
yield None yield None
req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey) req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
try: try:
res = RequestUtils().get_res(req_url) with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200: if res and res.status_code == 200:
results = res.json().get("Items") or [] results = res.json().get("Items") or []
for result in results: for result in results:
if not result: if not result:
continue continue
if result.get("Type") in ["Movie", "Series"]: if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id")) yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"): elif "Folder" in result.get("Type"):
for item in self.get_items(result.get("Id")): for item in self.get_items(result.get("Id")):
yield item yield item
except Exception as e: except Exception as e:
logger.error(f"连接Users/Items出错" + str(e)) logger.error(f"连接Users/Items出错" + str(e))
yield None yield None
@@ -559,9 +685,9 @@ class Jellyfin(metaclass=Singleton):
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return None return None
url = url.replace("[HOST]", self._host) \ url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey) \ .replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user) .replace("[USER]", self.user or '')
try: try:
return RequestUtils(accept_type="application/json").get_res(url=url) return RequestUtils(accept_type="application/json").get_res(url=url)
except Exception as e: except Exception as e:
@@ -577,9 +703,9 @@ class Jellyfin(metaclass=Singleton):
""" """
if not self._host or not self._apikey: if not self._host or not self._apikey:
return None return None
url = url.replace("[HOST]", self._host) \ url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey) \ .replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", self.user) .replace("[USER]", self.user or '')
try: try:
return RequestUtils( return RequestUtils(
headers=headers headers=headers
@@ -587,3 +713,158 @@ class Jellyfin(metaclass=Singleton):
except Exception as e: except Exception as e:
logger.error(f"连接Jellyfin出错" + str(e)) logger.error(f"连接Jellyfin出错" + str(e))
return None 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:
with RequestUtils().get_res(req_url) as res:
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"))
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
with RequestUtils().get_res(image) as image_res:
if not image_res or image_res.status_code == 404:
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
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:
with RequestUtils().get_res(req_url) as res:
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

View File

@@ -17,6 +17,16 @@ class PlexModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.plex.is_inactive():
self.plex.reconnect()
if not self.plex.get_librarys():
return False, "无法连接Plex请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "plex" return "MEDIASERVER", "plex"
@@ -95,11 +105,11 @@ class PlexModule(_ModuleBase):
media_statistic.user_count = 1 media_statistic.user_count = 1
return [media_statistic] return [media_statistic]
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]: def mediaserver_librarys(self, server: str = None, **kwargs) -> Optional[List[schemas.MediaServerLibrary]]:
""" """
媒体库列表 媒体库列表
""" """
if server != "plex": if server and server != "plex":
return None return None
return self.plex.get_librarys() return self.plex.get_librarys()
@@ -133,3 +143,27 @@ class PlexModule(_ModuleBase):
season=season, season=season,
episodes=episodes episodes=episodes
) for season, episodes in seasoninfo.items()] ) for season, episodes in seasoninfo.items()]
def mediaserver_playing(self, count: int = 20, server: str = None, **kwargs) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
if server and server != "plex":
return []
return self.plex.get_resume(count)
def mediaserver_latest(self, count: int = 20, server: str = None, **kwargs) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
if server and server != "plex":
return []
return self.plex.get_latest(count)
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取媒体库播放地址
"""
if server != "plex":
return None
return self.plex.get_play_url(item_id)

View File

@@ -1,4 +1,5 @@
import json import json
from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import List, Optional, Dict, Tuple, Generator, Any from typing import List, Optional, Dict, Tuple, Generator, Any
from urllib.parse import quote_plus from urllib.parse import quote_plus
@@ -10,10 +11,11 @@ from app import schemas
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
from app.schemas import MediaType from app.schemas import MediaType
from app.utils.singleton import Singleton
class Plex(metaclass=Singleton): class Plex:
_plex = None
def __init__(self): def __init__(self):
self._host = settings.PLEX_HOST self._host = settings.PLEX_HOST
@@ -22,6 +24,12 @@ class Plex(metaclass=Singleton):
self._host += "/" self._host += "/"
if not self._host.startswith("http"): if not self._host.startswith("http"):
self._host = "http://" + self._host self._host = "http://" + self._host
self._playhost = settings.PLEX_PLAY_HOST
if self._playhost:
if not self._playhost.endswith("/"):
self._playhost += "/"
if not self._playhost.startswith("http"):
self._playhost = "http://" + self._playhost
self._token = settings.PLEX_TOKEN self._token = settings.PLEX_TOKEN
if self._host and self._token: if self._host and self._token:
try: try:
@@ -50,6 +58,43 @@ class Plex(metaclass=Singleton):
self._plex = None self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}") logger.error(f"Plex服务器连接失败{str(e)}")
@lru_cache(maxsize=10)
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
"""
获取媒体服务器最近添加的媒体的图片列表
param: library_key
param: type type的含义: 1 电影 2 剧集 详见 plexapi/utils.py中SEARCHTYPES的定义
"""
if not self._plex:
return None
# 返回结果
poster_urls = {}
# 页码计数
container_start = 0
# 需要的总条数/每页的条数
total_size = 4
# 如果总数不足,接续获取下一页
while len(poster_urls) < total_size:
items = self._plex.fetchItems(f"/hubs/home/recentlyAdded?type={mtype}&sectionID={library_key}",
container_size=total_size,
container_start=container_start)
for item in items:
if item.type == 'episode':
# 如果是剧集的单集,则去找上级的图片
if item.parentThumb is not None:
poster_urls[item.parentThumb] = None
else:
# 否则就用自己的图片
if item.thumb is not None:
poster_urls[item.thumb] = None
if len(poster_urls) == total_size:
break
if len(items) < total_size:
break
container_start += total_size
return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in
list(poster_urls.keys())[:total_size]]
def get_librarys(self) -> List[schemas.MediaServerLibrary]: def get_librarys(self) -> List[schemas.MediaServerLibrary]:
""" """
获取媒体服务器所有媒体库列表 获取媒体服务器所有媒体库列表
@@ -62,12 +107,17 @@ class Plex(metaclass=Singleton):
logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}") logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}")
return [] return []
libraries = [] libraries = []
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
for library in self._libraries: for library in self._libraries:
if library.title in black_list:
continue
match library.type: match library.type:
case "movie": case "movie":
library_type = MediaType.MOVIE.value library_type = MediaType.MOVIE.value
image_list = self.__get_library_images(library.key, 1)
case "show": case "show":
library_type = MediaType.TV.value library_type = MediaType.TV.value
image_list = self.__get_library_images(library.key, 2)
case _: case _:
continue continue
libraries.append( libraries.append(
@@ -75,7 +125,10 @@ class Plex(metaclass=Singleton):
id=library.key, id=library.key,
name=library.title, name=library.title,
path=library.locations, path=library.locations,
type=library_type type=library_type,
image_list=image_list,
link=f"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}"
f"/com.plexapp.plugins.library?source={library.key}"
) )
) )
return libraries return libraries
@@ -543,3 +596,63 @@ class Plex(metaclass=Singleton):
获取plex对象以便直接操作 获取plex对象以便直接操作
""" """
return self._plex return self._plex
def get_play_url(self, item_id: str) -> str:
"""
拼装媒体播放链接
:param item_id: 媒体的的ID
"""
return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}'
def get_resume(self, num: int = 12) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获取继续观看的媒体
"""
if not self._plex:
return []
items = self._plex.fetchItems('/hubs/continueWatching/items', container_start=0, container_size=num)
ret_resume = []
for item in items:
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
if item_type == MediaType.MOVIE.value:
title = item.title
subtitle = item.year
else:
title = item.grandparentTitle
subtitle = f"S{item.parentIndex}:E{item.index} - {item.title}"
link = self.get_play_url(item.key)
image = item.artUrl
ret_resume.append(schemas.MediaServerPlayItem(
id=item.key,
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0
))
return ret_resume[:num]
def get_latest(self, num: int = 20) -> Optional[List[schemas.MediaServerPlayItem]]:
"""
获取最近添加媒体
"""
if not self._plex:
return None
items = self._plex.fetchItems('/library/recentlyAdded', container_start=0, container_size=num)
ret_resume = []
for item in items:
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
link = self.get_play_url(item.key)
title = item.title if item_type == MediaType.MOVIE.value else \
"%s%s" % (item.parentTitle, item.index)
image = item.posterUrl
ret_resume.append(schemas.MediaServerPlayItem(
id=item.key,
title=title,
subtitle=item.year,
type=item_type,
image=image,
link=link
))
return ret_resume[:num]

View File

@@ -26,6 +26,16 @@ class QbittorrentModule(_ModuleBase):
def stop(self): def stop(self):
pass pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
if self.qbittorrent.is_inactive():
self.qbittorrent.reconnect()
if not self.qbittorrent.transfer_info():
return False, "无法获取Qbittorrent状态请检查参数配置"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]: def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "DOWNLOADER", "qbittorrent" return "DOWNLOADER", "qbittorrent"
@@ -38,7 +48,8 @@ class QbittorrentModule(_ModuleBase):
self.qbittorrent.reconnect() self.qbittorrent.reconnect()
def download(self, content: Union[Path, str], download_dir: Path, cookie: str, def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]: episodes: Set[int] = None, category: str = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[Tuple[Optional[str], str]]:
""" """
根据种子文件,选择并添加下载任务 根据种子文件,选择并添加下载任务
:param content: 种子文件地址或者磁力链接 :param content: 种子文件地址或者磁力链接
@@ -46,6 +57,7 @@ class QbittorrentModule(_ModuleBase):
:param cookie: cookie :param cookie: cookie
:param episodes: 需要下载的集数 :param episodes: 需要下载的集数
:param category: 分类 :param category: 分类
:param downloader: 下载器
:return: 种子Hash错误信息 :return: 种子Hash错误信息
""" """
@@ -63,8 +75,12 @@ class QbittorrentModule(_ModuleBase):
logger.error(f"获取种子名称失败:{e}") logger.error(f"获取种子名称失败:{e}")
return "", 0 return "", 0
# 不是默认下载器不处理
if downloader != "qbittorrent":
return None
if not content: if not content:
return return None
if isinstance(content, Path) and not content.exists(): if isinstance(content, Path) and not content.exists():
return None, f"种子文件不存在:{content}" return None, f"种子文件不存在:{content}"
@@ -113,7 +129,7 @@ class QbittorrentModule(_ModuleBase):
# 获取种子Hash # 获取种子Hash
torrent_hash = self.qbittorrent.get_torrent_id_by_tag(tags=tag) torrent_hash = self.qbittorrent.get_torrent_id_by_tag(tags=tag)
if not torrent_hash: if not torrent_hash:
return None, f"获取种子Hash失败:{content}" return None, f"下载任务添加成功但获取Qbittorrent任务信息失败:{content}"
else: else:
if is_paused: if is_paused:
# 种子文件 # 种子文件
@@ -151,13 +167,18 @@ class QbittorrentModule(_ModuleBase):
return torrent_hash, "添加下载成功" return torrent_hash, "添加下载成功"
def list_torrents(self, status: TorrentStatus = None, def list_torrents(self, status: TorrentStatus = None,
hashs: Union[list, str] = None) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]: hashs: Union[list, str] = None,
downloader: str = settings.DEFAULT_DOWNLOADER
) -> Optional[List[Union[TransferTorrent, DownloadingTorrent]]]:
""" """
获取下载器种子列表 获取下载器种子列表
:param status: 种子状态 :param status: 种子状态
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器
:return: 下载器中符合状态的种子列表 :return: 下载器中符合状态的种子列表
""" """
if downloader != "qbittorrent":
return None
ret_torrents = [] ret_torrents = []
if hashs: if hashs:
# 按Hash获取 # 按Hash获取
@@ -206,7 +227,7 @@ class QbittorrentModule(_ModuleBase):
season_episode=meta.season_episode, season_episode=meta.season_episode,
progress=torrent.get('progress') * 100, progress=torrent.get('progress') * 100,
size=torrent.get('total_size'), size=torrent.get('total_size'),
state="paused" if torrent.get('state') == "paused" else "downloading", state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')), dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
upspeed=StringUtils.str_filesize(torrent.get('upspeed')), upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
left_time=StringUtils.str_secends( left_time=StringUtils.str_secends(
@@ -217,13 +238,16 @@ class QbittorrentModule(_ModuleBase):
return None return None
return ret_torrents return ret_torrents
def transfer_completed(self, hashs: Union[str, list], def transfer_completed(self, hashs: Union[str, list], path: Path = None,
path: Path = None) -> None: downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
""" """
转移完成后的处理 转移完成后的处理
:param hashs: 种子Hash :param hashs: 种子Hash
:param path: 源目录 :param path: 源目录
:param downloader: 下载器
""" """
if downloader != "qbittorrent":
return
self.qbittorrent.set_torrents_tag(ids=hashs, tags=['已整理']) self.qbittorrent.set_torrents_tag(ids=hashs, tags=['已整理'])
# 移动模式删除种子 # 移动模式删除种子
if settings.TRANSFER_TYPE == "move": if settings.TRANSFER_TYPE == "move":
@@ -236,47 +260,61 @@ class QbittorrentModule(_ModuleBase):
logger.warn(f"删除残留文件夹:{path}") logger.warn(f"删除残留文件夹:{path}")
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
def remove_torrents(self, hashs: Union[str, list]) -> bool: def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True,
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
""" """
删除下载器种子 删除下载器种子
:param hashs: 种子Hash :param hashs: 种子Hash
:param delete_file: 是否删除文件
:param downloader: 下载器
:return: bool :return: bool
""" """
return self.qbittorrent.delete_torrents(delete_file=True, ids=hashs) if downloader != "qbittorrent":
return None
return self.qbittorrent.delete_torrents(delete_file=delete_file, ids=hashs)
def start_torrents(self, hashs: Union[list, str]) -> bool: def start_torrents(self, hashs: Union[list, str],
downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
""" """
开始下载 开始下载
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器
:return: bool :return: bool
""" """
if downloader != "qbittorrent":
return None
return self.qbittorrent.start_torrents(ids=hashs) return self.qbittorrent.start_torrents(ids=hashs)
def stop_torrents(self, hashs: Union[list, str]) -> bool: def stop_torrents(self, hashs: Union[list, str], downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[bool]:
""" """
停止下载 停止下载
:param hashs: 种子Hash :param hashs: 种子Hash
:param downloader: 下载器
:return: bool :return: bool
""" """
if downloader != "qbittorrent":
return None
return self.qbittorrent.stop_torrents(ids=hashs) return self.qbittorrent.stop_torrents(ids=hashs)
def torrent_files(self, tid: str) -> Optional[TorrentFilesList]: def torrent_files(self, tid: str, downloader: str = settings.DEFAULT_DOWNLOADER) -> Optional[TorrentFilesList]:
""" """
获取种子文件列表 获取种子文件列表
""" """
if downloader != "qbittorrent":
return None
return self.qbittorrent.get_files(tid=tid) return self.qbittorrent.get_files(tid=tid)
def downloader_info(self) -> schemas.DownloaderInfo: def downloader_info(self) -> [schemas.DownloaderInfo]:
""" """
下载器信息 下载器信息
""" """
# 调用Qbittorrent API查询实时信息 # 调用Qbittorrent API查询实时信息
info = self.qbittorrent.transfer_info() info = self.qbittorrent.transfer_info()
if not info: if not info:
return schemas.DownloaderInfo() return [schemas.DownloaderInfo()]
return schemas.DownloaderInfo( return [schemas.DownloaderInfo(
download_speed=info.get("dl_info_speed"), download_speed=info.get("dl_info_speed"),
upload_speed=info.get("up_info_speed"), upload_speed=info.get("up_info_speed"),
download_size=info.get("dl_info_data"), download_size=info.get("dl_info_data"),
upload_size=info.get("up_info_data") upload_size=info.get("up_info_data")
) )]

View File

@@ -8,11 +8,10 @@ from qbittorrentapi.transfer import TransferInfoDictionary
from app.core.config import settings from app.core.config import settings
from app.log import logger from app.log import logger
from app.utils.singleton import Singleton
from app.utils.string import StringUtils from app.utils.string import StringUtils
class Qbittorrent(metaclass=Singleton): class Qbittorrent:
_host: str = None _host: str = None
_port: int = None _port: int = None
_username: str = None _username: str = None
@@ -120,6 +119,21 @@ class Qbittorrent(metaclass=Singleton):
tags=tags) tags=tags)
return None if error else torrents or [] return None if error else torrents or []
def delete_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
"""
删除Tag
:param ids: 种子Hash列表
:param tag: 标签内容
"""
if not self.qbc:
return False
try:
self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag)
return True
except Exception as err:
logger.error(f"删除种子Tag出错{str(err)}")
return False
def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool: def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
""" """
移除种子Tag 移除种子Tag
@@ -129,7 +143,7 @@ class Qbittorrent(metaclass=Singleton):
if not self.qbc: if not self.qbc:
return False return False
try: try:
self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag) self.qbc.torrents_remove_tags(torrent_hashes=ids, tags=tag)
return True return True
except Exception as err: except Exception as err:
logger.error(f"移除种子Tag出错{str(err)}") logger.error(f"移除种子Tag出错{str(err)}")
@@ -180,7 +194,7 @@ class Qbittorrent(metaclass=Singleton):
通过标签多次尝试获取刚添加的种子ID并移除标签 通过标签多次尝试获取刚添加的种子ID并移除标签
""" """
torrent_id = None torrent_id = None
# QB添加下载后需要时间重试5次每次等待5 # QB添加下载后需要时间重试10次每次等待3
for i in range(1, 10): for i in range(1, 10):
time.sleep(3) time.sleep(3)
torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags, torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags,
@@ -188,7 +202,7 @@ class Qbittorrent(metaclass=Singleton):
if torrent_id is None: if torrent_id is None:
continue continue
else: else:
self.remove_torrents_tag(torrent_id, tags) self.delete_torrents_tag(torrent_id, tags)
break break
return torrent_id return torrent_id
@@ -358,6 +372,24 @@ class Qbittorrent(metaclass=Singleton):
logger.error(f"设置速度限制出错:{str(err)}") logger.error(f"设置速度限制出错:{str(err)}")
return False return False
def get_speed_limit(self) -> Optional[Tuple[float, float]]:
"""
获取QB速度
:return: 返回download_limit 和upload_limit 默认是0
"""
if not self.qbc:
return None
download_limit = 0
upload_limit = 0
try:
download_limit = self.qbc.transfer.download_limit
upload_limit = self.qbc.transfer.upload_limit
except Exception as err:
logger.error(f"获取速度限制出错:{str(err)}")
return download_limit / 1024, upload_limit / 1024
def recheck_torrents(self, ids: Union[str, list]) -> bool: def recheck_torrents(self, ids: Union[str, list]) -> bool:
""" """
重新校验种子 重新校验种子

View File

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

View File

@@ -41,32 +41,32 @@ class Slack:
# 注册消息响应 # 注册消息响应
@slack_app.event("message") @slack_app.event("message")
def slack_message(message): def slack_message(message):
local_res = requests.post(self._ds_url, json=message, timeout=10) with requests.post(self._ds_url, json=message, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (message, local_res.text)) logger.debug("message: %s processed, response is: %s" % (message, local_res.text))
@slack_app.action(re.compile(r"actionId-\d+")) @slack_app.action(re.compile(r"actionId-\d+"))
def slack_action(ack, body): def slack_action(ack, body):
ack() ack()
local_res = requests.post(self._ds_url, json=body, timeout=60) with requests.post(self._ds_url, json=body, timeout=60) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
@slack_app.event("app_mention") @slack_app.event("app_mention")
def slack_mention(say, body): def slack_mention(say, body):
say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>") say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>")
local_res = requests.post(self._ds_url, json=body, timeout=10) with requests.post(self._ds_url, json=body, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
@slack_app.shortcut(re.compile(r"/*")) @slack_app.shortcut(re.compile(r"/*"))
def slack_shortcut(ack, body): def slack_shortcut(ack, body):
ack() ack()
local_res = requests.post(self._ds_url, json=body, timeout=10) with requests.post(self._ds_url, json=body, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
@slack_app.command(re.compile(r"/*")) @slack_app.command(re.compile(r"/*"))
def slack_command(ack, body): def slack_command(ack, body):
ack() ack()
local_res = requests.post(self._ds_url, json=body, timeout=10) with requests.post(self._ds_url, json=body, timeout=10) as local_res:
logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) logger.debug("message: %s processed, response is: %s" % (body, local_res.text))
# 启动服务 # 启动服务
try: try:
@@ -87,6 +87,12 @@ class Slack:
except Exception as err: except Exception as err:
logger.error("Slack消息接收服务停止失败: %s" % str(err)) logger.error("Slack消息接收服务停止失败: %s" % str(err))
def get_state(self) -> bool:
"""
获取状态
"""
return True if self._client else False
def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""): def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""):
""" """
发送Telegram消息 发送Telegram消息

View File

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

View File

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

View File

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

View File

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

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