Compare commits

...

236 Commits

Author SHA1 Message Date
jxxghp
6965415c52 v1.9.1
- 回退了多线程优先级过滤,以暂时解决过滤器偶发报错的问题(搜索慢的过滤规则层数减一减吧)
- 发现推荐改为使用TTL缓存,解决榜单长时间不刷新的问题
- 修复了目录指定了类别时会重复创建类别文件夹的问题
- 订阅保存路径支持下拉选择已配置的下载目录
- 降低了INFO日志打印量,如需打印详细日志请设置`DEBUG`为`true`(写过多日志会降低响应速度)
- 目录设定中增加了同盘优先选项,开启后在匹配媒体库目录时优先使用同盘/同根路径目录
- 修复了一个订阅匹配的问题

注意:因媒体库目录设置结构调整,`目录监控`插件指定目的路径时不再支持自动创建下级分类目录。建议插件中不指定目的目录,而是在`设定->目录`中设定好目录结构让系统自动匹配使用(根据需要打开同盘优先选项)。实际上如果是可以通过分类来指定目录的,建议直接使用内建的下载器监控功能,目录监控仅是个插件,仅适用于不通过MoviePilot自动下载的文件监控整理。
2024-05-24 12:47:34 +08:00
jxxghp
9be671fa2c fix 82226f1956 2024-05-24 12:36:51 +08:00
jxxghp
27b4f206a1 fix 订阅刷新站点范围扩大问题 2024-05-24 12:31:11 +08:00
jxxghp
a2b0c9bd3a fix #2164 2024-05-24 11:37:26 +08:00
jxxghp
ebc46d7d3b fix #2165 2024-05-24 11:31:37 +08:00
jxxghp
eb4e4b5141 feat:新增同盘优先设置 2024-05-24 11:19:21 +08:00
jxxghp
be11ef72a9 调整INFO日志打印量 && 回滚多线程过滤 2024-05-24 10:48:56 +08:00
jxxghp
a278c80951 fix #2151 TMDB发现和趋势修改为TTL缓存 2024-05-24 09:29:51 +08:00
jxxghp
6ee6de48ff 尝试修复过滤器并发报错 2024-05-24 08:56:32 +08:00
jxxghp
671bdad77c v1.9.1-beta
- 优化了订阅匹配逻辑
- 目录设置全新改版,多目录支持更加灵活,存量目录配置会自动升级为新格式
- 手动整理时支持下拉选择已配置目录
- 元数据刮削取消了全局开关,按媒体库目录设置,目录监控等插件新增了刮削开关并需要手动打开
- 站点管理支持拖动排序
- 修复了仪表板不可见组件仍会刷新的问题

注意:涉及前端变化升级后需要清理浏览器缓存(仅清理缓存文件,无需清理cookie)
2024-05-23 19:43:08 +08:00
jxxghp
a9ff8ec96d 更新 README.md 2024-05-23 14:28:40 +08:00
jxxghp
d1678355f1 fix #2099 2024-05-23 12:45:27 +08:00
jxxghp
ea399daef9 更新 __init__.py 2024-05-23 11:46:30 +08:00
jxxghp
e1122af97c 更新 __init__.py 2024-05-23 10:05:56 +08:00
jxxghp
21861111e6 更新 download.py 2024-05-23 10:05:07 +08:00
jxxghp
bd1e83ee8a fix 2024-05-23 08:53:20 +08:00
jxxghp
43da33bc50 fix 2024-05-23 08:41:20 +08:00
jxxghp
a09a207407 fix manual_transfer api 2024-05-23 08:09:36 +08:00
jxxghp
0aa3aa8521 Merge pull request #2146 from zhu0823/main 2024-05-23 07:10:48 +08:00
jxxghp
d9c6375252 feat:目录结构重大调整,谨慎更新到dev 2024-05-22 20:02:14 +08:00
jxxghp
f1f187fc77 add media category api 2024-05-22 18:02:28 +08:00
zhu0823
99d22554a1 fix: plex最近添加类型为show的资源 2024-05-22 13:34:03 +08:00
jxxghp
4835f6c6c9 更新 subscribe.py 2024-05-21 23:17:59 +08:00
jxxghp
5be2bf0633 feat:服务器地址变量化 2024-05-21 17:31:12 +08:00
jxxghp
7c7bc0b504 fix log 2024-05-21 17:14:16 +08:00
jxxghp
e0939fee75 fix https://github.com/jxxghp/MoviePilot/issues/2134 2024-05-21 16:36:09 +08:00
jxxghp
82226f1956 fix https://github.com/jxxghp/MoviePilot/issues/2125 调整订阅匹配逻辑 2024-05-21 11:57:12 +08:00
jxxghp
cfb43b4b04 fix torrent match 2024-05-21 09:54:06 +08:00
jxxghp
ebe2795eae Merge pull request #2128 from jeblove/main 2024-05-21 06:48:54 +08:00
jeblove
f7f747278d fix: jellyfin webhook百分比数据有时为None引发报错 2024-05-20 23:47:37 +08:00
jxxghp
58f17e89b6 v1.9.0
- 支持用户自定义主题风格
- 资源搜索列表模式下支持排序
- 索引站点新增支持`YemaPT`
- 修复了某些情况下订阅查询转圈的问题
- 修复了默认洗版设置不生效的问题
- 修复了存在活动连接时无法正常关闭系统的问题
- 回退了上个版本的最后一点修改(会导致手机端添加桌面图标时没有App效果)
2024-05-20 18:24:39 +08:00
jxxghp
433ca2ec28 fix YemaPT 2024-05-20 17:41:46 +08:00
jxxghp
ffac57ad4d 支持 YemaPT 2024-05-20 16:55:36 +08:00
jxxghp
0d2a4c50d6 fix error message 2024-05-20 15:39:43 +08:00
jxxghp
02c2edc30e fix WEB页面活动时无法正常停止服务的问题 2024-05-20 11:50:49 +08:00
jxxghp
65975235d4 fix scheduler 2024-05-20 10:16:20 +08:00
jxxghp
07a6abde0e fix json exception 2024-05-20 09:13:51 +08:00
jxxghp
fa47d9adeb fix 2024-05-19 14:17:34 +08:00
jxxghp
18d08c3672 fix 2024-05-19 12:35:21 +08:00
jxxghp
cf20049b7f Merge pull request #2117 from InfinityPacer/main
fix 开启DEBUG时,LOG文件也记录DEBUG日志
2024-05-19 12:28:08 +08:00
InfinityPacer
e3ce3302da fix 开启DEBUG时,LOG文件也记录DEBUG日志 2024-05-19 12:18:43 +08:00
jxxghp
d20951e7a0 Merge pull request #2116 from InfinityPacer/main 2024-05-19 12:08:03 +08:00
InfinityPacer
8a565bb79f fix #2113 2024-05-19 11:53:18 +08:00
jxxghp
cfdc8fb2c3 Merge pull request #2115 from InfinityPacer/main 2024-05-19 11:02:19 +08:00
InfinityPacer
111f830664 fix #2104 2024-05-19 10:58:09 +08:00
jxxghp
2821d6a9dc fix #2076 2024-05-19 09:54:52 +08:00
jxxghp
495d98c2b2 fix #2086 没有站点时订阅打不开的问题 && 默认洗版不生效的问题 2024-05-19 09:52:00 +08:00
jxxghp
e1e2779e48 fix #2101 1ptba签到增加index.php,认证及种子浏览有带具体的URI,如有问题需要站点放开。 2024-05-19 09:42:22 +08:00
jxxghp
363318f4f0 智能下载增加日志打印 2024-05-19 09:19:44 +08:00
jxxghp
521b960364 catch command exception 2024-05-19 08:35:18 +08:00
jxxghp
d2bcb197eb v1.8.9
- 站点增加了独立的超时时间设置(默认15秒),可根据站点连通情况调整对应超时时间,以缩短搜索耗时
- 使用多线程加快搜索结果的优先级过滤速度
- 极大提升了非首次登录时的页面加载速度(需要重新拉取镜像该特性才会生效)
- 仪表仪的拖拽图标默认隐藏,避免影响无边框组件的显示效果
- 安装网页到桌面App时,支持前进后退操作按钮
2024-05-17 14:51:02 +08:00
jxxghp
b0f9ca52e3 fix nginx cache 2024-05-17 14:37:25 +08:00
jxxghp
01a3efd402 fix nginx cache 2024-05-17 13:58:29 +08:00
jxxghp
a50427948a feat:多线程过滤 2024-05-17 12:20:39 +08:00
jxxghp
5614f10962 fix get_dashboard 2024-05-17 10:50:03 +08:00
jxxghp
5ff80dbe89 fix get_dashboard 2024-05-17 10:49:47 +08:00
jxxghp
278835c5d4 fix 2024-05-17 07:46:27 +08:00
jxxghp
92cdd67f3a Merge pull request #2093 from thsrite/main 2024-05-16 20:53:57 +08:00
thsrite
c56b58cc56 fix 2024-05-16 20:50:42 +08:00
thsrite
8bd4c21511 fix 2024-05-16 20:49:28 +08:00
thsrite
b94f201667 fix 拆包场景下,下载通知标题展示实际下载的集数 2024-05-16 20:44:57 +08:00
jxxghp
125e9eb30a Merge pull request #2091 from happyTonakai/main
Jellyfin webhook 添加播放百分比字段
2024-05-16 20:36:24 +08:00
happyTonakai
ea09d8c8d4 Jellyfin webhook 添加播放百分比字段 2024-05-16 20:20:24 +08:00
jxxghp
de0237f348 dashboard 传递 UA 2024-05-16 17:32:35 +08:00
jxxghp
62143bf7b6 fix db 2024-05-16 16:18:22 +08:00
jxxghp
3088bbb2f8 站点独立设置超时时间 2024-05-16 14:42:10 +08:00
jxxghp
43647e59a4 fix module name 2024-05-16 14:20:18 +08:00
jxxghp
a740330e66 Update README.md 2024-05-15 19:25:53 +08:00
jxxghp
10d4766353 fix bug 2024-05-15 07:42:03 +08:00
jxxghp
0a845fe8b6 Merge remote-tracking branch 'origin/main' 2024-05-14 20:37:18 +08:00
jxxghp
90dc52bb70 v1.8.8
- 新增了系统级通知功能
- 优化了主题切换使用体验
- 修复了仪表板存储空间卡片无法移动的问题
- 修复了仪表板自动刷新组件会重复生成的问题
- 修复了插件图表在暗黑主题下字体颜色偏暗的问题
- 仪表板组件支持无边框模式
- 默认内置了几个主要的第三方插件库
- 优先级规则支持拖拽排序
2024-05-14 20:37:03 +08:00
jxxghp
3816e2fba8 Merge pull request #2073 from developer-wlj/wlj0409 2024-05-14 20:35:22 +08:00
mayun110
20d92ca577 fix: 收藏洗版 jellyfin webhook 缺少的字段 2024-05-14 20:11:49 +08:00
jxxghp
db92761964 fix ide warning 2024-05-14 18:04:10 +08:00
jxxghp
3af5870733 Merge pull request #2071 from hotlcc/develop-20240514-修复一些发现的问题 2024-05-14 18:00:08 +08:00
Allen
53d01267b8 修复模块加载时前置模块失败导致后续均无法加载的问题 2024-05-14 17:25:22 +08:00
jxxghp
5ff21641f9 内置部分第三方插件库 2024-05-14 15:27:50 +08:00
jxxghp
643f2e3e66 Merge remote-tracking branch 'origin/main' 2024-05-14 10:08:15 +08:00
jxxghp
ae839235eb feat:服务异常时推送前台消息 2024-05-14 10:08:07 +08:00
jxxghp
5af94144ce Merge pull request #2067 from thsrite/main 2024-05-14 09:48:05 +08:00
thsrite
4281692321 fix add插件热加载变量开关(保留DEV开关 2024-05-14 09:24:02 +08:00
jxxghp
bd6d6b6882 fix 2024-05-14 08:30:29 +08:00
jxxghp
fabb02a8a0 fix README 2024-05-13 20:25:52 +08:00
jxxghp
223e655b6f feat:插件API立即生效 2024-05-13 20:15:47 +08:00
jxxghp
f0cb5b3e85 Merge pull request #2059 from EkkoG/fix_min_seeders_time 2024-05-13 11:25:10 +08:00
EkkoG
7579aae823 修复订阅时没有读取 min_seeders_time 导致最少做种人数生效时间不生效 2024-05-13 11:06:49 +08:00
jxxghp
36a8b6d780 更新 download.py 2024-05-13 07:38:14 +08:00
jxxghp
0471167b74 fix dev 2024-05-12 20:12:59 +08:00
jxxghp
28b996e54b Merge pull request #2056 from DDS-Derek/bump 2024-05-12 11:17:13 +08:00
DDSRem
d0989f72a9 bump: debian bullseye to bookworm 2024-05-12 09:55:35 +08:00
jxxghp
663e61e3a1 fix message api 2024-05-11 16:24:25 +08:00
jxxghp
71b0090947 Merge remote-tracking branch 'origin/main' 2024-05-11 12:53:40 +08:00
jxxghp
8ff0f81f47 fix message api 2024-05-11 12:53:32 +08:00
jxxghp
4186613a86 Merge pull request #2051 from InfinityPacer/main 2024-05-11 06:25:50 +08:00
InfinityPacer
4e1be23317 fix 热加载增加双重防抖 2024-05-11 01:23:33 +08:00
jxxghp
0e9e626ab6 fix 2024-05-10 20:09:45 +08:00
jxxghp
3d5761157a Merge pull request #2047 from InfinityPacer/main
fix 热加载不同平台路径及插件实例化
2024-05-10 19:57:48 +08:00
InfinityPacer
c407800b30 fix 热加载不同平台路径及插件实例化 2024-05-10 19:27:44 +08:00
jxxghp
c888a37aba fix README 2024-05-10 18:29:20 +08:00
jxxghp
d43998efee fix 插件热加载控重 2024-05-10 18:27:31 +08:00
jxxghp
8813e84053 feat:DEV模式下插件文件修改后自动重新加载 2024-05-10 13:33:00 +08:00
jxxghp
cf5a746f53 fix #2037 2024-05-10 08:22:05 +08:00
jxxghp
f9f58fc559 v1.8.7
- 认证站点增加新成员:DiscFan
- 支持插件在仪表板中显示Widget,站点数据统计、站点刷流插件已支持,其余插件待开发者适配,插件开发说明:https://github.com/jxxghp/MoviePilot-Plugins
- 仪表板组件支持用户通过拖拽调整显示顺序
- 调整了热门订阅订阅人数的显示样式
2024-05-09 20:06:19 +08:00
jxxghp
f59b5b6d27 fix plugin dashboard 2024-05-09 19:12:43 +08:00
jxxghp
30b3ad4a99 fix plugin dashboard 2024-05-09 19:12:12 +08:00
jxxghp
dfb9ce7520 fix tmdb match api 2024-05-09 18:49:58 +08:00
jxxghp
6c365f552e Merge pull request #2038 from Devinaille/fix_subtitle_match 2024-05-09 11:40:03 +08:00
tianyf
a81ee7d89a 修复副标题包含【】的情况下无法匹配标题的问题 2024-05-09 11:18:00 +08:00
jxxghp
5c9039e6d0 feat:插件仪表板API 2024-05-08 20:58:28 +08:00
jxxghp
cce2e13e21 fix README.md 2024-05-08 16:46:35 +08:00
jxxghp
0da87abc71 Merge remote-tracking branch 'origin/main' 2024-05-08 15:54:56 +08:00
jxxghp
6a2eecc744 fix #2016 2024-05-08 15:54:50 +08:00
jxxghp
c049e13c1c Merge pull request #2032 from z3shan33/main 2024-05-08 13:04:13 +08:00
z3shan33
7ec49ce076 fix #2031 2024-05-08 11:09:36 +08:00
jxxghp
5be2fc35b5 Merge pull request #2026 from zhu0823/main 2024-05-07 18:31:12 +08:00
zhu0823
0b84312559 fix: 某些剧集parentThumb为None时的问题 2024-05-07 18:28:53 +08:00
jxxghp
8bb43b52bc - 优化热门订阅数据统计,热门订阅支持展示订阅人数 2024-05-07 16:19:05 +08:00
jxxghp
bd348f118c fix 订阅统计清理 2024-05-07 16:01:52 +08:00
jxxghp
4a3a3483d0 fix api 2024-05-07 12:31:24 +08:00
jxxghp
fd6314f19f fix 2024-05-06 22:48:24 +08:00
jxxghp
17a9f3a626 更新 version.py 2024-05-06 19:08:49 +08:00
jxxghp
75c898e6eb Merge pull request #2018 from zhu0823/main 2024-05-06 18:37:00 +08:00
zhu0823
089d4785aa fix: plex最近添加的剧集封面地址 2024-05-06 18:35:10 +08:00
jxxghp
4d48295f72 fix bug 2024-05-06 16:46:03 +08:00
jxxghp
ed119b7beb fix 数据共享开关 2024-05-06 12:37:51 +08:00
jxxghp
90d5a8b0c9 add 数据共享开关 2024-05-06 11:54:32 +08:00
jxxghp
dd5c0de7b1 feat:订阅统计 2024-05-06 11:19:41 +08:00
jxxghp
73bdca282c fix api desc 2024-05-05 13:51:49 +08:00
jxxghp
360a54581f add api 2024-05-05 13:43:14 +08:00
jxxghp
1fc7587cbb fix 豆瓣&Bangumi搜索过于宽泛 2024-05-05 12:14:58 +08:00
jxxghp
dcd46f1627 fix #2001 2024-05-05 11:54:42 +08:00
jxxghp
d8644a20c0 fix #2011 2024-05-05 11:22:23 +08:00
jxxghp
23b47f98c1 Merge pull request #2009 from DDS-Derek/main 2024-05-05 09:55:00 +08:00
DDSRem
347c91fa0b bump: ca-certificates version to bookworm 2024-05-04 19:14:30 +08:00
jxxghp
ac961b37b4 Merge pull request #2004 from thsrite/main 2024-05-03 13:52:45 +08:00
thsrite
068c49a79a fix 最小做种数生效时间 2024-05-03 13:09:08 +08:00
jxxghp
e7174b402c Merge pull request #1988 from zhu0823/main 2024-04-30 19:20:33 +08:00
zhu0823
d21267090a 同步更新上游api 2024-04-30 19:14:29 +08:00
zhu0823
51dc2c33a0 feat: plex最近添加过滤黑名单 2024-04-30 19:14:08 +08:00
jxxghp
8aef488ab6 Merge pull request #1986 from Sxnan/tmdbid-type 2024-04-30 16:03:44 +08:00
sxnan
0cbf45f9b9 Cast tmdbid to int type after getting metainfo from title 2024-04-30 15:55:10 +08:00
jxxghp
c0ae32d654 - 修复豆瓣数据源搜索媒体类型错误的问题 2024-04-30 13:33:28 +08:00
jxxghp
ff1b0e02d6 fix 特殊集数识别 2024-04-30 11:49:03 +08:00
jxxghp
76a8b02fe5 fix 豆瓣搜索词条类型错误问题 2024-04-30 11:17:03 +08:00
jxxghp
43f594393c feat:插件增加排序字段 2024-04-30 08:34:37 +08:00
jxxghp
008e11d63f fix 豆瓣人物头像质量 2024-04-30 07:15:21 +08:00
jxxghp
9dd610f245 更新 site.py 2024-04-29 21:46:55 +08:00
jxxghp
c5d087aad6 Merge pull request #1979 from InfinityPacer/main 2024-04-29 20:43:14 +08:00
jxxghp
576c5741f9 v1.8.5
- 支持媒体信息多数据源聚合搜索(TheMovieDb、豆瓣、Bangumi),可在`设定`-`搜索`中调整数据源范围和顺序
- 插件支持设置标签,优化了插件功能的使用体验
- 适配馒头最新架构调整,需在站点管理中手动维护站点令牌和请求头
2024-04-29 20:39:20 +08:00
InfinityPacer
51387c31c4 fix plugin_order 2024-04-29 20:39:01 +08:00
jxxghp
c2a40876e2 适配m-team新鉴权机制 2024-04-29 20:19:46 +08:00
jxxghp
c06bdf0491 fix exception 2024-04-28 18:10:04 +08:00
jxxghp
f726130c31 feat:插件读取Label 2024-04-28 13:35:47 +08:00
jxxghp
4033ffeb15 fix 聚合结果排序 2024-04-28 12:03:48 +08:00
jxxghp
f81af8e9fb Merge remote-tracking branch 'origin/main' 2024-04-28 10:41:05 +08:00
jxxghp
e3f9260299 fix README.md 2024-04-28 10:40:59 +08:00
jxxghp
c80ccaf74b Merge pull request #1976 from thsrite/main 2024-04-28 10:26:05 +08:00
thsrite
0e60c976be fix 默认过滤规则支持最少做种人数生效发布时间,防止过滤掉到最新发布的种子 2024-04-28 10:21:42 +08:00
jxxghp
805c7d2701 fix torrent filter log 2024-04-28 09:19:09 +08:00
jxxghp
4499f001dd fix bug 2024-04-28 09:03:16 +08:00
jxxghp
71c6a3718b feat:媒体搜索聚合开关 2024-04-28 08:55:07 +08:00
jxxghp
6404f9d45c 更新 tmdb.py 2024-04-27 22:39:50 +08:00
jxxghp
ce357540eb 更新 douban.py 2024-04-27 22:38:47 +08:00
jxxghp
e56cfd6ad4 fix douban apis 2024-04-27 22:29:17 +08:00
jxxghp
25e5f7a9f6 fix tmdb apis 2024-04-27 21:56:27 +08:00
jxxghp
6d69ac42e5 fix bangumi apis 2024-04-27 21:36:42 +08:00
jxxghp
6a71bed821 fix douban api 2024-04-27 21:17:32 +08:00
jxxghp
1718758d1c fix 豆瓣图片质量 2024-04-27 17:16:19 +08:00
jxxghp
7a37078e90 feat:媒体信息聚合 2024-04-27 15:20:46 +08:00
jxxghp
26b5ad6a44 Merge pull request #1969 from lightolly/dev/20240428_1
feat:add person api
2024-04-27 13:00:46 +08:00
olly
fa884c9608 feat:add person api 2024-04-27 11:52:23 +08:00
jxxghp
6927b5fbd3 Merge pull request #1968 from lightolly/dev/20240428 2024-04-27 10:24:35 +08:00
olly
59fca63d4a feat:add api 2024-04-27 10:12:40 +08:00
jxxghp
7489d6a912 v1.8.4
- 搜索框支持搜索演员,修复了演员参演作品只显示电影的问题
- 插件支持在数据页面绑定事件以及调用API接口
- 启动时如用户认证失败,后台会间歇性重试一段时间
- `设定`-`关于` 增加显示前端版本号
- 历史记录支持排序
- 优化了小屏幕下的弹窗使用体验
- Jellyfin 的页面跳转调整为支持`latest`分支版本
- 修复了个别情况下订阅历史记录会登记失败的问题
2024-04-27 09:09:09 +08:00
jxxghp
b437fd6021 fix bug 2024-04-27 00:30:17 +08:00
jxxghp
c303ab0765 fix api 2024-04-26 20:29:04 +08:00
jxxghp
9daff87f2f fix api 2024-04-26 19:41:17 +08:00
jxxghp
f20b1bcfe9 feat:人物搜索API 2024-04-26 17:47:45 +08:00
jxxghp
2f71e401be fix 2024-04-26 17:11:24 +08:00
jxxghp
0840e0bcbc Merge pull request #1967 from thsrite/main
fix 当前版本、重启检查前端版本
2024-04-26 17:05:12 +08:00
thsrite
933af7485c fix function name 2024-04-26 17:00:40 +08:00
thsrite
baddaabd73 fix 2024-04-26 16:59:19 +08:00
thsrite
8028866cee fix 2024-04-26 16:58:30 +08:00
thsrite
242894cec2 fix 当前版本、重启检查前端版本 2024-04-26 16:54:25 +08:00
jxxghp
967ad3a507 Merge pull request #1966 from thsrite/main 2024-04-26 14:44:16 +08:00
thsrite
2dbe049a91 fix 查询某时间之后的转移历史 2024-04-26 14:41:52 +08:00
jxxghp
c5afc65cbd fix #1955 启动时用户认证失败时,间歇性重试 2024-04-26 11:08:25 +08:00
jxxghp
e35bacecd5 fix #1942 2024-04-26 10:42:24 +08:00
jxxghp
d84c86b0f6 fix 插件重置 2024-04-25 16:48:37 +08:00
jxxghp
73ae09b041 fix bug 2024-04-25 10:31:11 +08:00
jxxghp
a11318390d feat:读取前端版本号 2024-04-25 10:18:38 +08:00
jxxghp
1714990e2e feat:读取前端版本号 2024-04-25 10:18:14 +08:00
jxxghp
44cd5f52e0 fix #1945 2024-04-24 10:25:45 +08:00
jxxghp
59b9dc354e fix 整合重复代码 2024-04-24 08:18:11 +08:00
jxxghp
591969015f v1.8.3
- 仪表板设定支持按用户持久化保存,同时也支持按浏览器差异化配置
- 修复了文件整理使用`original_name`占位符时后缀重复以及应用了识别词的问题
- 优化了个别站点的适配
- 修复了插件市场显示空插件的问题
2024-04-23 17:53:32 +08:00
jxxghp
6118e235c3 Merge pull request #1943 from thsrite/main 2024-04-23 17:17:28 +08:00
thsrite
228b1a11d0 fix 2024-04-23 16:18:30 +08:00
thsrite
c8a1e59310 fix plugin 403 msg 2024-04-23 16:16:39 +08:00
jxxghp
b0f7a11328 fix naming 2024-04-23 11:22:43 +08:00
jxxghp
b753e50580 fix api order 2024-04-23 10:06:19 +08:00
jxxghp
3002bf4dd2 fix 2024-04-23 10:02:15 +08:00
jxxghp
0cbe8f5cdc Merge pull request #1920 from hotlcc/develop-20240417-用户配置
新增用户配置相关能力和接口
2024-04-23 09:57:55 +08:00
jxxghp
1a03d19469 Merge remote-tracking branch 'origin/main' 2024-04-23 09:56:28 +08:00
jxxghp
b7c1106744 fix #1940 2024-04-23 09:56:21 +08:00
Allen
d6c6c999fc 优化用户级配置能力 2024-04-23 09:51:18 +08:00
jxxghp
408703d4a3 Merge pull request #1938 from thsrite/main 2024-04-22 18:14:34 +08:00
thsrite
40a612c327 fix 2024-04-22 15:57:34 +08:00
thsrite
e519fc484b fix 获取指定用户的订阅列表 2024-04-22 15:56:50 +08:00
thsrite
e430a3e88b fix 获取指定tmdb_id的订阅列表 2024-04-22 15:49:57 +08:00
jxxghp
316f61bf69 fix #1922 2024-04-22 09:54:51 +08:00
jxxghp
750c4441db fix #1930 2024-04-22 09:31:35 +08:00
jxxghp
441cee4ee5 v1.8.2
- 新增订阅历史记录功能
- 站点支持显示连接状态
- 站点新增支持花梨月下

注意:更新后需要清理浏览器缓存
2024-04-19 19:57:42 +08:00
jxxghp
ebf2f53ae1 fix api 2024-04-19 19:51:01 +08:00
jxxghp
4e7000efbb Merge pull request #1925 from Aodi/main
fix 反向代理图片显示问题,url改为查询参数避免双斜杠的优化
2024-04-19 19:37:29 +08:00
jxxghp
0679a32659 fix 2024-04-19 12:31:38 +08:00
jxxghp
148984ad0e Merge pull request #1923 from thsrite/main 2024-04-19 11:50:31 +08:00
thsrite
dd8804ef3e fix 2024-04-19 11:49:03 +08:00
aodi
fb0018dda6 fix 反向代理图片显示问题,url改为查询参数避免双斜杠的优化 2024-04-19 11:24:37 +08:00
thsrite
c14e529c91 fix #1921 2024-04-19 10:26:34 +08:00
jxxghp
f6222122c0 feat:订阅历史以及API 2024-04-18 21:00:57 +08:00
jxxghp
3a18267ec0 feat:订阅历史以及API 2024-04-18 15:48:46 +08:00
Allen
ae60040120 fixbug 2024-04-18 15:19:46 +08:00
jxxghp
b04bc74550 fix public site flag 2024-04-18 15:10:06 +08:00
Allen
666d6eb048 新增用户配置相关能力和接口 2024-04-18 12:33:35 +08:00
jxxghp
73a3a8cf94 fix #1914 2024-04-18 11:20:03 +08:00
jxxghp
6d66c5b577 更新 site.py 2024-04-17 15:02:03 +08:00
jxxghp
c3ffe38d4d feat:站点使用统计 2024-04-17 12:42:32 +08:00
jxxghp
5108dbbeb5 fix plugin install 2024-04-17 09:46:02 +08:00
jxxghp
cbf56bd9b7 fix module log 2024-04-16 18:22:02 +08:00
jxxghp
67965b09a6 Merge remote-tracking branch 'origin/main' 2024-04-16 18:21:04 +08:00
jxxghp
a2678d5815 fix #1882 季赋值错误! 2024-04-16 18:20:54 +08:00
jxxghp
36b25e6a08 Merge pull request #1908 from zhu0823/main 2024-04-16 17:49:33 +08:00
zhu0823
c98c8c8836 feat: plex媒体数量统计过滤黑名单 2024-04-16 17:42:25 +08:00
jxxghp
423b7cf340 Merge pull request #1907 from zhu0823/main 2024-04-16 16:54:43 +08:00
zhu0823
02acc8bc35 feat: plex继续观看过滤黑名单 2024-04-16 16:38:16 +08:00
jxxghp
664b42f050 fix original_name 2024-04-16 10:22:49 +08:00
125 changed files with 4209 additions and 1712 deletions

3
.gitignore vendored
View File

@@ -15,4 +15,5 @@ config/user.db
config/sites/**
*.pyc
*.log
.vscode
.vscode
venv

View File

@@ -1,4 +1,4 @@
FROM python:3.11.4-slim-bullseye
FROM python:3.11.4-slim-bookworm
ARG MOVIEPILOT_VERSION
ENV LANG="C.UTF-8" \
TZ="Asia/Shanghai" \
@@ -16,6 +16,7 @@ ENV LANG="C.UTF-8" \
IYUU_SIGN=""
WORKDIR "/app"
RUN apt-get update -y \
&& apt-get upgrade -y \
&& apt-get -y install \
musl-dev \
nginx \

View File

@@ -48,7 +48,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- Windows
1. 独立执行文件版本:下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases)双击运行后自动生成配置文件目录访问http://localhost:3000
2. 安装包版本:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
2. 安装包版本【推荐】[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
- 群晖套件
@@ -82,7 +82,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **❗AUTH_SITE** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
认证资源`v1.2.4+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`
认证资源`v1.2.8+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`/`discfan`
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
@@ -103,6 +103,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
| agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
| hdkyl | `HDKYL_UID`用户ID<br/>`HDKYL_PASSKEY`:密钥 |
| qingwa | `QINGWA_UID`用户ID<br/>`QINGWA_PASSKEY`:密钥 |
| discfan | `DISCFAN_UID`用户ID<br/>`DISCFAN_PASSKEY`:密钥 |
### 2. **环境变量 / 配置文件**
@@ -115,25 +116,25 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **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_****
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
- **DEV:** 开发者模式,`true`/`false`,默认`false`仅用于本地开发使用,开启后会暂停所有定时任务,且插件代码文件的修改无需重启会自动重载生效
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker镜像
---
- **TMDB_API_DOMAIN** TMDB API地址默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可
- **TMDB_IMAGE_DOMAIN** TMDB图片地址默认`image.tmdb.org`可配置为其它中转代理以加速TMDB图片显示`static-mdb.v.geilijiasu.com`
- **WALLPAPER** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
- **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
- **RECOGNIZE_SOURCE** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类,且受豆瓣控流限制
- **FANART_ENABLE** Fanart开关`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
- **SCRAP_SOURCE** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
- **SCRAP_FOLLOW_TMDB** 新增已入库媒体是否跟随TMDB信息变化`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
---
- **AUTO_DOWNLOAD_USER** 远程交互搜索时自动择优下载的用户ID消息通知渠道的用户ID多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
- **DOWNLOAD_SUBTITLE** 下载站点字幕,`true`/`false`,默认`true`
- **SEARCH_MULTIPLE_NAME** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
- **SUBSCRIBE_STATISTIC_SHARE** 是否匿名分享订阅数据,用于统计和展示用户热门订阅,`true`/`false`,默认`true`
- **PLUGIN_STATISTIC_SHARE** 是否匿名分享插件安装统计数据,用于统计和显示插件下载安装次数,`true`/`false`,默认`true`
---
- **OCR_HOST** OCR识别服务器地址格式`http(s)://ip:port`用于识别站点验证码实现自动登录获取Cookie等不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
---
- **DOWNLOAD_SUBTITLE** 下载站点字幕,`true`/`false`,默认`true`
---
- **SEARCH_MULTIPLE_NAME** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
---
- **MOVIE_RENAME_FORMAT** 电影重命名格式基于jinjia2语法
`MOVIE_RENAME_FORMAT`支持的配置项:
@@ -190,8 +191,12 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
### 4. **插件扩展**
- **PLUGIN_MARKET** 插件市场仓库地址仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` 通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork或者查看频道置顶了解更多第三方插件仓库
- **PLUGIN_MARKET** 插件市场仓库地址仅支持Github仓库`main`分支,多个地址使用`,`分隔,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork或者查看频道置顶了解更多第三方插件仓库,目前已有 `130+` 插件。
默认已内置以下插件库:
1. https://github.com/jxxghp/MoviePilot-Plugins
2. https://github.com/thsrite/MoviePilot-Plugins
3. https://github.com/honue/MoviePilot-Plugins
4. https://github.com/InfinityPacer/MoviePilot-Plugins
## 使用
@@ -203,6 +208,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
### 3. **文件整理**
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关且仅会处理通过MoviePilot添加下载的任务。
- 下载器监控默认轮循间隔为5分钟如果是使用qbittorrent可在 `QB设置`->`下载完成时运行外部程序` 处填入:`curl "http://localhost:3000/api/v1/transfer/now?token=moviepilot" `实现无需等待轮循下载完成后立即整理入库地址、端口和token按实际调整curl也可更换为wget
- 使用`目录监控`等插件实现更灵活的自动整理。
### 4. **通知交互**
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
@@ -258,9 +264,15 @@ sudo sysctl -p
![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)
![mp1](https://github.com/jxxghp/MoviePilot/assets/51039935/123a39cd-11b6-4bd3-b3a3-e3e1103416b9)
![mp2](https://github.com/jxxghp/MoviePilot/assets/51039935/230de330-8162-4314-a897-0bce9b1e5d00)
![mp3](https://github.com/jxxghp/MoviePilot/assets/51039935/de678f45-bbe4-4bdc-aefc-cc5ae774a726)
![mp4](https://github.com/jxxghp/MoviePilot/assets/51039935/8bc6a0a5-7c57-464f-ae63-d6241310dc43)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/bfa77c71-510a-46a6-9c1e-cf98cb101e3a)
![image](https://github.com/jxxghp/MoviePilot/assets/51039935/51cafd09-e38c-47f9-ae62-1e83ab8bf89b)

View File

@@ -17,14 +17,13 @@ def calendar(page: int = 1,
"""
浏览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]
medias = BangumiChain().calendar()
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return []
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.BangumiPerson])
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
def bangumi_credits(bangumiid: int,
page: int = 1,
count: int = 20,
@@ -32,23 +31,46 @@ def bangumi_credits(bangumiid: int,
"""
查询Bangumi演职员表
"""
persons = BangumiChain().bangumi_credits(bangumiid, page=page, count=count)
if not persons:
return []
return [schemas.BangumiPerson(**person) for person in persons]
persons = BangumiChain().bangumi_credits(bangumiid)
if persons:
return persons[(page - 1) * count: page * count]
return []
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
def bangumi_recommend(bangumiid: int,
page: int = 1,
count: int = 20,
_: 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]
medias = BangumiChain().bangumi_recommend(bangumiid)
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return []
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
def bangumi_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
return BangumiChain().person_detail(person_id=person_id)
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
def bangumi_person_credits(person_id: int,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物参演作品
"""
medias = BangumiChain().person_credits(person_id=person_id)
if medias:
return [media.to_dict() for media in medias[(page - 1) * 20: page * 20]]
return []
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)

View File

@@ -1,3 +1,4 @@
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
@@ -5,10 +6,10 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.core.config import settings
from app.core.security import verify_token, verify_uri_token
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
from app.helper.directory import DirectoryHelper
from app.scheduler import Scheduler
from app.utils.system import SystemUtils
@@ -47,7 +48,8 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询存储空间信息
"""
total_storage, free_storage = SystemUtils.space_usage(settings.LIBRARY_PATHS)
library_dirs = DirectoryHelper().get_library_dirs()
total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path])
return schemas.Storage(
total_storage=total_storage,
used_storage=total_storage - free_storage
@@ -75,6 +77,10 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询下载器信息
"""
# 下载目录空间
download_dirs = DirectoryHelper().get_download_dirs()
_, free_space = SystemUtils.space_usage([Path(d.path) for d in download_dirs if d.path])
# 下载器信息
downloader_info = schemas.DownloaderInfo()
transfer_infos = DashboardChain().downloader_info()
if transfer_infos:
@@ -83,7 +89,7 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
downloader_info.upload_speed += transfer_info.upload_speed
downloader_info.download_size += transfer_info.download_size
downloader_info.upload_size += transfer_info.upload_size
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
downloader_info.free_space = free_space
return downloader_info

View File

@@ -13,7 +13,7 @@ from app.utils.http import RequestUtils
router = APIRouter()
@router.get("/img/{imgurl:path}", summary="豆瓣图片代理")
@router.get("/img", summary="豆瓣图片代理")
def douban_img(imgurl: str) -> Any:
"""
豆瓣图片代理
@@ -28,6 +28,28 @@ def douban_img(imgurl: str) -> Any:
return None
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
def douban_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
return DoubanChain().person_detail(person_id=person_id)
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
def douban_person_credits(person_id: int,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物参演作品
"""
medias = DoubanChain().person_credits(person_id=person_id, page=page)
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
def movie_showing(page: int = 1,
count: int = 30,
@@ -36,10 +58,9 @@ def movie_showing(page: int = 1,
浏览豆瓣正在热映
"""
movies = DoubanChain().movie_showing(page=page, count=count)
if not movies:
return []
medias = [MediaInfo(douban_info=movie) for movie in movies]
return [media.to_dict() for media in medias]
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
@@ -53,13 +74,9 @@ def douban_movies(sort: str = "R",
"""
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
sort=sort, tags=tags, page=page, count=count)
if not movies:
return []
medias = [MediaInfo(douban_info=movie) for movie in movies]
return [media.to_dict() for media in medias
if media.poster_path
and "movie_large.jpg" not in media.poster_path
and "tv_normal.png" not in media.poster_path]
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
@@ -73,14 +90,9 @@ def douban_tvs(sort: str = "R",
"""
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
sort=sort, tags=tags, page=page, count=count)
if not tvs:
return []
medias = [MediaInfo(douban_info=tv) for tv in tvs]
return [media.to_dict() for media in medias
if media.poster_path
and "movie_large.jpg" not in media.poster_path
and "tv_normal.jpg" not in media.poster_path
and "tv_large.jpg" not in media.poster_path]
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
@@ -90,8 +102,10 @@ def movie_top250(page: int = 1,
"""
浏览豆瓣剧集信息
"""
movies = DoubanChain().movie_top250(page=page, count=count) or []
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
movies = DoubanChain().movie_top250(page=page, count=count)
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
@@ -101,8 +115,10 @@ def tv_weekly_chinese(page: int = 1,
"""
中国每周剧集口碑榜
"""
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
@@ -112,8 +128,10 @@ def tv_weekly_global(page: int = 1,
"""
全球每周剧集口碑榜
"""
tvs = DoubanChain().tv_weekly_global(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
@@ -123,8 +141,10 @@ def tv_animation(page: int = 1,
"""
热门动画剧集
"""
tvs = DoubanChain().tv_animation(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
tvs = DoubanChain().tv_animation(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
@@ -134,8 +154,10 @@ def movie_hot(page: int = 1,
"""
热门电影
"""
movies = DoubanChain().movie_hot(page=page, count=count) or []
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
movies = DoubanChain().movie_hot(page=page, count=count)
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
@@ -145,29 +167,26 @@ def tv_hot(page: int = 1,
"""
热门电视剧
"""
tvs = DoubanChain().tv_hot(page=page, count=count) or []
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
tvs = DoubanChain().tv_hot(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.DoubanPerson])
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
def douban_credits(doubanid: str,
type_name: str,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询演员阵容type_name: 电影/电视剧
根据豆瓣ID查询演员阵容type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
doubaninfos = DoubanChain().movie_credits(doubanid=doubanid, page=page)
return DoubanChain().movie_credits(doubanid=doubanid)
elif mediatype == MediaType.TV:
doubaninfos = DoubanChain().tv_credits(doubanid=doubanid, page=page)
else:
return []
if not doubaninfos:
return []
else:
return [schemas.DoubanPerson(**doubaninfo) for doubaninfo in doubaninfos]
return DoubanChain().tv_credits(doubanid=doubanid)
return []
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
@@ -179,15 +198,14 @@ def douban_recommend(doubanid: str,
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
doubaninfos = DoubanChain().movie_recommend(doubanid=doubanid)
medias = DoubanChain().movie_recommend(doubanid=doubanid)
elif mediatype == MediaType.TV:
doubaninfos = DoubanChain().tv_recommend(doubanid=doubanid)
medias = DoubanChain().tv_recommend(doubanid=doubanid)
else:
return []
if not doubaninfos:
return []
else:
return [MediaInfo(douban_info=doubaninfo).to_dict() for doubaninfo in doubaninfos]
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)

View File

@@ -23,13 +23,13 @@ def read(
return DownloadChain().downloading()
@router.post("/", summary="添加下载", response_model=schemas.Response)
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
def download(
media_in: schemas.MediaInfo,
torrent_in: schemas.TorrentInfo,
current_user: User = Depends(get_current_active_user)) -> Any:
"""
添加下载任务
添加下载任务(含媒体信息)
"""
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
@@ -51,12 +51,12 @@ def download(
})
@router.post("/add", summary="添加下载", response_model=schemas.Response)
@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response)
def add(
torrent_in: schemas.TorrentInfo,
current_user: User = Depends(get_current_active_user)) -> Any:
"""
添加下载任
添加下载任务(不含媒体信息)
"""
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)

View File

@@ -9,8 +9,10 @@ from app.chain.transfer import TransferChain
from app.core.event import eventmanager
from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
from app.db.userauth import get_current_active_superuser
from app.schemas.types import EventType
router = APIRouter()
@@ -103,3 +105,13 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
# 删除记录
TransferHistory.delete(db, history_in.id)
return schemas.Response(success=True)
@router.get("/empty/transfer", summary="清空转移历史记录", response_model=schemas.Response)
def delete_transfer_history(db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
"""
清空转移历史记录
"""
TransferHistory.truncate(db)
return schemas.Response(success=True)

View File

@@ -15,7 +15,6 @@ from app.db import get_db
from app.db.models.user import User
from app.log import logger
from app.utils.web import WebUtils
from app.utils.otp import OtpUtils
router = APIRouter()

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import List, Any
from typing import List, Any, Union
from fastapi import APIRouter, Depends
@@ -63,18 +63,38 @@ def recognize_file2(path: str,
return recognize_file(path)
@router.get("/search", summary="搜索媒体信息", response_model=List[schemas.MediaInfo])
def search_by_title(title: str,
page: int = 1,
count: int = 8,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
def search(title: str,
type: str = "media",
page: int = 1,
count: int = 8,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
模糊搜索媒体信息列表
模糊搜索媒体/人物信息列表 media媒体信息person人物信息
"""
_, medias = MediaChain().search(title=title)
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return []
def __get_source(obj: Union[dict, schemas.MediaPerson]):
"""
获取对象属性
"""
if isinstance(obj, dict):
return obj.get("source")
return obj.source
result = []
if type == "media":
_, medias = MediaChain().search(title=title)
if medias:
result = [media.to_dict() for media in medias]
else:
result = MediaChain().search_persons(name=title)
if result:
# 按设置的顺序对结果进行排序
setting_order = settings.SEARCH_SOURCE.split(',') or []
sort_order = {}
for index, source in enumerate(setting_order):
sort_order[source] = index
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
return result[(page - 1) * count:page * count]
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
@@ -99,6 +119,14 @@ def scrape(path: str,
return schemas.Response(success=True, message="刮削完成")
@router.get("/category", summary="查询自动分类配置", response_model=dict)
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询自动分类配置
"""
return MediaChain().media_category() or {}
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
def media_info(mediaid: str, type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -1,13 +1,13 @@
from typing import Any, List
from typing import Any, List, Dict
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
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.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db import get_db
@@ -37,14 +37,14 @@ def play_item(itemid: str) -> schemas.Response:
})
@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:
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
def exists_local(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:
"""
判断本地是否存在
"""
@@ -61,35 +61,35 @@ def exists(title: str = None,
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])
@router.post("/exists_remote", summary="查询已存在的剧集信息(媒体服务器)", response_model=Dict[int, list])
def exists(media_in: schemas.MediaInfo,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据媒体信息查询媒体库已存在的剧集信息
"""
# 转化为媒体信息对象
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.dict())
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)
if not existsinfo:
return []
if media_in.season:
return {
media_in.season: existsinfo.seasons.get(media_in.season) or []
}
return existsinfo.seasons
@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)
@@ -101,18 +101,13 @@ def not_exists(media_in: schemas.MediaInfo,
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
# 转化为媒体信息对象
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.dict())
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
if mediainfo.type == MediaType.MOVIE:
# 电影已存在时返回空列表,存在时返回空对像列表
# 电影已存在时返回空列表,存在时返回空对像列表
return [] if exist_flag else [NotExistMediaInfo()]
elif no_exists and no_exists.get(mediakey):
# 电视剧返回缺失的剧集

View File

@@ -1,4 +1,3 @@
import json
from typing import Union, Any, List
from fastapi import APIRouter, BackgroundTasks, Depends

View File

@@ -1,6 +1,6 @@
from typing import Any, List
from typing import Any, List, Annotated
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Header
from app import schemas
from app.core.plugin import PluginManager
@@ -13,6 +13,29 @@ from app.schemas.types import SystemConfigKey
router = APIRouter()
def register_plugin_api(plugin_id: str = None):
"""
注册插件API先删除后新增
"""
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
router.add_api_route(**api)
def remove_plugin_api(plugin_id: str):
"""
移除插件API
"""
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> List[schemas.Plugin]:
"""
@@ -101,6 +124,8 @@ def install(plugin_id: str,
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@@ -125,6 +150,23 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token))
return PluginManager().get_plugin_page(plugin_id)
@router.get("/dashboards", summary="获取有仪表板的插件清单")
def dashboard_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
"""
获取所有插件仪表板
"""
return PluginManager().get_dashboard_plugins()
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -133,9 +175,14 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
# 删除配置
PluginManager().delete_plugin_config(plugin_id)
# 重新生效插件
PluginManager().init_plugin(plugin_id, {})
PluginManager().init_plugin(plugin_id, {
"enabled": False,
"enable": False
})
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@@ -159,6 +206,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
PluginManager().init_plugin(plugin_id, conf)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@@ -180,9 +229,10 @@ def uninstall_plugin(plugin_id: str,
PluginManager().remove_plugin(plugin_id)
# 移除插件服务
Scheduler().remove_plugin_job(plugin_id)
# 移除插件API
remove_plugin_api(plugin_id)
return schemas.Response(success=True)
# 注册插件API
for api in PluginManager().get_plugin_apis():
router.add_api_route(**api)
# 注册全部插件API
register_plugin_api()

View File

@@ -10,9 +10,12 @@ from app.chain.torrents import TorrentsChain
from app.core.event import EventManager
from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.systemconfig_oper import SystemConfigOper
from app.db.userauth import get_current_active_superuser
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
@@ -57,6 +60,7 @@ def add_site(
site_in.url = f"{_scheme}://{_netloc}/"
site_in.name = site_info.get("name")
site_in.id = None
site_in.public = 1 if site_info.get("public") else 0
site = Site(**site_in.dict())
site.create(db)
# 通知站点更新
@@ -90,24 +94,6 @@ def update_site(
return schemas.Response(success=True)
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
def delete_site(
site_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
删除站点
"""
Site.delete(db, site_id)
# 插件站点删除
EventManager().send_event(EventType.SiteDeleted,
{
"site_id": site_id
})
return schemas.Response(success=True)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
def cookie_cloud_sync(background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@@ -120,7 +106,7 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
def reset(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
_: User = Depends(get_current_active_superuser)) -> Any:
"""
清空所有站点数据并重新同步CookieCloud站点信息
"""
@@ -137,6 +123,21 @@ def reset(db: Session = Depends(get_db),
return schemas.Response(success=True, message="站点已重置!")
@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response)
def update_sites_priority(
priorities: List[dict],
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
批量更新站点优先级
"""
for priority in priorities:
site = Site.get(db, priority.get("id"))
if site:
site.update(db, {"pri": priority.get("pri")})
return schemas.Response(success=True)
@router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response)
def update_cookie(
site_id: int,
@@ -239,6 +240,22 @@ def read_site_by_domain(
return site
@router.get("/statistic/{site_url}", summary="站点统计信息", response_model=schemas.SiteStatistic)
def read_site_by_domain(
site_url: str,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
通过域名获取站点统计信息
"""
domain = StringUtils.get_url_domain(site_url)
sitestatistic = SiteStatistic.get_by_domain(db, domain)
if sitestatistic:
return sitestatistic
return schemas.SiteStatistic(domain=domain)
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
"""
@@ -273,3 +290,21 @@ def read_site(
detail=f"站点 {site_id} 不存在",
)
return site
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
def delete_site(
site_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)
) -> Any:
"""
删除站点
"""
Site.delete(db, site_id)
# 插件站点删除
EventManager().send_event(EventType.SiteDeleted,
{
"site_id": site_id
})
return schemas.Response(success=True)

View File

@@ -1,18 +1,22 @@
import json
from typing import List, Any
import cn2an
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
from sqlalchemy.orm import Session
from app import schemas
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_uri_token
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.user import User
from app.db.userauth import get_current_active_user
from app.helper.subscribe import SubscribeHelper
from app.scheduler import Scheduler
from app.schemas.types import MediaType
@@ -38,7 +42,12 @@ def read_subscribes(
subscribes = Subscribe.list(db)
for subscribe in subscribes:
if subscribe.sites:
subscribe.sites = json.loads(subscribe.sites)
try:
subscribe.sites = json.loads(str(subscribe.sites))
except json.JSONDecodeError:
subscribe.sites = []
else:
subscribe.sites = []
return subscribes
@@ -162,7 +171,10 @@ def subscribe_mediaid(
meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
if result and result.sites:
result.sites = json.loads(result.sites)
try:
result.sites = json.loads(result.sites)
except json.JSONDecodeError:
result.sites = []
return result if result else Subscribe()
@@ -197,9 +209,11 @@ def search_subscribes(
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
sid=None,
state='R',
manual=True
**{
"sid": None,
"state": 'R',
"manual": True
}
)
return schemas.Response(success=True)
@@ -215,29 +229,15 @@ def search_subscribe(
background_tasks.add_task(
Scheduler().start,
job_id="subscribe_search",
sid=subscribe_id,
state=None,
manual=True
**{
"sid": subscribe_id,
"state": None,
"manual": True
}
)
return schemas.Response(success=True)
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
def read_subscribe(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据订阅编号查询订阅信息
"""
if not subscribe_id:
return Subscribe()
subscribe = Subscribe.get(db, subscribe_id)
if subscribe and subscribe.sites:
subscribe.sites = json.loads(subscribe.sites)
return subscribe
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
def delete_subscribe_by_mediaid(
mediaid: str,
@@ -262,19 +262,6 @@ def delete_subscribe_by_mediaid(
return schemas.Response(success=True)
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
def delete_subscribe(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
删除订阅信息
"""
Subscribe.delete(db, subscribe_id)
return schemas.Response(success=True)
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
authorization: str = Header(None)) -> Any:
@@ -326,3 +313,120 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
username=user_name)
return schemas.Response(success=True)
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
def read_subscribe(
mtype: str,
page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询电影/电视剧订阅历史
"""
historys = SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
for history in historys:
if history and history.sites:
try:
history.sites = json.loads(history.sites)
except json.JSONDecodeError:
history.sites = []
return historys
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
def delete_subscribe(
history_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
删除订阅历史
"""
SubscribeHistory.delete(db, history_id)
return schemas.Response(success=True)
@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo])
def popular_subscribes(
stype: str,
page: int = 1,
count: int = 30,
min_sub: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询热门订阅
"""
subscribes = SubscribeHelper().get_statistic(stype=stype, page=page, count=count)
if subscribes:
ret_medias = []
for sub in subscribes:
# 订阅人数
count = sub.get("count")
if min_sub and count < min_sub:
continue
media = MediaInfo()
media.type = MediaType(sub.get("type"))
media.tmdb_id = sub.get("tmdbid")
# 处理标题
title = sub.get("name")
season = sub.get("season")
if season and int(season) > 1 and media.tmdb_id:
# 小写数据转大写
season_str = cn2an.an2cn(season, "low")
title = f"{title}{season_str}"
media.title = title
media.year = sub.get("year")
media.douban_id = sub.get("doubanid")
media.bangumi_id = sub.get("bangumiid")
media.tvdb_id = sub.get("tvdbid")
media.imdb_id = sub.get("imdbid")
media.season = sub.get("season")
media.overview = sub.get("description")
media.vote_average = sub.get("vote")
media.poster_path = sub.get("poster")
media.backdrop_path = sub.get("backdrop")
media.popularity = count
ret_medias.append(media)
return [media.to_dict() for media in ret_medias]
return []
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
def read_subscribe(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据订阅编号查询订阅信息
"""
if not subscribe_id:
return Subscribe()
subscribe = Subscribe.get(db, subscribe_id)
if subscribe and subscribe.sites:
try:
subscribe.sites = json.loads(subscribe.sites)
except json.JSONDecodeError:
subscribe.sites = []
return subscribe
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
def delete_subscribe(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
删除订阅信息
"""
subscribe = Subscribe.get(db, subscribe_id)
if subscribe:
subscribe.delete(db, subscribe_id)
# 统计订阅
SubscribeHelper().sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
return schemas.Response(success=True)

View File

@@ -10,10 +10,13 @@ from fastapi.responses import StreamingResponse
from app import schemas
from app.chain.search import SearchChain
from app.core.config import settings
from app.chain.system import SystemChain
from app.core.config import settings, global_vars
from app.core.module import ModuleManager
from app.core.security import verify_token
from app.db.models import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.userauth import get_current_active_superuser
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.helper.sites import SitesHelper
@@ -26,7 +29,7 @@ from version import APP_VERSION
router = APIRouter()
@router.get("/img/{proxy}/{imgurl:path}", summary="图片代理")
@router.get("/img/{proxy}", summary="图片代理")
def get_img(imgurl: str, proxy: bool = False) -> Any:
"""
通过图片代理(使用代理服务器)
@@ -43,7 +46,7 @@ def get_img(imgurl: str, proxy: bool = False) -> Any:
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
def get_env_setting(_: User = Depends(get_current_active_superuser)):
"""
查询系统环境变量,包括当前版本号
"""
@@ -54,6 +57,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
"VERSION": APP_VERSION,
"AUTH_VERSION": SitesHelper().auth_version,
"INDEXER_VERSION": SitesHelper().indexer_version,
"FRONTEND_VERSION": SystemChain().get_frontend_version()
})
return schemas.Response(success=True,
data=info)
@@ -61,7 +65,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
def set_env_setting(env: dict,
_: schemas.TokenPayload = Depends(verify_token)):
_: User = Depends(get_current_active_superuser)):
"""
更新系统环境变量
"""
@@ -95,6 +99,8 @@ def get_progress(process_type: str, token: str):
def event_generator():
while True:
if global_vars.is_system_stopped():
break
detail = progress.get(process_type)
yield 'data: %s\n\n' % json.dumps(detail)
time.sleep(0.2)
@@ -104,7 +110,7 @@ def get_progress(process_type: str, token: str):
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
def get_setting(key: str,
_: schemas.TokenPayload = Depends(verify_token)):
_: User = Depends(get_current_active_superuser)):
"""
查询系统设置
"""
@@ -119,7 +125,7 @@ def get_setting(key: str,
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
_: schemas.TokenPayload = Depends(verify_token)):
_: User = Depends(get_current_active_superuser)):
"""
更新系统设置
"""
@@ -152,6 +158,8 @@ def get_message(token: str, role: str = "sys"):
def event_generator():
while True:
if global_vars.is_system_stopped():
break
detail = message.get(role)
yield 'data: %s\n\n' % (detail or '')
time.sleep(3)
@@ -180,6 +188,8 @@ def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
for line in f.readlines()[-max(length, 50):]:
yield 'data: %s\n\n' % line
while True:
if global_vars.is_system_stopped():
break
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (t or '')
time.sleep(1)
@@ -276,9 +286,12 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询已加载的模块ID列表
"""
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
modules = [{
"id": k,
"name": v.get_name(),
} for k, v in ModuleManager().get_modules().items()]
return schemas.Response(success=True, data={
"ids": module_ids
"modules": modules
})
@@ -292,19 +305,21 @@ def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
def restart_system(_: User = Depends(get_current_active_superuser)):
"""
重启系统
"""
if not SystemUtils.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
# 标识停止事件
global_vars.stop_system()
# 执行重启
ret, msg = SystemUtils.restart()
return schemas.Response(success=ret, message=msg)
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
def reload_module(_: User = Depends(get_current_active_superuser)):
"""
重新加载模块
"""
@@ -315,7 +330,7 @@ def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
def execute_command(jobid: str,
_: schemas.TokenPayload = Depends(verify_token)):
_: User = Depends(get_current_active_superuser)):
"""
执行命令
"""

View File

@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends
from app import schemas
from app.chain.tmdb import TmdbChain
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.schemas.types import MediaType
@@ -17,10 +16,9 @@ def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
根据TMDBID查询themoviedb所有季信息
"""
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
if not seasons_info:
return []
else:
if seasons_info:
return seasons_info
return []
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
@@ -32,15 +30,14 @@ def tmdb_similar(tmdbid: int,
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
medias = TmdbChain().movie_similar(tmdbid=tmdbid)
elif mediatype == MediaType.TV:
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
medias = TmdbChain().tv_similar(tmdbid=tmdbid)
else:
return []
if not tmdbinfos:
return []
else:
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfos]
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
@@ -52,18 +49,17 @@ def tmdb_recommend(tmdbid: int,
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
medias = TmdbChain().movie_recommend(tmdbid=tmdbid)
elif mediatype == MediaType.TV:
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
medias = TmdbChain().tv_recommend(tmdbid=tmdbid)
else:
return []
if not tmdbinfos:
return []
else:
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfos]
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.TmdbPerson])
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
def tmdb_credits(tmdbid: int,
type_name: str,
page: int = 1,
@@ -73,28 +69,21 @@ def tmdb_credits(tmdbid: int,
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
persons = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
elif mediatype == MediaType.TV:
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
persons = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
else:
return []
if not tmdbinfos:
return []
else:
return [schemas.TmdbPerson(**tmdbinfo) for tmdbinfo in tmdbinfos]
return persons or []
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
def tmdb_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
if not tmdbinfo:
return schemas.TmdbPerson()
else:
return schemas.TmdbPerson(**tmdbinfo)
return TmdbChain().person_detail(person_id=person_id)
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
@@ -104,11 +93,10 @@ def tmdb_person_credits(person_id: int,
"""
根据人物ID查询人物参演作品
"""
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
if not tmdbinfo:
return []
else:
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfo]
medias = TmdbChain().person_credits(person_id=person_id, page=page)
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
@@ -127,7 +115,7 @@ def tmdb_movies(sort_by: str = "popularity.desc",
page=page)
if not movies:
return []
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
return [movie.to_dict() for movie in movies]
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
@@ -146,7 +134,7 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
page=page)
if not tvs:
return []
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
return [tv.to_dict() for tv in tvs]
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
@@ -158,7 +146,7 @@ def tmdb_trending(page: int = 1,
infos = TmdbChain().tmdb_trending(page=page)
if not infos:
return []
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
return [info.to_dict() for info in infos]
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
@@ -167,8 +155,4 @@ def tmdb_season_episodes(tmdbid: int, season: int,
"""
根据TMDBID查询某季的所有信信息
"""
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
if not episodes_info:
return []
else:
return episodes_info
return TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)

View File

@@ -19,6 +19,7 @@ def manual_transfer(path: str = None,
logid: int = None,
target: str = None,
tmdbid: int = None,
doubanid: str = None,
type_name: str = None,
season: int = None,
transfer_type: str = None,
@@ -27,6 +28,7 @@ def manual_transfer(path: str = None,
episode_part: str = None,
episode_offset: int = 0,
min_filesize: int = 0,
scrape: bool = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -36,6 +38,7 @@ def manual_transfer(path: str = None,
:param target: 目标路径
:param type_name: 媒体类型、电影/电视剧
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param season: 剧集季号
:param transfer_type: 转移类型move/copy 等
:param episode_format: 剧集识别格式
@@ -43,6 +46,7 @@ def manual_transfer(path: str = None,
:param episode_part: 剧集识别分集信息
:param episode_offset: 剧集识别偏移量
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param db: 数据库
:param _: Token校验
"""
@@ -66,10 +70,6 @@ def manual_transfer(path: str = None,
if history.dest and str(history.dest) != "None":
# 删除旧的已整理文件
transfer.delete_files(Path(history.dest))
if not target:
target = transfer.get_root_path(path=history.dest,
type_name=history.type,
category=history.category)
elif path:
in_path = Path(path)
else:
@@ -91,11 +91,13 @@ def manual_transfer(path: str = None,
in_path=in_path,
target=target,
tmdbid=tmdbid,
doubanid=doubanid,
mtype=mtype,
season=season,
transfer_type=transfer_type,
epformat=epformat,
min_filesize=min_filesize,
scrape=scrape,
force=force
)
# 失败

View File

@@ -1,6 +1,6 @@
import base64
import re
from typing import Any, List
from typing import Any, List, Union
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
@@ -10,6 +10,7 @@ from app.core.security import get_password_hash
from app.db import get_db
from app.db.models.user import User
from app.db.userauth import get_current_active_superuser, get_current_active_user
from app.db.userconfig_oper import UserConfigOper
from app.utils.otp import OtpUtils
router = APIRouter()
@@ -86,8 +87,8 @@ def read_current_user(
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
async def upload_avatar(user_id: int, db: Session = Depends(get_db),
file: UploadFile = File(...)):
async def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
_: User = Depends(get_current_active_user)):
"""
上传用户头像
"""
@@ -142,6 +143,28 @@ def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
return schemas.Response(success=user.is_otp)
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
def get_config(key: str,
current_user: User = Depends(get_current_active_user)):
"""
查询用户配置
"""
value = UserConfigOper().get(username=current_user.name, key=key)
return schemas.Response(success=True, data={
"value": value
})
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
current_user: User = Depends(get_current_active_user)):
"""
更新用户配置
"""
UserConfigOper().set(username=current_user.name, key=key, value=value)
return schemas.Response(success=True)
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
def delete_user(
*,

View File

@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.metainfo import MetaInfo
from app.core.security import verify_uri_apikey
from app.db import get_db
@@ -121,7 +120,7 @@ def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
return [
{
"id": 1,
"path": "/" if not settings.LIBRARY_PATHS else str(settings.LIBRARY_PATHS[0]),
"path": "/",
"accessible": True,
"freeSpace": 0,
"unmappedFolders": []

View File

@@ -19,7 +19,7 @@ from app.db.message_oper import MessageOper
from app.helper.message import MessageHelper
from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
WebhookEventInfo, TmdbEpisode
WebhookEventInfo, TmdbEpisode, MediaPerson
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
from app.utils.object import ObjectUtils
@@ -92,8 +92,13 @@ class ChainBase(metaclass=ABCMeta):
logger.debug(f"请求模块执行:{method} ...")
result = None
modules = self.modulemanager.get_modules(method)
modules = self.modulemanager.get_running_modules(method)
for module in modules:
try:
module_name = module.get_name()
except Exception as err:
logger.error(f"获取模块名称出错:{str(err)}")
module_name = module.__class__.__name__
try:
func = getattr(module, method)
if is_result_empty(result):
@@ -112,7 +117,10 @@ class ChainBase(metaclass=ABCMeta):
break
except Exception as err:
logger.error(
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
f"运行模块 {module.__class__.__name__}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
self.messagehelper.put(title=f"{module_name}发生了错误",
message=str(err),
role="system")
return result
def recognize_media(self, meta: MetaBase = None,
@@ -260,6 +268,13 @@ class ChainBase(metaclass=ABCMeta):
"""
return self.run_module("search_medias", meta=meta)
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
:param name: 人物名称
"""
return self.run_module("search_persons", name=name)
def search_torrents(self, site: CommentedMap,
keywords: List[str],
mtype: MediaType = None,
@@ -343,7 +358,8 @@ class ChainBase(metaclass=ABCMeta):
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None,
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
episodes_info: List[TmdbEpisode] = None,
scrape: bool = None) -> Optional[TransferInfo]:
"""
文件转移
:param path: 文件路径
@@ -352,10 +368,12 @@ class ChainBase(metaclass=ABCMeta):
:param transfer_type: 转移模式
:param target: 转移目标路径
:param episodes_info: 当前季的全部集信息
:param scrape: 是否刮削元数据
:return: {path, target_path, message}
"""
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
transfer_type=transfer_type, target=target, episodes_info=episodes_info)
transfer_type=transfer_type, target=target, episodes_info=episodes_info,
scrape=scrape)
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
@@ -491,6 +509,13 @@ class ChainBase(metaclass=ABCMeta):
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, metainfo=metainfo,
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
def media_category(self) -> Optional[Dict[str, list]]:
"""
获取媒体分类
:return: 获取二级分类配置字典项,需包括电影、电视剧、动漫
"""
return self.run_module("media_category")
def register_commands(self, commands: Dict[str, dict]) -> None:
"""
注册菜单命令

View File

@@ -1,6 +1,8 @@
from typing import Optional, List
from app import schemas
from app.chain import ChainBase
from app.core.context import MediaInfo
from app.utils.singleton import Singleton
@@ -9,13 +11,11 @@ class BangumiChain(ChainBase, metaclass=Singleton):
Bangumi处理链单例运行
"""
def calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def calendar(self) -> Optional[List[MediaInfo]]:
"""
获取Bangumi每日放送
:param page: 页码
:param count: 每页数量
"""
return self.run_module("bangumi_calendar", page=page, count=count)
return self.run_module("bangumi_calendar")
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
@@ -25,18 +25,30 @@ class BangumiChain(ChainBase, metaclass=Singleton):
"""
return self.run_module("bangumi_info", bangumiid=bangumiid)
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
"""
根据BangumiID查询电影演职员表
:param bangumiid: BangumiID
:param page: 页码
:param count: 数量
"""
return self.run_module("bangumi_credits", bangumiid=bangumiid, page=page, count=count)
return self.run_module("bangumi_credits", bangumiid=bangumiid)
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
def bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:
"""
根据BangumiID查询推荐电影
:param bangumiid: BangumiID
"""
return self.run_module("bangumi_recommend", bangumiid=bangumiid)
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据人物ID查询Bangumi人物详情
:param person_id: 人物ID
"""
return self.run_module("bangumi_person_detail", person_id=person_id)
def person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:
"""
根据人物ID查询人物参演作品
:param person_id: 人物ID
"""
return self.run_module("bangumi_person_credits", person_id=person_id)

View File

@@ -1,7 +1,8 @@
from typing import Optional, List
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo
from app.schemas import MediaType
from app.utils.singleton import Singleton
@@ -11,7 +12,22 @@ class DoubanChain(ChainBase, metaclass=Singleton):
豆瓣处理链,单例运行
"""
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据人物ID查询豆瓣人物详情
:param person_id: 人物ID
"""
return self.run_module("douban_person_detail", person_id=person_id)
def person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
"""
根据人物ID查询人物参演作品
:param person_id: 人物ID
:param page: 页码
"""
return self.run_module("douban_person_credits", person_id=person_id, page=page)
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取豆瓣电影TOP250
:param page: 页码
@@ -19,26 +35,26 @@ class DoubanChain(ChainBase, metaclass=Singleton):
"""
return self.run_module("movie_top250", page=page, count=count)
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取正在上映的电影
"""
return self.run_module("movie_showing", page=page, count=count)
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取本周中国剧集榜
"""
return self.run_module("tv_weekly_chinese", page=page, count=count)
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取本周全球剧集榜
"""
return self.run_module("tv_weekly_global", page=page, count=count)
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
page: int = 0, count: int = 30) -> Optional[List[dict]]:
page: int = 0, count: int = 30) -> Optional[List[MediaInfo]]:
"""
发现豆瓣电影、剧集
:param mtype: 媒体类型
@@ -51,52 +67,46 @@ class DoubanChain(ChainBase, metaclass=Singleton):
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
page=page, count=count)
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取动画剧集
"""
return self.run_module("tv_animation", page=page, count=count)
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取热门电影
"""
if settings.RECOGNIZE_SOURCE != "douban":
return None
return self.run_module("movie_hot", page=page, count=count)
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取热门剧集
"""
if settings.RECOGNIZE_SOURCE != "douban":
return None
return self.run_module("tv_hot", page=page, count=count)
def movie_credits(self, doubanid: str, page: int = 1) -> List[dict]:
def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电影演职人员
:param doubanid: 豆瓣ID
:param page: 页码
"""
return self.run_module("douban_movie_credits", doubanid=doubanid, page=page)
return self.run_module("douban_movie_credits", doubanid=doubanid)
def tv_credits(self, doubanid: str, page: int = 1) -> List[dict]:
def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电视剧演职人员
:param doubanid: 豆瓣ID
:param page: 页码
"""
return self.run_module("douban_tv_credits", doubanid=doubanid, page=page)
return self.run_module("douban_tv_credits", doubanid=doubanid)
def movie_recommend(self, doubanid: str) -> List[dict]:
def movie_recommend(self, doubanid: str) -> List[MediaInfo]:
"""
根据豆瓣ID查询推荐电影
:param doubanid: 豆瓣ID
"""
return self.run_module("douban_movie_recommend", doubanid=doubanid)
def tv_recommend(self, doubanid: str) -> List[dict]:
def tv_recommend(self, doubanid: str) -> List[MediaInfo]:
"""
根据豆瓣ID查询推荐电视剧
:param doubanid: 豆瓣ID

View File

@@ -14,6 +14,8 @@ from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.mediaserver_oper import MediaServerOper
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
@@ -32,9 +34,12 @@ class DownloadChain(ChainBase):
self.torrent = TorrentHelper()
self.downloadhis = DownloadHistoryOper()
self.mediaserver = MediaServerOper()
self.directoryhelper = DirectoryHelper()
self.messagehelper = MessageHelper()
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
channel: MessageChannel = None, userid: str = None, username: str = None):
channel: MessageChannel = None, userid: str = None, username: str = None,
download_episodes: str = None):
"""
发送添加下载的消息
:param meta: 元数据
@@ -43,6 +48,7 @@ class DownloadChain(ChainBase):
:param channel: 通知渠道
:param userid: 用户ID指定时精确发送对应用户
:param username: 通知显示的下载用户信息
:param download_episodes: 下载的集数
"""
msg_text = ""
if username:
@@ -80,7 +86,7 @@ class DownloadChain(ChainBase):
mtype=NotificationType.Download,
userid=userid,
title=f"{mediainfo.title_year} "
f"{meta.season_episode} 开始下载",
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
text=msg_text,
image=mediainfo.get_message_image()))
@@ -207,6 +213,9 @@ class DownloadChain(ChainBase):
_torrent = context.torrent_info
_media = context.media_info
_meta = context.meta_info
# 实际下载的集数
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
_folder_name = ""
if not torrent_file:
# 下载种子文件,得到的可能是文件也可能是磁力链
@@ -222,35 +231,26 @@ class DownloadChain(ChainBase):
# 下载目录
if not save_path:
if settings.DOWNLOAD_CATEGORY and _media and _media.category:
# 开启下载二级目录
if _media.type == MediaType.MOVIE:
# 电影
download_dir = settings.SAVE_MOVIE_PATH / _media.category
else:
if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
download_dir = settings.SAVE_ANIME_PATH / _media.category
else:
# 电视剧
download_dir = settings.SAVE_TV_PATH / _media.category
elif _media:
# 未开启下载二级目录
if _media.type == MediaType.MOVIE:
# 电影
download_dir = settings.SAVE_MOVIE_PATH
else:
if _media.genre_ids \
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
download_dir = settings.SAVE_ANIME_PATH
else:
# 电视剧
download_dir = settings.SAVE_TV_PATH
# 获取下载目录
dir_info = self.directoryhelper.get_download_dir(_media)
if not dir_info:
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
title="下载失败", role="system")
return None
# 一级目录
if not dir_info.media_type and dir_info.auto_category:
# 一级自动分类
download_dir = Path(dir_info.path) / _media.type.value
else:
# 未识别
download_dir = settings.SAVE_PATH
# 一级不分类
download_dir = Path(dir_info.path)
# 二级目录
if not dir_info.category and dir_info.auto_category and _media and _media.category:
# 二级自动分类
download_dir = download_dir / _media.category
else:
# 自定义下载目录
download_dir = Path(save_path)
@@ -284,7 +284,7 @@ class DownloadChain(ChainBase):
tvdbid=_media.tvdb_id,
doubanid=_media.douban_id,
seasons=_meta.season,
episodes=_meta.episode,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
download_hash=_hash,
torrent_name=_torrent.title,
@@ -321,7 +321,8 @@ class DownloadChain(ChainBase):
self.downloadhis.add_files(files_to_add)
# 发送消息群发不带channel和userid
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, username=username)
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
username=username, download_episodes=download_episodes)
# 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
# 广播事件
@@ -429,12 +430,15 @@ class DownloadChain(ChainBase):
# 如果是电影,直接下载
for context in contexts:
if context.media_info.type == MediaType.MOVIE:
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
if self.download_single(context, save_path=save_path, channel=channel,
userid=userid, username=username):
# 下载成功
logger.info(f"{context.torrent_info.title} 添加下载成功")
downloaded_list.append(context)
# 电视剧整季匹配
logger.info(f"开始匹配电视剧整季:{no_exists}")
if no_exists:
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
need_seasons: Dict[int, list] = {}
@@ -447,6 +451,7 @@ class DownloadChain(ChainBase):
if not need_seasons.get(need_mid):
need_seasons[need_mid] = []
need_seasons[need_mid].append(tv.season or 1)
logger.info(f"缺失整季:{need_seasons}")
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
for need_mid, need_season in need_seasons.items():
# 循环种子
@@ -471,14 +476,16 @@ class DownloadChain(ChainBase):
if set(torrent_season).issubset(set(need_season)):
if len(torrent_season) == 1:
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
logger.info(f"开始下载种子 {torrent.title} ...")
content, _, torrent_files = self.download_torrent(torrent)
if not content:
logger.warn(f"{torrent.title} 种子下载失败!")
continue
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
continue
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
if not torrent_episodes:
continue
# 更新集数范围
@@ -489,10 +496,11 @@ class DownloadChain(ChainBase):
need_total = __get_season_episodes(need_mid, torrent_season[0])
if len(torrent_episodes) < need_total:
logger.info(
f"{meta.org_string} 解析文件集数发现不是完整合集")
f"{meta.org_string} 解析文件集数发现不是完整合集,先放弃这个种子")
continue
else:
# 下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(
context=context,
torrent_file=content if isinstance(content, Path) else None,
@@ -503,12 +511,14 @@ class DownloadChain(ChainBase):
)
else:
# 下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(context,
save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id:
# 下载成功
logger.info(f"{torrent.title} 添加下载成功")
downloaded_list.append(context)
# 更新仍需季集
need_season = __update_seasons(_mid=need_mid,
@@ -518,6 +528,7 @@ class DownloadChain(ChainBase):
# 全部下载完成
break
# 电视剧季内的集匹配
logger.info(f"开始电视剧完整集匹配:{no_exists}")
if no_exists:
# TMDBID列表
need_tv_list = list(no_exists)
@@ -567,11 +578,13 @@ class DownloadChain(ChainBase):
# 为需要集的子集则下载
if torrent_episodes.issubset(set(need_episodes)):
# 下载
logger.info(f"开始下载 {meta.title} ...")
download_id = self.download_single(context,
save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id:
# 下载成功
logger.info(f"{meta.title} 添加下载成功")
downloaded_list.append(context)
# 更新仍需集数
need_episodes = __update_episodes(_mid=need_mid,
@@ -580,6 +593,7 @@ class DownloadChain(ChainBase):
_current=torrent_episodes)
# 仍然缺失的剧集从整季中选择需要的集数文件下载仅支持QB和TR
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
if no_exists:
# TMDBID列表
no_exists_list = list(no_exists)
@@ -625,15 +639,17 @@ class DownloadChain(ChainBase):
and len(meta.season_list) == 1 \
and meta.season_list[0] == need_season:
# 检查种子看是否有需要的集
logger.info(f"开始下载种子 {torrent.title} ...")
content, _, torrent_files = self.download_torrent(torrent)
if not content:
logger.info(f"{torrent.title} 种子下载失败!")
continue
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
continue
# 种子全部集
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
logger.info(f"{torrent.site_name} - {meta.org_string} 解析文件集数:{torrent_episodes}")
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
# 选中的集
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
if not selected_episodes:
@@ -641,6 +657,7 @@ class DownloadChain(ChainBase):
continue
logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}")
# 添加下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(
context=context,
torrent_file=content if isinstance(content, Path) else None,
@@ -653,6 +670,7 @@ class DownloadChain(ChainBase):
if not download_id:
continue
# 下载成功
logger.info(f"{torrent.title} 添加下载成功")
downloaded_list.append(context)
# 更新种子集数范围
begin_ep = min(torrent_episodes)
@@ -665,6 +683,7 @@ class DownloadChain(ChainBase):
_current=selected_episodes)
# 返回下载的资源,剩下没下完的
logger.info(f"成功下载种子数:{len(downloaded_list)},剩余未下载的剧集:{no_exists}")
return downloaded_list, no_exists
def get_no_exists_info(self, meta: MetaBase,

View File

@@ -34,7 +34,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
if not mediainfo:
# 试使用辅助识别,如果有注册响应事件的话
# 试使用辅助识别,如果有注册响应事件的话
if eventmanager.check(EventType.NameRecognize):
logger.info(f'请求辅助识别,标题:{title} ...')
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
@@ -143,7 +143,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 识别媒体信息
mediainfo = self.recognize_media(meta=file_meta)
if not mediainfo:
# 试使用辅助识别,如果有注册响应事件的话
# 试使用辅助识别,如果有注册响应事件的话
if eventmanager.check(EventType.NameRecognize):
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
@@ -156,9 +156,9 @@ class MediaChain(ChainBase, metaclass=Singleton):
# 返回上下文
return Context(meta_info=file_meta, media_info=mediainfo)
def search(self, title: str) -> Tuple[MetaBase, List[MediaInfo]]:
def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
"""
搜索媒体信息
搜索媒体/人物信息
:param title: 搜索内容
:return: 识别元数据,媒体信息列表
"""

View File

@@ -102,12 +102,23 @@ class SearchChain(ChainBase):
:param filter_rule: 过滤规则,为空是使用默认过滤规则
:param area: 搜索范围title or imdbid
"""
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
"""
执行优先级过滤
"""
return self.filter_torrents(rule_string=priority_rule,
torrent_list=torrent_list,
season_episodes=season_episodes,
mediainfo=mediainfo) or []
# 豆瓣标题处理
if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title)
mediainfo.title = meta.name
mediainfo.season = meta.begin_season
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
@@ -116,17 +127,19 @@ class SearchChain(ChainBase):
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
# 缺失的季集
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
if no_exists and no_exists.get(mediakey):
# 过滤剧集
season_episodes = {sea: info.episodes
for sea, info in no_exists[mediainfo.tmdb_id].items()}
for sea, info in no_exists[mediakey].items()}
elif mediainfo.season:
# 豆瓣只搜索当前季
season_episodes = {mediainfo.season: []}
else:
season_episodes = None
# 搜索关键词
if keyword:
keywords = [keyword]
@@ -147,9 +160,11 @@ class SearchChain(ChainBase):
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
return []
# 开始新进度
self.progress.start(ProgressKey.Search)
# 匹配的资源
# 开始匹配
_match_torrents = []
# 总数
_total = len(torrents)
@@ -177,17 +192,6 @@ class SearchChain(ChainBase):
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != torrent_meta.org_string:
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
logger.info(f'{mediainfo.title} 通过词表指定TMDBID匹配到资源{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent)
continue
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
logger.info(f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源{torrent.site_name} - {torrent.title}')
_match_torrents.append(torrent)
continue
# 比对种子
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
@@ -202,25 +206,12 @@ class SearchChain(ChainBase):
key=ProgressKey.Search)
else:
_match_torrents = 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,
@@ -229,22 +220,43 @@ class SearchChain(ChainBase):
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
logger.info(f"过滤规则过滤完成,剩余 {len(_match_torrents)} 个资源")
# 开始优先级规则/剧集过滤
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始优先级规则/剧集过滤,当前规则:{priority_rule} ...')
_match_torrents = __do_filter(_match_torrents)
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
logger.info(f"优先级规则/剧集过滤完成,剩余 {len(_match_torrents)} 个资源")
# 去掉mediainfo中多余的数据
mediainfo.clear()
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
media_info=mediainfo,
torrent_info=torrent) for torrent in _match_torrents]
logger.info(f"过滤完成,剩余 {len(contexts)} 个资源")
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
# 排序
self.progress.update(value=100,
self.progress.update(value=99,
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
key=ProgressKey.Search)
contexts = self.torrenthelper.sort_torrents(contexts)
# 结束进度
self.progress.update(value=100,
text=f'搜索完成,共 {len(contexts)} 个资源',
key=ProgressKey.Search)
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
self.progress.end(ProgressKey.Search)
# 返回
return contexts

View File

@@ -1,5 +1,6 @@
import base64
import re
from datetime import datetime
from typing import Tuple, Optional
from typing import Union
from urllib.parse import urljoin
@@ -13,6 +14,7 @@ from app.db.models.site import Site
from app.db.site_oper import SiteOper
from app.db.siteicon_oper import SiteIconOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.sitestatistic_oper import SiteStatisticOper
from app.helper.browser import PlaywrightHelper
from app.helper.cloudflare import under_challenge
from app.helper.cookie import CookieHelper
@@ -43,13 +45,17 @@ class SiteChain(ChainBase):
self.message = MessageHelper()
self.cookiecloud = CookieCloudHelper()
self.systemconfig = SystemConfigOper()
self.sitestatistic = SiteStatisticOper()
# 特殊站点登录验证
self.special_site_test = {
"zhuque.in": self.__zhuque_test,
"m-team.io": self.__mteam_test,
"m-team.cc": self.__mteam_test,
"ptlsp.com": self.__ptlsp_test,
"ptlsp.com": self.__indexphp_test,
"1ptba.com": self.__indexphp_test,
"star-space.net": self.__indexphp_test,
"yemapt.org": self.__yema_test,
}
def is_special_site(self, domain: str) -> bool:
@@ -70,7 +76,7 @@ class SiteChain(ChainBase):
ua=user_agent,
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
timeout=site.timeout or 15
).get_res(url=site.url)
if res and res.status_code == 200:
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
@@ -87,7 +93,7 @@ class SiteChain(ChainBase):
},
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=15
timeout=site.timeout or 15
).get_res(url=f"{site.url}api/user/getInfo")
if user_res and user_res.status_code == 200:
user_info = user_res.json()
@@ -102,19 +108,23 @@ class SiteChain(ChainBase):
"""
user_agent = site.ua or settings.USER_AGENT
url = f"{site.url}api/member/profile"
headers = {
"Content-Type": "application/json",
"User-Agent": user_agent,
"Accept": "application/json, text/plain, */*",
"Authorization": site.token
}
res = RequestUtils(
ua=user_agent,
cookies=site.cookie,
headers=headers,
proxies=settings.PROXY if site.proxy else None,
timeout=15
timeout=site.timeout or 15
).post_res(url=url)
if res and res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("data"):
# 更新最后访问时间
res = RequestUtils(cookies=site.cookie,
ua=user_agent,
timeout=60,
res = RequestUtils(headers=headers,
timeout=site.timeout or 15,
proxies=settings.PROXY if site.proxy else None,
referer=f"{site.url}index"
).post_res(url=urljoin(url, "api/member/updateLastBrowse"))
@@ -122,11 +132,35 @@ class SiteChain(ChainBase):
return True, "连接成功"
else:
return True, f"连接成功,但更新状态失败"
return False, "Cookie已失"
return False, "鉴权已过期或无"
def __ptlsp_test(self, site: Site) -> Tuple[bool, str]:
@staticmethod
def __yema_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆:ptlsp
判断站点是否已经登陆:yemapt
"""
user_agent = site.ua or settings.USER_AGENT
url = f"{site.url}api/consumer/fetchSelfDetail"
headers = {
"User-Agent": user_agent,
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
}
res = RequestUtils(
headers=headers,
cookies=site.cookie,
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=url)
if res and res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("success"):
return True, "连接成功"
return False, "Cookie已过期"
def __indexphp_test(self, site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆ptlsp/1ptba
"""
site.url = f"{site.url}index.php"
return self.__test(site)
@@ -141,7 +175,7 @@ class SiteChain(ChainBase):
:return:
"""
favicon_url = urljoin(url, "favicon.ico")
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
res = RequestUtils(cookies=cookie, timeout=30, ua=ua).get_res(url=url)
if res:
html_text = res.text
else:
@@ -153,7 +187,7 @@ class SiteChain(ChainBase):
if fav_link:
favicon_url = urljoin(url, fav_link[0])
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
@@ -181,7 +215,7 @@ class SiteChain(ChainBase):
if not cookies:
logger.error(f"CookieCloud同步失败{msg}")
if manual:
self.message.put(f"CookieCloud同步失败 {msg}")
self.message.put(msg, title="CookieCloud同步失败", role="system")
return False, msg
# 保存Cookie或新增站点
_update_count = 0
@@ -269,7 +303,7 @@ class SiteChain(ChainBase):
if _fail_count > 0:
ret_msg += f"{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.message.put(f"CookieCloud同步成功, {ret_msg}")
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
logger.info(f"CookieCloud同步成功{ret_msg}")
return True, ret_msg
@@ -350,12 +384,21 @@ class SiteChain(ChainBase):
# 模拟登录
try:
# 开始记时
start_time = datetime.now()
# 特殊站点测试
if self.special_site_test.get(domain):
return self.special_site_test[domain](site_info)
# 通用站点测试
return self.__test(site_info)
state, message = self.special_site_test[domain](site_info)
else:
# 通用站点测试
state, message = self.__test(site_info)
# 统计
seconds = (datetime.now() - start_time).seconds
if state:
self.sitestatistic.success(domain=domain, seconds=seconds)
else:
self.sitestatistic.fail(domain)
return state, message
except Exception as e:
return False, f"{str(e)}"

View File

@@ -2,6 +2,7 @@ import json
import random
import time
from datetime import datetime
from json import JSONDecodeError
from typing import Dict, List, Optional, Union, Tuple
from app.chain import ChainBase
@@ -15,9 +16,12 @@ from app.core.event import eventmanager, Event, EventManager
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo
from app.db.models.subscribe import Subscribe
from app.db.site_oper import SiteOper
from app.db.subscribe_oper import SubscribeOper
from app.db.subscribehistory_oper import SubscribeHistoryOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
from app.helper.subscribe import SubscribeHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo, Notification
@@ -34,11 +38,14 @@ class SubscribeChain(ChainBase):
self.downloadchain = DownloadChain()
self.searchchain = SearchChain()
self.subscribeoper = SubscribeOper()
self.subscribehistoryoper = SubscribeHistoryOper()
self.subscribehelper = SubscribeHelper()
self.torrentschain = TorrentsChain()
self.mediachain = MediaChain()
self.message = MessageHelper()
self.systemconfig = SystemConfigOper()
self.torrenthelper = TorrentHelper()
self.siteoper = SiteOper()
def add(self, title: str, year: str,
mtype: MediaType = None,
@@ -120,6 +127,9 @@ class SubscribeChain(ChainBase):
kwargs.update({
'lack_episode': kwargs.get('total_episode')
})
else:
# 避免season为0的问题
season = None
# 更新媒体图片
self.obtain_images(mediainfo=mediainfo)
# 合并信息
@@ -134,7 +144,7 @@ class SubscribeChain(ChainBase):
'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"),
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version") if not kwargs.get("best_version") else kwargs.get("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"),
@@ -151,6 +161,7 @@ class SubscribeChain(ChainBase):
text=f"{err_msg}",
image=mediainfo.get_message_image(),
userid=userid))
return None, err_msg
elif message:
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
if username:
@@ -162,12 +173,28 @@ class SubscribeChain(ChainBase):
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
text=text,
image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeAdded, {
"subscribe_id": sid,
"username": username,
"mediainfo": mediainfo.to_dict(),
})
# 发送事件
EventManager().send_event(EventType.SubscribeAdded, {
"subscribe_id": sid,
"username": username,
"mediainfo": mediainfo.to_dict(),
})
# 统计订阅
self.subscribehelper.sub_reg_async({
"name": title,
"year": year,
"type": metainfo.type.value,
"tmdbid": mediainfo.tmdb_id,
"imdbid": mediainfo.imdb_id,
"tvdbid": mediainfo.tvdb_id,
"doubanid": mediainfo.douban_id,
"bangumiid": mediainfo.bangumi_id,
"season": metainfo.begin_season,
"poster": mediainfo.get_poster_image(),
"backdrop": mediainfo.get_backdrop_image(),
"vote": mediainfo.vote_average,
"description": mediainfo.overview
})
# 返回结果
return sid, ""
@@ -354,9 +381,9 @@ class SubscribeChain(ChainBase):
# 手动触发时发送系统消息
if manual:
if sid:
self.message.put(f'订阅 {subscribes[0].name} 搜索完成!')
self.message.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
else:
self.message.put('所有订阅搜索完成!')
self.message.put('所有订阅搜索完成!', title="订阅搜索", role="system")
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
mediainfo: MediaInfo, downloads: List[Context]):
@@ -370,17 +397,8 @@ class SubscribeChain(ChainBase):
# 当前下载资源的优先级
priority = max([item.torrent_info.pri_order for item in downloads])
if priority == 100:
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,
"mediainfo": mediainfo.to_dict(),
})
# 洗版完成
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo, bestversion=True)
else:
# 正在洗版,更新资源优先级
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
@@ -404,18 +422,8 @@ class SubscribeChain(ChainBase):
if ((no_lefts and meta.type == MediaType.TV)
or (downloads and meta.type == MediaType.MOVIE)
or force):
# 全部下载完成
logger.info(f'{mediainfo.title_year} 完成订阅')
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,
"mediainfo": mediainfo.to_dict(),
})
# 完成订阅
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
elif downloads and meta.type == MediaType.TV:
# 电视剧更新已下载集数
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
@@ -454,7 +462,10 @@ class SubscribeChain(ChainBase):
获取订阅中涉及的站点清单
"""
if subscribe.sites:
return json.loads(subscribe.sites)
try:
return json.loads(subscribe.sites)
except JSONDecodeError:
return []
# 默认站点
return self.systemconfig.get(SystemConfigKey.RssSites) or []
@@ -493,6 +504,7 @@ class SubscribeChain(ChainBase):
"tv_size": default_rule.get("tv_size"),
"movie_size": default_rule.get("movie_size"),
"min_seeders": default_rule.get("min_seeders"),
"min_seeders_time": default_rule.get("min_seeders_time"),
}
def match(self, torrents: Dict[str, List[Context]]):
@@ -502,6 +514,8 @@ class SubscribeChain(ChainBase):
if not torrents:
logger.warn('没有缓存资源,无法匹配订阅')
return
# 记录重新识别过的种子
_recognize_cached = []
# 所有订阅
subscribes = self.subscribeoper.list('R')
# 遍历订阅
@@ -513,6 +527,15 @@ class SubscribeChain(ChainBase):
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
# 订阅的站点域名列表
domains = []
if subscribe.sites:
try:
siteids = json.loads(subscribe.sites)
if siteids:
domains = self.siteoper.get_domains_by_ids(siteids)
except JSONDecodeError:
pass
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
@@ -581,15 +604,38 @@ class SubscribeChain(ChainBase):
# 遍历缓存种子
_match_context = []
for domain, contexts in torrents.items():
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
if domains and domain not in domains:
continue
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
for context in contexts:
# 检查是否匹配
torrent_meta = context.meta_info
torrent_mediainfo = context.media_info
torrent_info = context.torrent_info
# 如果识别了媒体信息则比对TMDBID和类型
if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
# 直接比对媒体信息
# 先判断是否有没识别的种子
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
_cache_key = f"{torrent_info.title}_{torrent_info.description}"
if _cache_key not in _recognize_cached:
_recognize_cached.append(_cache_key)
logger.info(f'{torrent_info.site_name} - {torrent_info.title} 订阅缓存为未识别状态,尝试重新识别...')
# 重新识别(不使用缓存)
torrent_mediainfo = self.recognize_media(meta=torrent_meta, cache=False)
if not torrent_mediainfo:
logger.warn(f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info):
# 匹配成功
logger.info(f'{mediainfo.title_year} 通过标题匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
# 更新缓存
torrent_mediainfo = mediainfo
context.media_info = mediainfo
else:
continue
# 直接比对媒体信息
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
if torrent_mediainfo.type != mediainfo.type:
continue
if torrent_mediainfo.tmdb_id \
@@ -601,22 +647,8 @@ class SubscribeChain(ChainBase):
logger.info(
f'{mediainfo.title_year} 通过媒体信ID匹配到资源{torrent_info.site_name} - {torrent_info.title}')
else:
# 没有torrent_mediainfo媒体信息按标题匹配
manual_match = False
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if torrent_meta.tmdbid and torrent_meta.tmdbid != mediainfo.tmdb_id:
continue
if torrent_meta.doubanid and torrent_meta.doubanid != mediainfo.douban_id:
continue
manual_match = True
if not manual_match:
# 没有指定tmdbid按标题匹配
if not self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent_info,
logerror=False):
continue
continue
# 优先级过滤规则
if subscribe.best_version:
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
@@ -628,28 +660,28 @@ class SubscribeChain(ChainBase):
mediainfo=torrent_mediainfo)
if result is not None and not result:
# 不符合过滤规则
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
logger.debug(f"{torrent_info.title} 不匹配当前过滤规则")
continue
# 不在订阅站点范围的不处理
sub_sites = self.get_sub_sites(subscribe)
if sub_sites and torrent_info.site not in sub_sites:
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
continue
# 如果是电视剧
if torrent_mediainfo.type == MediaType.TV:
# 有多季的不要
if len(torrent_meta.season_list) > 1:
logger.info(f'{torrent_info.title} 有多季,不处理')
logger.debug(f'{torrent_info.title} 有多季,不处理')
continue
# 比对季
if torrent_meta.begin_season:
if meta.begin_season != torrent_meta.begin_season:
logger.info(f'{torrent_info.title} 季不匹配')
logger.debug(f'{torrent_info.title} 季不匹配')
continue
elif meta.begin_season != 1:
logger.info(f'{torrent_info.title} 季不匹配')
logger.debug(f'{torrent_info.title} 季不匹配')
continue
# 非洗版
if not subscribe.best_version:
@@ -664,7 +696,7 @@ class SubscribeChain(ChainBase):
not set(no_exists_info.episodes).intersection(
set(torrent_meta.episode_list)
):
logger.info(
logger.debug(
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
)
continue
@@ -676,7 +708,7 @@ class SubscribeChain(ChainBase):
# 洗版时,非整季不要
if meta.type == MediaType.TV:
if torrent_meta.episode_list:
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
continue
# 过滤规则
@@ -774,7 +806,10 @@ class SubscribeChain(ChainBase):
return
note = []
if subscribe.note:
note = json.loads(subscribe.note)
try:
note = json.loads(subscribe.note)
except JSONDecodeError:
note = []
for context in downloads:
meta = context.meta_info
mediainfo = context.media_info
@@ -805,7 +840,10 @@ class SubscribeChain(ChainBase):
return False
if not episodes:
return False
note = json.loads(subscribe.note)
try:
note = json.loads(subscribe.note)
except JSONDecodeError:
return False
if set(episodes).issubset(set(note)):
return True
return False
@@ -842,6 +880,36 @@ class SubscribeChain(ChainBase):
"lack_episode": lack_episode
})
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo,
meta: MetaBase, bestversion: bool = False):
"""
完成订阅
"""
# 完成订阅
msgstr = "订阅"
if bestversion:
msgstr = "洗版"
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
# 新增订阅历史
self.subscribehistoryoper.add(**subscribe.to_dict())
# 删除订阅
self.subscribeoper.delete(subscribe.id)
# 发送通知
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
image=mediainfo.get_message_image()))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,
"subscribe_info": subscribe.to_dict(),
"mediainfo": mediainfo.to_dict(),
})
# 统计订阅
self.subscribehelper.sub_done_async({
"tmdbid": mediainfo.tmdb_id,
"doubanid": mediainfo.douban_id
})
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
查询订阅并发送消息
@@ -890,6 +958,11 @@ class SubscribeChain(ChainBase):
return
# 删除订阅
self.subscribeoper.delete(subscribe_id)
# 统计订阅
self.subscribehelper.sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
# 重新发送消息
self.remote_list(channel, userid)
@@ -982,7 +1055,10 @@ class SubscribeChain(ChainBase):
for subscribe in self.subscribeoper.list():
if not subscribe.sites:
continue
sites = json.loads(subscribe.sites) or []
try:
sites = json.loads(subscribe.sites)
except JSONDecodeError:
sites = []
if site_id not in sites:
continue
sites.remove(site_id)

View File

@@ -1,5 +1,6 @@
import json
import re
from pathlib import Path
from typing import Union
from app.chain import ChainBase
@@ -40,19 +41,31 @@ class SystemChain(ChainBase, metaclass=Singleton):
}, self._restart_file)
SystemUtils.restart()
def __get_version_message(self) -> str:
"""
获取版本信息文本
"""
server_release_version = self.__get_server_release_version()
front_release_version = self.__get_front_release_version()
server_local_version = self.get_server_local_version()
front_local_version = self.get_frontend_version()
if server_release_version == server_local_version:
title = f"当前后端版本:{server_local_version},已是最新版本\n"
else:
title = f"当前后端版本:{server_local_version},远程版本:{server_release_version}\n"
if front_release_version == front_local_version:
title += f"当前前端版本:{front_local_version},已是最新版本"
else:
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
return title
def version(self, channel: MessageChannel, userid: Union[int, str]):
"""
查看当前版本、远程版本
"""
release_version = self.__get_release_version()
local_version = self.get_local_version()
if release_version == local_version:
title = f"当前版本:{local_version},已是最新版本"
else:
title = f"当前版本:{local_version},远程版本:{release_version}"
self.post_message(Notification(channel=channel,
title=title, userid=userid))
title=self.__get_version_message(),
userid=userid))
def restart_finish(self):
"""
@@ -71,33 +84,50 @@ class SystemChain(ChainBase, metaclass=Singleton):
userid = restart_channel.get('userid')
# 版本号
release_version = self.__get_release_version()
local_version = self.get_local_version()
if release_version == local_version:
title = f"当前版本:{local_version}"
else:
title = f"当前版本:{local_version},远程版本:{release_version}"
title = self.__get_version_message()
self.post_message(Notification(channel=channel,
title=f"系统已重启完成!{title}",
title=f"系统已重启完成!\n{title}",
userid=userid))
self.remove_cache(self._restart_file)
@staticmethod
def __get_release_version():
def __get_server_release_version():
"""
获取最新版本
获取后端最新版本
"""
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
try:
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
except Exception as err:
logger.error(f"获取后端最新版本失败:{str(err)}")
return None
@staticmethod
def get_local_version():
def __get_front_release_version():
"""
获取前端最新版本
"""
try:
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
return None
except Exception as err:
logger.error(f"获取前端最新版本失败:{str(err)}")
return None
@staticmethod
def get_server_local_version():
"""
查看当前版本
"""
@@ -117,3 +147,20 @@ class SystemChain(ChainBase, metaclass=Singleton):
return None
except Exception as err:
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
@staticmethod
def get_frontend_version():
"""
获取前端版本
"""
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
if version_file.exists():
try:
with open(version_file, 'r') as f:
version = str(f.read()).strip()
return version
except Exception as err:
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
else:
logger.warn("未找到前端版本文件,请正确设置 FRONTEND_PATH")
return None

View File

@@ -6,6 +6,7 @@ from cachetools import cached, TTLCache
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo
from app.schemas import MediaType
from app.utils.singleton import Singleton
@@ -16,7 +17,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
"""
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
with_original_language: str, page: int = 1) -> Optional[List[dict]]:
with_original_language: str, page: int = 1) -> Optional[List[MediaInfo]]:
"""
:param mtype: 媒体类型
:param sort_by: 排序方式
@@ -25,21 +26,17 @@ class TmdbChain(ChainBase, metaclass=Singleton):
:param page: 页码
:return: 媒体信息列表
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
return None
return self.run_module("tmdb_discover", mtype=mtype,
sort_by=sort_by, with_genres=with_genres,
with_original_language=with_original_language,
page=page)
def tmdb_trending(self, page: int = 1) -> Optional[List[dict]]:
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
"""
TMDB流行趋势
:param page: 第几页
:return: TMDB信息列表
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
return None
return self.run_module("tmdb_trending", page=page)
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
@@ -57,35 +54,35 @@ class TmdbChain(ChainBase, metaclass=Singleton):
"""
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season)
def movie_similar(self, tmdbid: int) -> List[dict]:
def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询类似电影
:param tmdbid: TMDBID
"""
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
def tv_similar(self, tmdbid: int) -> List[dict]:
def tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询类似电视剧
:param tmdbid: TMDBID
"""
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
def movie_recommend(self, tmdbid: int) -> List[dict]:
def movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询推荐电影
:param tmdbid: TMDBID
"""
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
def tv_recommend(self, tmdbid: int) -> List[dict]:
def tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询推荐电视剧
:param tmdbid: TMDBID
"""
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
def movie_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电影演职人员
:param tmdbid: TMDBID
@@ -93,7 +90,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
"""
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
def tv_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电视剧演职人员
:param tmdbid: TMDBID
@@ -101,14 +98,14 @@ class TmdbChain(ChainBase, metaclass=Singleton):
"""
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
def person_detail(self, person_id: int) -> dict:
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据TMDBID查询演职员详情
:param person_id: 人物ID
"""
return self.run_module("tmdb_person_detail", person_id=person_id)
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
def person_credits(self, person_id: int, page: int = 1) -> Optional[List[MediaInfo]]:
"""
根据人物ID查询人物参演作品
:param person_id: 人物ID
@@ -117,7 +114,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
@cached(cache=TTLCache(maxsize=1, ttl=3600))
def get_random_wallpager(self):
def get_random_wallpager(self) -> Optional[str]:
"""
获取随机壁纸缓存1个小时
"""
@@ -126,6 +123,6 @@ class TmdbChain(ChainBase, metaclass=Singleton):
# 随机一个电影
while True:
info = random.choice(infos)
if info and info.get("backdrop_path"):
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
if info and info.backdrop_path:
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.backdrop_path}"
return None

View File

@@ -153,12 +153,15 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
# 所有站点索引
indexers = self.siteshelper.get_indexers()
# 需要刷新的站点domain
domains = []
# 遍历站点缓存资源
for indexer in indexers:
# 未开启的站点不刷新
if sites and indexer.get("id") not in sites:
continue
domain = StringUtils.get_url_domain(indexer.get("domain"))
domains.append(domain)
if stype == "spider":
# 刷新首页种子
torrents: List[TorrentInfo] = self.browse(domain=domain)
@@ -219,7 +222,9 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
self.save_cache(torrents_cache, self._rss_file)
# 返回
# 去除不在站点范围内的缓存种子
if sites and torrents_cache:
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
return torrents_cache
def __renew_rss_url(self, domain: str, site: dict):

View File

@@ -86,7 +86,8 @@ class TransferChain(ChainBase):
mediainfo: MediaInfo = None, download_hash: str = None,
target: Path = None, transfer_type: str = None,
season: int = None, epformat: EpisodeFormat = None,
min_filesize: int = 0, force: bool = False) -> Tuple[bool, str]:
min_filesize: int = 0, scrape: bool = None,
force: bool = False) -> Tuple[bool, str]:
"""
执行一个复杂目录的转移操作
:param path: 待转移目录或文件
@@ -98,6 +99,7 @@ class TransferChain(ChainBase):
:param season: 季
:param epformat: 剧集格式
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param force: 是否强制转移
返回:成功标识,错误信息
"""
@@ -223,8 +225,6 @@ class TransferChain(ChainBase):
# 合并季
if season is not None:
file_meta.begin_season = season
elif file_meta.begin_season is None:
file_meta.begin_season = 1
if not file_meta:
logger.error(f"{file_path} 无法识别有效信息")
@@ -280,6 +280,8 @@ class TransferChain(ChainBase):
# 获取集数据
if file_mediainfo.type == MediaType.TV:
if file_meta.begin_season is None:
file_meta.begin_season = 1
file_mediainfo.season = file_mediainfo.season or file_meta.begin_season
episodes_info = self.tmdbchain.tmdb_episodes(
tmdbid=file_mediainfo.tmdb_id,
@@ -300,7 +302,8 @@ class TransferChain(ChainBase):
path=file_path,
transfer_type=transfer_type,
target=target,
episodes_info=episodes_info)
episodes_info=episodes_info,
scrape=scrape)
if not transferinfo:
logger.error("文件转移模块运行失败")
return False, "文件转移模块运行失败"
@@ -357,7 +360,7 @@ class TransferChain(ChainBase):
transferinfo=transferinfo
)
# 刮削单个文件
if settings.SCRAP_METADATA:
if transferinfo.need_scrape:
self.scrape_metadata(path=transferinfo.target_path,
mediainfo=file_mediainfo,
transfer_type=transfer_type,
@@ -487,24 +490,6 @@ class TransferChain(ChainBase):
text=errmsg, userid=userid))
return
@staticmethod
def get_root_path(path: str, type_name: str, category: str) -> Optional[Path]:
"""
计算媒体库目录的根路径
"""
if not path or path == "None":
return None
index = -2
if type_name != '电影':
index = -3
if category:
index -= 1
if '/' in path:
retpath = '/'.join(path.split('/')[:index])
else:
retpath = '\\'.join(path.split('\\')[:index])
return Path(retpath)
def re_transfer(self, logid: int, mtype: MediaType = None,
mediaid: str = None) -> Tuple[bool, str]:
"""
@@ -522,7 +507,6 @@ class TransferChain(ChainBase):
src_path = Path(history.src)
if not src_path.exists():
return False, f"源目录不存在:{src_path}"
dest_path = self.get_root_path(path=history.dest, type_name=history.type, category=history.category)
# 查询媒体信息
if mtype and mediaid:
mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
@@ -545,7 +529,6 @@ class TransferChain(ChainBase):
state, errmsg = self.do_transfer(path=src_path,
mediainfo=mediainfo,
download_hash=history.download_hash,
target=dest_path,
force=True)
if not state:
return False, errmsg
@@ -555,32 +538,36 @@ class TransferChain(ChainBase):
def manual_transfer(self, in_path: Path,
target: Path = None,
tmdbid: int = None,
doubanid: str = None,
mtype: MediaType = None,
season: int = None,
transfer_type: str = None,
epformat: EpisodeFormat = None,
min_filesize: int = 0,
scrape: bool = None,
force: bool = False) -> Tuple[bool, Union[str, list]]:
"""
手动转移,支持复杂条件,带进度显示
:param in_path: 源文件路径
:param target: 目标路径
:param tmdbid: TMDB ID
:param doubanid: 豆瓣ID
:param mtype: 媒体类型
:param season: 季度
:param transfer_type: 转移类型
:param epformat: 剧集格式
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param force: 是否强制转移
"""
logger.info(f"手动转移:{in_path} ...")
if tmdbid:
if tmdbid or doubanid:
# 有输入TMDBID时单个识别
# 识别媒体信息
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, mtype=mtype)
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if not mediainfo:
return False, f"媒体信息识别失败tmdbid: {tmdbid}, type: {mtype.value}"
return False, f"媒体信息识别失败tmdbid{tmdbid}doubanid{doubanid}type: {mtype.value}"
# 开始进度
self.progress.start(ProgressKey.FileTransfer)
self.progress.update(value=0,
@@ -595,6 +582,7 @@ class TransferChain(ChainBase):
season=season,
epformat=epformat,
min_filesize=min_filesize,
scrape=scrape,
force=force
)
if not state:

View File

@@ -10,9 +10,11 @@ from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.system import SystemChain
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.event import Event as ManagerEvent
from app.core.event import eventmanager, EventManager
from app.core.plugin import PluginManager
from app.helper.message import MessageHelper
from app.helper.thread import ThreadHelper
from app.log import logger
from app.scheduler import Scheduler
@@ -50,6 +52,8 @@ class Command(metaclass=Singleton):
self.chain = CommandChian()
# 定时服务管理
self.scheduler = Scheduler()
# 消息管理器
self.messagehelper = MessageHelper()
# 线程管理器
self.threader = ThreadHelper()
# 内置命令
@@ -165,7 +169,8 @@ class Command(metaclass=Singleton):
}
)
# 广播注册命令菜单
self.chain.register_commands(commands=self.get_commands())
if not settings.DEV:
self.chain.register_commands(commands=self.get_commands())
# 消息处理线程
self._thread = Thread(target=self.__run)
# 启动事件处理线程
@@ -182,9 +187,9 @@ class Command(metaclass=Singleton):
if event:
logger.info(f"处理事件:{event.event_type} - {handlers}")
for handler in handlers:
names = handler.__qualname__.split(".")
[class_name, method_name] = names
try:
names = handler.__qualname__.split(".")
[class_name, method_name] = names
if class_name in self.pluginmanager.get_plugin_ids():
# 插件事件
self.threader.submit(
@@ -196,10 +201,15 @@ class Command(metaclass=Singleton):
# 检查全局变量中是否存在
if class_name not in globals():
# 导入模块除了插件和Command本身只有chain能响应事件
module = importlib.import_module(
f"app.chain.{class_name[:-5].lower()}"
)
class_obj = getattr(module, class_name)()
try:
module = importlib.import_module(
f"app.chain.{class_name[:-5].lower()}"
)
class_obj = getattr(module, class_name)()
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
continue
else:
# 通过类名创建类实例
class_obj = globals()[class_name]()
@@ -211,6 +221,9 @@ class Command(metaclass=Singleton):
)
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
self.messagehelper.put(title=f"{event.event_type} 事件处理出错",
message=f"{class_name}.{method_name}{str(e)}",
role="system")
def __run_command(self, command: Dict[str, any],
data_str: str = "",
@@ -266,9 +279,11 @@ class Command(metaclass=Singleton):
"""
停止事件处理线程
"""
logger.info("正在停止事件处理...")
self._event.set()
try:
self._thread.join()
logger.info("事件处理停止完成")
except Exception as e:
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
@@ -319,6 +334,9 @@ class Command(metaclass=Singleton):
logger.info(f"{command.get('description')} 执行完成")
except Exception as err:
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
self.messagehelper.put(title=f"执行命令 {cmd} 出错",
message=str(err),
role="system")
@staticmethod
def send_plugin_event(etype: EventType, data: dict) -> None:

View File

@@ -1,7 +1,8 @@
import secrets
import sys
import threading
from pathlib import Path
from typing import List, Optional
from typing import Optional
from pydantic import BaseSettings, validator
@@ -9,10 +10,15 @@ from app.utils.system import SystemUtils
class Settings(BaseSettings):
"""
系统配置类
"""
# 项目名称
PROJECT_NAME = "MoviePilot"
# API路径
API_V1_STR: str = "/api/v1"
# 前端资源路径
FRONTEND_PATH: str = "/public"
# 密钥
SECRET_KEY: str = secrets.token_urlsafe(32)
# 允许的域名
@@ -31,6 +37,8 @@ class Settings(BaseSettings):
DEBUG: bool = False
# 是否开发模式
DEV: bool = False
# 是否开启插件热加载
PLUGIN_AUTO_RELOAD: bool = False
# 配置文件目录
CONFIG_DIR: Optional[str] = None
# 超级管理员
@@ -41,12 +49,12 @@ class Settings(BaseSettings):
WALLPAPER: str = "tmdb"
# 网络代理 IP:PORT
PROXY_HOST: Optional[str] = None
# 媒体搜索来源 themoviedb/douban/bangumi多个用,分隔
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
# 媒体识别来源 themoviedb/douban
RECOGNIZE_SOURCE: str = "themoviedb"
# 刮削来源 themoviedb/douban
SCRAP_SOURCE: str = "themoviedb"
# 刮削入库的媒体文件
SCRAP_METADATA: bool = True
# 新增已入库媒体是否跟随TMDB信息变化
SCRAP_FOLLOW_TMDB: bool = True
# TMDB图片地址
@@ -149,16 +157,6 @@ class Settings(BaseSettings):
TR_PASSWORD: Optional[str] = None
# 种子标签
TORRENT_TAG: str = "MOVIEPILOT"
# 下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: Optional[str] = None
# 电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: Optional[str] = None
# 电视剧下载保存目录,容器内映射路径需要一致
DOWNLOAD_TV_PATH: Optional[str] = None
# 动漫下载保存目录,容器内映射路径需要一致
DOWNLOAD_ANIME_PATH: Optional[str] = None
# 下载目录二级分类
DOWNLOAD_CATEGORY: bool = False
# 下载站点字幕
DOWNLOAD_SUBTITLE: bool = True
# 媒体服务器 emby/jellyfin/plex多个媒体服务器,分割
@@ -187,6 +185,8 @@ class Settings(BaseSettings):
PLEX_TOKEN: Optional[str] = None
# 转移方式 link/copy/move/softlink
TRANSFER_TYPE: str = "copy"
# 是否同盘优先
TRANSFER_SAME_DISK: bool = True
# CookieCloud是否启动本地服务
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
# CookieCloud服务器地址
@@ -201,16 +201,6 @@ class Settings(BaseSettings):
OCR_HOST: str = "https://movie-pilot.org"
# CookieCloud对应的浏览器UA
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
# 媒体库目录,多个目录使用,分隔
LIBRARY_PATH: Optional[str] = None
# 电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影"
# 电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧"
# 动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: Optional[str] = None
# 二级分类
LIBRARY_CATEGORY: bool = True
# 电视剧动漫的分类genre_ids
ANIME_GENREIDS = [16]
# 电影重命名格式
@@ -227,7 +217,7 @@ class Settings(BaseSettings):
# 大内存模式
BIG_MEMORY_MODE: bool = False
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
# Github token提高请求api限流阈值 ghp_****
GITHUB_TOKEN: Optional[str] = None
# 自动检查和更新站点资源包(站点索引、认证等)
@@ -238,6 +228,35 @@ class Settings(BaseSettings):
DOH_ENABLE: bool = True
# 搜索多个名称
SEARCH_MULTIPLE_NAME: bool = False
# 订阅数据共享
SUBSCRIBE_STATISTIC_SHARE: bool = True
# 插件安装数据共享
PLUGIN_STATISTIC_SHARE: bool = True
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
MP_SERVER_HOST: str = "https://movie-pilot.org"
# 【已弃用】刮削入库的媒体文件
SCRAP_METADATA: bool = True
# 【已弃用】下载保存目录,容器内映射路径需要一致
DOWNLOAD_PATH: Optional[str] = None
# 【已弃用】电影下载保存目录,容器内映射路径需要一致
DOWNLOAD_MOVIE_PATH: Optional[str] = None
# 【已弃用】电视剧下载保存目录,容器内映射路径需要一致
DOWNLOAD_TV_PATH: Optional[str] = None
# 【已弃用】动漫下载保存目录,容器内映射路径需要一致
DOWNLOAD_ANIME_PATH: Optional[str] = None
# 【已弃用】下载目录二级分类
DOWNLOAD_CATEGORY: bool = False
# 【已弃用】媒体库目录,多个目录使用,分隔
LIBRARY_PATH: Optional[str] = None
# 【已弃用】电影媒体库目录名
LIBRARY_MOVIE_NAME: str = "电影"
# 【已弃用】电视剧媒体库目录名
LIBRARY_TV_NAME: str = "电视剧"
# 【已弃用】动漫媒体库目录名,不设置时使用电视剧目录
LIBRARY_ANIME_NAME: Optional[str] = None
# 【已弃用】二级分类
LIBRARY_CATEGORY: bool = True
@validator("SUBSCRIBE_RSS_INTERVAL",
"COOKIECLOUD_INTERVAL",
@@ -322,48 +341,6 @@ class Settings(BaseSettings):
"server": self.PROXY_HOST
}
@property
def LIBRARY_PATHS(self) -> List[Path]:
if self.LIBRARY_PATH:
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
return [self.CONFIG_PATH / "library"]
@property
def SAVE_PATH(self) -> Path:
"""
获取下载保存目录
"""
if self.DOWNLOAD_PATH:
return Path(self.DOWNLOAD_PATH)
return self.CONFIG_PATH / "downloads"
@property
def SAVE_MOVIE_PATH(self) -> Path:
"""
获取电影下载保存目录
"""
if self.DOWNLOAD_MOVIE_PATH:
return Path(self.DOWNLOAD_MOVIE_PATH)
return self.SAVE_PATH
@property
def SAVE_TV_PATH(self) -> Path:
"""
获取电视剧下载保存目录
"""
if self.DOWNLOAD_TV_PATH:
return Path(self.DOWNLOAD_TV_PATH)
return self.SAVE_PATH
@property
def SAVE_ANIME_PATH(self) -> Path:
"""
获取动漫下载保存目录
"""
if self.DOWNLOAD_ANIME_PATH:
return Path(self.DOWNLOAD_ANIME_PATH)
return self.SAVE_TV_PATH
@property
def GITHUB_HEADERS(self):
"""
@@ -415,7 +392,31 @@ class Settings(BaseSettings):
case_sensitive = True
class GlobalVar(object):
"""
全局标识
"""
# 系统停止事件
STOP_EVENT: threading.Event = threading.Event()
def stop_system(self):
"""
停止系统
"""
self.STOP_EVENT.set()
def is_system_stopped(self):
"""
是否停止
"""
return self.STOP_EVENT.is_set()
# 实例化配置
settings = Settings(
_env_file=Settings().CONFIG_PATH / "app.env",
_env_file_encoding="utf-8"
)
# 全局标识
global_vars = GlobalVar()

View File

@@ -133,6 +133,8 @@ class TorrentInfo:
@dataclass
class MediaInfo:
# 来源themoviedb、douban、bangumi
source: str = None
# 类型 电影、电视剧
type: MediaType = None
# 媒体标题
@@ -355,6 +357,8 @@ class MediaInfo:
if not info:
return
# 来源
self.source = "themoviedb"
# 本体
self.tmdb_info = info
# 类型
@@ -440,6 +444,8 @@ class MediaInfo:
"""
if not info:
return
# 来源
self.source = "douban"
# 本体
self.douban_info = info
# 豆瓣ID
@@ -448,10 +454,16 @@ class MediaInfo:
if not self.type:
if isinstance(info.get('media_type'), MediaType):
self.type = info.get('media_type')
elif info.get("type"):
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
elif info.get("subtype"):
self.type = MediaType.MOVIE if info.get("subtype") == "movie" else MediaType.TV
elif info.get("target_type"):
self.type = MediaType.MOVIE if info.get("target_type") == "movie" else MediaType.TV
elif info.get("type_name"):
self.type = MediaType(info.get("type_name"))
elif info.get("uri"):
self.type = MediaType.MOVIE if "/movie/" in info.get("uri") else MediaType.TV
elif info.get("type") and info.get("type") in ["movie", "tv"]:
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
# 标题
if not self.title:
self.title = info.get("title")
@@ -464,6 +476,8 @@ class MediaInfo:
# 年份
if not self.year:
self.year = info.get("year")[:4] if info.get("year") else None
if not self.year and info.get("extra"):
self.year = info.get("extra").get("year")
# 识别标题中的季
meta = MetaInfo(info.get("title"))
# 季
@@ -496,12 +510,21 @@ class MediaInfo:
if info.get("pic"):
self.poster_path = info.get("pic", {}).get("large")
if not self.poster_path and info.get("cover_url"):
self.poster_path = info.get("cover_url")
# imageView2/0/q/80/w/9999/h/120/format/webp -> imageView2/1/w/500/h/750/format/webp
self.poster_path = re.sub(r'imageView2/\d/q/\d+/w/\d+/h/\d+/format/webp', 'imageView2/1/w/500/h/750/format/webp', info.get("cover_url"))
if not self.poster_path and info.get("cover"):
self.poster_path = info.get("cover").get("url")
if info.get("cover").get("url"):
self.poster_path = info.get("cover").get("url")
else:
self.poster_path = info.get("cover").get("large", {}).get("url")
# 简介
if not self.overview:
self.overview = info.get("intro") or info.get("card_subtitle") or ""
if not self.overview:
if info.get("extra", {}).get("info"):
extra_info = info.get("extra").get("info")
if extra_info:
self.overview = "".join(["".join(item) for item in extra_info])
# 从简介中提取年份
if self.overview and not self.year:
match = re.search(r'\d{4}', self.overview)
@@ -553,6 +576,8 @@ class MediaInfo:
"""
if not info:
return
# 来源
self.source = "bangumi"
# 本体
self.bangumi_info = info
# 豆瓣ID
@@ -589,6 +614,8 @@ class MediaInfo:
if not self.poster_path:
if info.get("images"):
self.poster_path = info.get("images", {}).get("large")
if not self.poster_path and info.get("image"):
self.poster_path = info.get("image")
# 简介
if not self.overview:
self.overview = info.get("summary")

View File

@@ -291,7 +291,7 @@ class MetaBase(object):
return self.season
else:
return ""
@property
def season_seq(self) -> str:
"""
@@ -334,7 +334,7 @@ class MetaBase(object):
str(self.end_episode).rjust(2, "0"))
else:
return ""
@property
def episode_list(self) -> List[int]:
"""
@@ -532,7 +532,7 @@ class MetaBase(object):
self.end_episode = end
if self.begin_episode and self.end_episode:
self.total_episode = (self.end_episode - self.begin_episode) + 1
def merge(self, meta: Self):
"""
全并Meta信息
@@ -550,13 +550,13 @@ class MetaBase(object):
self.year = meta.year
# 季
if (self.type == MediaType.TV
and not self.begin_season):
and self.begin_season is None):
self.begin_season = meta.begin_season
self.end_season = meta.end_season
self.total_season = meta.total_season
# 开始集
if (self.type == MediaType.TV
and not self.begin_episode):
and self.begin_episode is None):
self.begin_episode = meta.begin_episode
self.end_episode = meta.end_episode
self.total_episode = meta.total_episode

View File

@@ -1,5 +1,4 @@
import re
from pathlib import Path
from typing import Optional
from Pinyin2Hanzi import is_pinyin
@@ -27,7 +26,7 @@ class MetaVideo(MetaBase):
_source = ""
_effect = []
# 正则式区
_season_re = r"S(\d{2})|^S(\d{1,2})$|S(\d{1,2})E"
_season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E"
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
@@ -57,7 +56,7 @@ class MetaVideo(MetaBase):
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""
初始化
:param title: 标题
:param title: 标题,文件为去掉了后缀
:param subtitle: 副标题
:param isfile: 是否是文件名
"""
@@ -68,11 +67,10 @@ class MetaVideo(MetaBase):
self._source = ""
self._effect = []
# 判断是否纯数字命名
title_path = Path(title)
if title_path.suffix.lower() in settings.RMT_MEDIAEXT \
and title_path.stem.isdigit() \
and len(title_path.stem) < 5:
self.begin_episode = int(title_path.stem)
if isfile \
and title.isdigit() \
and len(title) < 5:
self.begin_episode = int(title)
self.type = MediaType.TV
return
# 去掉名称中第1个[]的内容
@@ -139,7 +137,7 @@ class MetaVideo(MetaBase):
# 处理part
if self.part and self.part.upper() == "PART":
self.part = None
# 没有中文标题时,试中描述中获取中文名
# 没有中文标题时,试中描述中获取中文名
if not self.cn_name and self.en_name and self.subtitle:
if self.__is_pinyin(self.en_name):
# 英文名是拼音

View File

@@ -6,6 +6,7 @@ import regex as re
from app.core.config import settings
from app.core.meta import MetaAnime, MetaVideo, MetaBase
from app.core.meta.words import WordsMatcher
from app.log import logger
from app.schemas.types import MediaType
@@ -25,6 +26,8 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
# 判断是否处理文件
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
isfile = True
# 去掉后缀
title = Path(title).stem
else:
isfile = False
# 识别
@@ -35,9 +38,12 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
meta.apply_words = apply_words or []
# 修正媒体信息
if metainfo.get('tmdbid'):
meta.tmdbid = metainfo['tmdbid']
try:
meta.tmdbid = int(metainfo['tmdbid'])
except ValueError as _:
logger.warn("tmdbid 必须是数字")
if metainfo.get('doubanid'):
meta.tmdbid = metainfo['doubanid']
meta.doubanid = metainfo['doubanid']
if metainfo.get('type'):
meta.type = metainfo['type']
if metainfo.get('begin_season'):

View File

@@ -1,3 +1,4 @@
import traceback
from typing import Generator, Optional, Tuple
from app.core.config import settings
@@ -34,22 +35,31 @@ class ModuleManager(metaclass=Singleton):
for module in modules:
module_id = module.__name__
self._modules[module_id] = module
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.info(f"Moudle Loaded{module_id}")
try:
# 生成实例
_module = module()
# 初始化模块
if self.check_setting(_module.init_setting()):
# 通过模板开关控制加载
_module.init_module()
self._running_modules[module_id] = _module
logger.info(f"Moudle Loaded{module_id}")
except Exception as err:
logger.error(f"Load Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
def stop(self):
"""
停止所有模块
"""
for _, module in self._running_modules.items():
logger.info("正在停止所有模块...")
for module_id, module in self._running_modules.items():
if hasattr(module, "stop"):
module.stop()
try:
module.stop()
logger.info(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
logger.info("模块停止完成")
def reload(self):
"""
@@ -87,7 +97,7 @@ class ModuleManager(metaclass=Singleton):
return True
return False
def get_modules(self, method: str) -> Generator:
def get_running_modules(self, method: str) -> Generator:
"""
获取实现了同一方法的模块列表
"""
@@ -97,3 +107,9 @@ class ModuleManager(metaclass=Singleton):
if hasattr(module, method) \
and ObjectUtils.check_method(getattr(module, method)):
yield module
def get_modules(self) -> dict:
"""
获取模块列表
"""
return self._modules

View File

@@ -1,7 +1,14 @@
import concurrent
import concurrent.futures
import inspect
import os
import threading
import time
import traceback
from typing import List, Any, Dict, Tuple, Optional
from typing import List, Any, Dict, Tuple, Optional, Callable
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
from app import schemas
from app.core.config import settings
@@ -18,6 +25,59 @@ from app.utils.string import StringUtils
from app.utils.system import SystemUtils
class PluginMonitorHandler(FileSystemEventHandler):
# 计时器
__reload_timer = None
# 防抖时间间隔
__debounce_interval = 0.5
# 最近一次修改时间
__last_modified = 0
# 修改间隔
__timeout = 2
def on_modified(self, event):
"""
插件文件修改后重载
"""
if event.is_directory:
return
current_time = time.time()
if current_time - self.__last_modified < self.__timeout:
return
self.__last_modified = current_time
# 读取插件根目录下的__init__.py文件读取class XXXX(_PluginBase)的类名
try:
# 使用os.path和pathlib处理跨平台的路径问题
plugin_dir = event.src_path.split("plugins" + os.sep)[1].split(os.sep)[0]
init_file = settings.ROOT_PATH / "app" / "plugins" / plugin_dir / "__init__.py"
with open(init_file, "r", encoding="utf-8") as f:
lines = f.readlines()
pid = None
for line in lines:
if line.startswith("class") and "(_PluginBase)" in line:
pid = line.split("class ")[1].split("(_PluginBase)")[0]
if pid:
# 防抖处理,通过计时器延迟加载
if self.__reload_timer:
self.__reload_timer.cancel()
self.__reload_timer = threading.Timer(self.__debounce_interval, self.__reload_plugin, [pid])
self.__reload_timer.start()
except Exception as e:
logger.error(f"插件文件修改后重载出错:{str(e)}")
@staticmethod
def __reload_plugin(pid):
"""
重新加载插件
"""
try:
logger.info(f"插件 {pid} 文件修改,重新加载...")
PluginManager().reload_plugin(pid)
except Exception as e:
logger.error(f"插件文件修改后重载出错:{str(e)}")
class PluginManager(metaclass=Singleton):
"""
插件管理器
@@ -30,11 +90,16 @@ class PluginManager(metaclass=Singleton):
_running_plugins: dict = {}
# 配置Key
_config_key: str = "plugin.%s"
# 监听器
_observer: Observer = None
def __init__(self):
self.siteshelper = SitesHelper()
self.pluginhelper = PluginHelper()
self.systemconfig = SystemConfigOper()
# 开发者模式监测插件修改
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
self.__start_monitor()
def init_config(self):
# 停止已有插件
@@ -47,11 +112,28 @@ class PluginManager(metaclass=Singleton):
启动加载插件
:param pid: 插件ID为空加载所有插件
"""
def check_module(module: Any):
"""
检查模块
"""
if not hasattr(module, 'init_plugin') or not hasattr(module, "plugin_name"):
return False
return True
# 扫描插件目录
plugins = ModuleHelper.load(
"app.plugins",
filter_func=lambda _, obj: hasattr(obj, 'init_plugin')
)
if pid:
# 加载指定插件
plugins = ModuleHelper.load_with_pre_filter(
"app.plugins",
filter_func=lambda name, obj: check_module(obj) and name == pid
)
else:
# 加载所有插件
plugins = ModuleHelper.load(
"app.plugins",
filter_func=lambda _, obj: check_module(obj)
)
# 已安装插件
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
# 排序
@@ -105,6 +187,10 @@ class PluginManager(metaclass=Singleton):
:param pid: 插件ID为空停止所有插件
"""
# 停止插件
if pid:
logger.info(f"正在停止插件 {pid}...")
else:
logger.info("正在停止所有插件...")
for plugin_id, plugin in self._running_plugins.items():
if pid and plugin_id != pid:
continue
@@ -120,6 +206,28 @@ class PluginManager(metaclass=Singleton):
# 清空
self._plugins = {}
self._running_plugins = {}
logger.info("插件停止完成")
def __start_monitor(self):
"""
开发者模式下监测插件文件修改
"""
logger.info("开发者模式下开始监测插件文件修改...")
monitor_handler = PluginMonitorHandler()
self._observer = Observer()
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
self._observer.start()
def stop_monitor(self):
"""
停止监测插件修改
"""
# 停止监测
if self._observer:
logger.info("正在停止插件文件修改监测...")
self._observer.stop()
self._observer.join()
logger.info("插件文件修改监测停止完成")
@staticmethod
def __stop_plugin(plugin: Any):
@@ -217,10 +325,11 @@ class PluginManager(metaclass=Singleton):
获取插件表单
:param pid: 插件ID
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return [], {}
if hasattr(self._running_plugins[pid], "get_form"):
return self._running_plugins[pid].get_form() or ([], {})
if hasattr(plugin, "get_form"):
return plugin.get_form() or ([], {})
return [], {}
def get_plugin_page(self, pid: str) -> List[dict]:
@@ -228,12 +337,45 @@ class PluginManager(metaclass=Singleton):
获取插件页面
:param pid: 插件ID
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return []
if hasattr(self._running_plugins[pid], "get_page"):
return self._running_plugins[pid].get_page() or []
if hasattr(plugin, "get_page"):
return plugin.get_page() or []
return []
def get_plugin_dashboard(self, pid: str, **kwargs) -> Optional[schemas.PluginDashboard]:
"""
获取插件仪表盘
:param pid: 插件ID
"""
def __get_params_count(func: Callable):
"""
获取函数的参数信息
"""
signature = inspect.signature(func)
return len(signature.parameters)
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if hasattr(plugin, "get_dashboard"):
# 检查方法的参数个数
if __get_params_count(plugin.get_dashboard) > 0:
dashboard: Tuple = plugin.get_dashboard(**kwargs)
else:
dashboard: Tuple = plugin.get_dashboard()
if dashboard:
cols, attrs, elements = dashboard
return schemas.PluginDashboard(
id=pid,
name=plugin.plugin_name,
cols=cols or {},
elements=elements,
attrs=attrs or {}
)
return None
def get_plugin_commands(self) -> List[Dict[str, Any]]:
"""
获取插件命令
@@ -254,7 +396,7 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件命令出错:{str(e)}")
return ret_commands
def get_plugin_apis(self) -> List[Dict[str, Any]]:
def get_plugin_apis(self, plugin_id: str = None) -> List[Dict[str, Any]]:
"""
获取插件API
[{
@@ -267,6 +409,8 @@ class PluginManager(metaclass=Singleton):
"""
ret_apis = []
for pid, plugin in self._running_plugins.items():
if plugin_id and pid != plugin_id:
continue
if hasattr(plugin, "get_api") \
and ObjectUtils.check_method(plugin.get_api):
try:
@@ -301,17 +445,37 @@ class PluginManager(metaclass=Singleton):
logger.error(f"获取插件 {pid} 服务出错:{str(e)}")
return ret_services
def get_dashboard_plugins(self) -> List[dict]:
"""
获取有仪表盘的插件列表
"""
dashboards = []
for pid, plugin in self._running_plugins.items():
if hasattr(plugin, "get_dashboard") \
and ObjectUtils.check_method(plugin.get_dashboard):
try:
if not plugin.get_state():
continue
dashboards.append({
"id": pid,
"name": plugin.plugin_name
})
except Exception as e:
logger.error(f"获取有仪表盘的插件出错:{str(e)}")
return dashboards
def get_plugin_attr(self, pid: str, attr: str) -> Any:
"""
获取插件属性
:param pid: 插件ID
:param attr: 属性名
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if not hasattr(self._running_plugins[pid], attr):
if not hasattr(plugin, attr):
return None
return getattr(self._running_plugins[pid], attr)
return getattr(plugin, attr)
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
"""
@@ -321,11 +485,12 @@ class PluginManager(metaclass=Singleton):
:param args: 参数
:param kwargs: 关键字参数
"""
if not self._running_plugins.get(pid):
plugin = self._running_plugins.get(pid)
if not plugin:
return None
if not hasattr(self._running_plugins[pid], method):
if not hasattr(plugin, method):
return None
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
return getattr(plugin, method)(*args, **kwargs)
def get_plugin_ids(self) -> List[str]:
"""
@@ -353,6 +518,7 @@ class PluginManager(metaclass=Singleton):
logger.warn(f"获取插件库失败:{market}")
return
ret_plugins = []
add_time = len(online_plugins)
for pid, plugin_info in online_plugins.items():
# 运行状插件
plugin_obj = self._running_plugins.get(pid)
@@ -406,6 +572,9 @@ class PluginManager(metaclass=Singleton):
# 图标
if plugin_info.get("icon"):
plugin.plugin_icon = plugin_info.get("icon")
# 标签
if plugin_info.get("labels"):
plugin.plugin_label = plugin_info.get("labels")
# 作者
if plugin_info.get("author"):
plugin.plugin_author = plugin_info.get("author")
@@ -416,8 +585,11 @@ class PluginManager(metaclass=Singleton):
plugin.repo_url = market
# 本地标志
plugin.is_local = False
# 添加顺序
plugin.add_time = add_time
# 汇总
ret_plugins.append(plugin)
add_time -= 1
return ret_plugins
@@ -510,12 +682,17 @@ class PluginManager(metaclass=Singleton):
# 作者链接
if hasattr(plugin_class, "author_url"):
plugin.author_url = plugin_class.author_url
# 加载顺序
if hasattr(plugin_class, "plugin_order"):
plugin.plugin_order = plugin_class.plugin_order
# 是否需要更新
plugin.has_update = False
# 本地标志
plugin.is_local = True
# 汇总
plugins.append(plugin)
# 根据加载排序重新排序
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
return plugins
@staticmethod

View File

@@ -7,3 +7,4 @@ from .subscribe import Subscribe
from .systemconfig import SystemConfig
from .transferhistory import TransferHistory
from .user import User
from .userconfig import UserConfig

View File

@@ -25,6 +25,10 @@ class Site(Base):
cookie = Column(String)
# User-Agent
ua = Column(String)
# ApiKey
apikey = Column(String)
# Token
token = Column(String)
# 是否使用代理 0-否1-是
proxy = Column(Integer)
# 过滤规则
@@ -41,6 +45,8 @@ class Site(Base):
limit_count = Column(Integer, default=0)
# 流控间隔
limit_seconds = Column(Integer, default=0)
# 超时时间
timeout = Column(Integer, default=0)
# 是否启用
is_active = Column(Boolean(), default=True)
# 创建时间
@@ -63,6 +69,12 @@ class Site(Base):
result = db.query(Site).order_by(Site.pri).all()
return list(result)
@staticmethod
@db_query
def get_domains_by_ids(db: Session, ids: list):
result = db.query(Site.domain).filter(Site.id.in_(ids)).all()
return [r[0] for r in result]
@staticmethod
@db_update
def reset(db: Session):

View File

@@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy import Column, Integer, String, Sequence
from sqlalchemy.orm import Session
from app.db import db_query, db_update, Base
class SiteStatistic(Base):
"""
站点统计表
"""
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 域名Key
domain = Column(String, index=True)
# 成功次数
success = Column(Integer)
# 失败次数
fail = Column(Integer)
# 平均耗时 秒
seconds = Column(Integer)
# 最后一次访问状态 0-成功 1-失败
lst_state = Column(Integer)
# 最后访问时间
lst_mod_date = Column(String, default=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
# 耗时记录 Json
note = Column(String)
@staticmethod
@db_query
def get_by_domain(db: Session, domain: str):
return db.query(SiteStatistic).filter(SiteStatistic.domain == domain).first()
@staticmethod
@db_update
def reset(db: Session):
db.query(SiteStatistic).delete()

View File

@@ -135,6 +135,25 @@ class Subscribe(Base):
subscribe.delete(db, subscribe.id)
return True
@staticmethod
@db_query
def list_by_username(db: Session, username: str, state: str = None, mtype: str = None):
if mtype:
if state:
result = db.query(Subscribe).filter(Subscribe.state == state,
Subscribe.username == username,
Subscribe.type == mtype).all()
else:
result = db.query(Subscribe).filter(Subscribe.username == username,
Subscribe.type == mtype).all()
else:
if state:
result = db.query(Subscribe).filter(Subscribe.state == state,
Subscribe.username == username).all()
else:
result = db.query(Subscribe).filter(Subscribe.username == username).all()
return list(result)
@staticmethod
@db_query
def list_by_type(db: Session, mtype: str, days: int):

View File

@@ -0,0 +1,72 @@
from sqlalchemy import Column, Integer, String, Sequence, Float
from sqlalchemy.orm import Session
from app.db import db_query, Base
class SubscribeHistory(Base):
"""
订阅历史表
"""
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 标题
name = Column(String, nullable=False, index=True)
# 年份
year = Column(String)
# 类型
type = Column(String)
# 搜索关键字
keyword = Column(String)
tmdbid = Column(Integer, index=True)
imdbid = Column(String)
tvdbid = Column(Integer)
doubanid = Column(String, index=True)
bangumiid = Column(Integer, index=True)
# 季号
season = Column(Integer)
# 海报
poster = Column(String)
# 背景图
backdrop = Column(String)
# 评分float
vote = Column(Float)
# 简介
description = Column(String)
# 过滤规则
filter = Column(String)
# 包含
include = Column(String)
# 排除
exclude = Column(String)
# 质量
quality = Column(String)
# 分辨率
resolution = Column(String)
# 特效
effect = Column(String)
# 总集数
total_episode = Column(Integer)
# 开始集数
start_episode = Column(Integer)
# 订阅完成时间
date = Column(String)
# 订阅用户
username = Column(String)
# 订阅站点
sites = Column(String)
# 是否洗版
best_version = Column(Integer, default=0)
# 保存路径
save_path = Column(String)
# 是否使用 imdbid 搜索
search_imdbid = Column(Integer, default=0)
@staticmethod
@db_query
def list_by_type(db: Session, mtype: str, page: int = 1, count: int = 30):
result = db.query(SubscribeHistory).filter(
SubscribeHistory.type == mtype
).order_by(
SubscribeHistory.date.desc()
).offset((page - 1) * count).limit(count).all()
return list(result)

View File

@@ -212,3 +212,11 @@ class TransferHistory(Base):
"download_hash": download_hash
}
)
@staticmethod
@db_query
def list_by_date(db: Session, date: str):
"""
查询某时间之后的转移历史
"""
return db.query(TransferHistory).filter(TransferHistory.date > date).order_by(TransferHistory.id.desc()).all()

View File

@@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, Sequence, UniqueConstraint, Index
from sqlalchemy.orm import Session
from app.db import db_query, db_update, Base
class UserConfig(Base):
"""
用户配置表
"""
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
# 用户名
username = Column(String, index=True)
# 配置键
key = Column(String)
# 值
value = Column(String, nullable=True)
__table_args__ = (
# 用户名和配置键联合唯一
UniqueConstraint('username', 'key'),
Index('ix_userconfig_username_key', 'username', 'key'),
)
@staticmethod
@db_query
def get_by_key(db: Session, username: str, key: str):
return db.query(UserConfig) \
.filter(UserConfig.username == username) \
.filter(UserConfig.key == key) \
.first()
@db_update
def delete_by_key(self, db: Session, username: str, key: str):
userconfig = self.get_by_key(db=db, username=username, key=key)
if userconfig:
userconfig.delete(db=db, rid=userconfig.id)
return True

View File

@@ -63,6 +63,12 @@ class SiteOper(DbOper):
"""
return Site.get_by_domain(self._db, domain)
def get_domains_by_ids(self, ids: List[int]) -> List[str]:
"""
按ID获取站点域名
"""
return Site.get_domains_by_ids(self._db, ids)
def exists(self, domain: str) -> bool:
"""
判断站点是否存在

View File

@@ -0,0 +1,70 @@
import json
from datetime import datetime
from app.db import DbOper
from app.db.models.sitestatistic import SiteStatistic
class SiteStatisticOper(DbOper):
"""
站点统计管理
"""
def success(self, domain: str, seconds: int = None):
"""
站点访问成功
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = SiteStatistic.get_by_domain(self._db, domain)
if sta:
avg_seconds, note = None, {}
if seconds is not None:
note: dict = json.loads(sta.note or "{}")
note[lst_date] = seconds or 1
avg_times = len(note.keys())
if avg_times > 10:
note = dict(sorted(note.items(), key=lambda x: x[0], reverse=True)[:10])
avg_seconds = sum([v for v in note.values()]) // avg_times
sta.update(self._db, {
"success": sta.success + 1,
"seconds": avg_seconds or sta.seconds,
"lst_state": 0,
"lst_mod_date": lst_date,
"note": json.dumps(note) if note else sta.note
})
else:
note = {}
if seconds is not None:
note = {
lst_date: seconds or 1
}
SiteStatistic(
domain=domain,
success=1,
fail=0,
seconds=seconds or 1,
lst_state=0,
lst_mod_date=lst_date,
note=json.dumps(note)
).create(self._db)
def fail(self, domain: str):
"""
站点访问失败
"""
lst_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sta = SiteStatistic.get_by_domain(self._db, domain)
if sta:
sta.update(self._db, {
"fail": sta.fail + 1,
"lst_state": 1,
"lst_mod_date": lst_date
})
else:
SiteStatistic(
domain=domain,
success=0,
fail=1,
lst_state=1,
lst_mod_date=lst_date
).create(self._db)

View File

@@ -89,6 +89,18 @@ class SubscribeOper(DbOper):
subscribe.update(self._db, payload)
return subscribe
def list_by_tmdbid(self, tmdbid: int, season: int = None) -> List[Subscribe]:
"""
获取指定tmdb_id的订阅
"""
return Subscribe.get_by_tmdbid(self._db, tmdbid=tmdbid, season=season)
def list_by_username(self, username: str, state: str = None, mtype: str = None) -> List[Subscribe]:
"""
获取指定用户的订阅
"""
return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype)
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
"""
获取指定类型的订阅

View File

@@ -0,0 +1,30 @@
import time
from app.db import DbOper
from app.db.models.subscribehistory import SubscribeHistory
class SubscribeHistoryOper(DbOper):
"""
订阅历史管理
"""
def add(self, **kwargs):
"""
新增订阅
"""
# 去除kwargs中 SubscribeHistory 没有的字段
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
# 更新完成订阅时间
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
# 去掉主键
if "id" in kwargs:
kwargs.pop("id")
subscribe = SubscribeHistory(**kwargs)
subscribe.create(self._db)
def list_by_type(self, mtype: str, page: int = 1, count: int = 30) -> SubscribeHistory:
"""
获取指定类型的订阅
"""
return SubscribeHistory.list_by_type(self._db, mtype=mtype, page=page, count=count)

View File

@@ -177,3 +177,10 @@ class TransferHistoryOper(DbOper):
errmsg="未识别到媒体信息"
)
return his
def list_by_date(self, date: str) -> List[TransferHistory]:
"""
查询某时间之后的转移历史
:param date: 日期
"""
return TransferHistory.list_by_date(self._db, date)

96
app/db/userconfig_oper.py Normal file
View File

@@ -0,0 +1,96 @@
import json
from typing import Any, Union, Dict, Optional
from app.db import DbOper
from app.db.models.userconfig import UserConfig
from app.schemas.types import UserConfigKey
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
class UserConfigOper(DbOper, metaclass=Singleton):
# 配置缓存
__USERCONF: Dict[str, Dict[str, Any]] = {}
def __init__(self):
"""
加载配置到内存
"""
super().__init__()
for item in UserConfig.list(self._db):
value = json.loads(item.value) if ObjectUtils.is_obj(item.value) else item.value
self.__set_config_cache(username=item.username, key=item.key, value=value)
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
"""
设置用户配置
"""
if isinstance(key, UserConfigKey):
key = key.value
# 更新内存
self.__set_config_cache(username=username, key=key, value=value)
# 写入数据库
if ObjectUtils.is_obj(value):
value = json.dumps(value)
elif value is None:
value = ''
conf = UserConfig.get_by_key(db=self._db, username=username, key=key)
if conf:
if value:
conf.update(self._db, {"value": value})
else:
conf.delete(self._db, conf.id)
else:
conf = UserConfig(username=username, key=key, value=value)
conf.create(self._db)
def get(self, username: str, key: Union[str, UserConfigKey] = None) -> Any:
"""
获取用户配置
"""
if not username:
return self.__USERCONF
if isinstance(key, UserConfigKey):
key = key.value
if not key:
return self.__get_config_caches(username=username)
return self.__get_config_cache(username=username, key=key)
def __del__(self):
if self._db:
self._db.close()
def __set_config_cache(self, username: str, key: str, value: Any):
"""
设置配置缓存
"""
if not username or not key:
return
cache = self.__USERCONF
if not cache:
cache = {}
user_cache = cache.get(username)
if not user_cache:
user_cache = {}
cache[username] = user_cache
user_cache[key] = value
self.__USERCONF = cache
def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:
"""
获取配置缓存
"""
if not username or not self.__USERCONF:
return None
return self.__USERCONF.get(username)
def __get_config_cache(self, username: str, key: str) -> Any:
"""
获取配置缓存
"""
if not username or not key or not self.__USERCONF:
return None
user_cache = self.__get_config_caches(username)
if not user_cache:
return None
return user_cache.get(key)

113
app/helper/directory.py Normal file
View File

@@ -0,0 +1,113 @@
from pathlib import Path
from typing import List, Optional
from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey, MediaType
from app.utils.system import SystemUtils
class DirectoryHelper:
"""
下载目录/媒体库目录帮助类
"""
def __init__(self):
self.systemconfig = SystemConfigOper()
def get_download_dirs(self) -> List[schemas.MediaDirectory]:
"""
获取下载目录
"""
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.DownloadDirectories)
if not dir_conf:
return []
return [schemas.MediaDirectory(**d) for d in dir_conf]
def get_library_dirs(self) -> List[schemas.MediaDirectory]:
"""
获取媒体库目录
"""
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.LibraryDirectories)
if not dir_conf:
return []
return [schemas.MediaDirectory(**d) for d in dir_conf]
def get_download_dir(self, media: MediaInfo = None) -> Optional[schemas.MediaDirectory]:
"""
根据媒体信息获取下载目录
:param media: 媒体信息
"""
media_dirs = self.get_download_dirs()
# 按照配置顺序查找(保存后的数据已经排序)
for media_dir in media_dirs:
if not media_dir.path:
continue
# 没有媒体信息时,返回第一个类型为全部的目录
if (not media or media.type == MediaType.UNKNOWN) and not media_dir.media_type:
return media_dir
# 目录类型为全部的,符合条件
if not media_dir.media_type:
return media_dir
# 处理类型
if media.genre_ids \
and set(media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
media_type = "动漫"
else:
media_type = media.type.value
# 目录类型相等,目录类别为全部,符合条件
if media_dir.media_type == media_type and not media_dir.category:
return media_dir
# 目录类型相等,目录类别相等,符合条件
if media_dir.media_type == media_type and media_dir.category == media.category:
return media_dir
return None
def get_library_dir(self, media: MediaInfo = None, in_path: Path = None) -> Optional[schemas.MediaDirectory]:
"""
根据媒体信息获取媒体库目录,需判断是否同盘优先
:param media: 媒体信息
:param in_path: 源目录
"""
matched_dirs = []
library_dirs = self.get_library_dirs()
# 按照配置顺序查找(保存后的数据已经排序)
for library_dir in library_dirs:
if not library_dir.path:
continue
# 没有媒体信息时,返回第一个类型为全部的目录
if (not media or media.type == MediaType.UNKNOWN) and not library_dir.media_type:
matched_dirs.append(library_dir)
# 目录类型为全部的,符合条件
if not library_dir.media_type:
matched_dirs.append(library_dir)
# 处理类型
if media.genre_ids \
and set(media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
media_type = "动漫"
else:
media_type = media.type.value
# 目录类型相等,目录类别为全部,符合条件
if library_dir.media_type == media_type and not library_dir.category:
matched_dirs.append(library_dir)
# 目录类型相等,目录类别相等,符合条件
if library_dir.media_type == media_type and library_dir.category == media.category:
matched_dirs.append(library_dir)
# 未匹配到
if not matched_dirs:
return None
# 优先同盘
if in_path and settings.TRANSFER_SAME_DISK:
for matched_dir in matched_dirs:
matched_path = Path(matched_dir.path)
if not matched_path.exists():
matched_path.mkdir(parents=True, exist_ok=True)
if SystemUtils.is_same_disk(matched_path, in_path):
return matched_dir
return matched_dirs[0]

View File

@@ -10,26 +10,46 @@ class MessageHelper(metaclass=Singleton):
"""
消息队列管理器,包括系统消息和用户消息
"""
def __init__(self):
self.sys_queue = queue.Queue()
self.user_queue = queue.Queue()
def put(self, message: Any, role: str = "sys", note: Union[list, dict] = None):
def put(self, message: Any, role: str = "plugin", title: str = None, note: Union[list, dict] = None):
"""
存消息
:param message: 消息
:param role: 消息通道 sys/user
:param role: 消息通道 systm系统消息plugin插件消息user用户消息
:param title: 标题
:param note: 附件json
"""
if role == "sys":
self.sys_queue.put(message)
if role in ["system", "plugin"]:
# 没有标题时获取插件名称
if role == "plugin" and not title:
title = "插件通知"
# 系统通知,默认
self.sys_queue.put(json.dumps({
"type": role,
"title": title,
"text": message,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note
}))
else:
if isinstance(message, str):
self.user_queue.put(message)
# 非系统的文本通知
self.user_queue.put(json.dumps({
"title": title,
"text": message,
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note
}))
elif hasattr(message, "to_dict"):
# 非系统的复杂结构通知,如媒体信息/种子列表等。
content = message.to_dict()
content['title'] = title
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
content['note'] = json.dumps(note) if note else None
content['note'] = note
self.user_queue.put(json.dumps(content))
def get(self, role: str = "sys") -> Optional[str]:

View File

@@ -13,9 +13,9 @@ class ModuleHelper:
"""
@classmethod
def load(cls, package_path, filter_func=lambda name, obj: True):
def load(cls, package_path: str, filter_func=lambda name, obj: True):
"""
导入模块
导入模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数入参为模块名和模块对象返回True则导入否则不导入
:return:
@@ -36,7 +36,39 @@ class ModuleHelper:
if isinstance(obj, type) and filter_func(name, obj):
submodules.append(obj)
except Exception as err:
logger.error(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
return submodules
@classmethod
def load_with_pre_filter(cls, package_path: str, filter_func=lambda name, obj: True):
"""
导入子模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数入参为模块名和模块对象返回True则导入否则不导入
:return:
"""
submodules: list = []
packages = importlib.import_module(package_path)
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
try:
if package_name.startswith('_'):
continue
full_package_name = f'{package_path}.{package_name}'
module = importlib.import_module(full_package_name)
# 预检查模块中的对象
candidates = [(name, obj) for name, obj in module.__dict__.items() if
not name.startswith('_') and isinstance(obj, type)]
# 确定是否需要重新加载
if any(filter_func(name, obj) for name, obj in candidates):
importlib.reload(module)
# reload后对象已经发生变更重新过滤已经重新加载后的模块中的对象
for name, obj in module.__dict__.items():
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj):
submodules.append(obj)
except Exception as err:
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
return submodules

View File

@@ -22,17 +22,18 @@ class PluginHelper(metaclass=Singleton):
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
_install_reg = "https://movie-pilot.org/plugin/install/%s"
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/%s"
_install_report = "https://movie-pilot.org/plugin/install"
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
_install_statistic = "https://movie-pilot.org/plugin/statistic"
_install_statistic = f"{settings.MP_SERVER_HOST}/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")
if settings.PLUGIN_STATISTIC_SHARE:
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]:
@@ -80,6 +81,8 @@ class PluginHelper(metaclass=Singleton):
"""
获取插件安装统计
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return {}
res = RequestUtils(timeout=10).get_res(self._install_statistic)
if res and res.status_code == 200:
return res.json()
@@ -89,6 +92,8 @@ class PluginHelper(metaclass=Singleton):
"""
安装插件统计
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
if not pid:
return False
res = RequestUtils(timeout=5).get_res(self._install_reg % pid)
@@ -100,6 +105,8 @@ class PluginHelper(metaclass=Singleton):
"""
上报存量插件安装统计
"""
if not settings.PLUGIN_STATISTIC_SHARE:
return False
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
if not plugins:
return False
@@ -130,12 +137,13 @@ 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}"
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
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} - " \
f"{'超出速率限制请配置GITHUB_TOKEN环境变量或稍后重试' if r.status_code == 403 else r.reason}"
ret = r.json()
if ret and ret[0].get("message") == "Not Found":
return None, "插件在仓库中不存在"
@@ -155,7 +163,8 @@ class PluginHelper(metaclass=Singleton):
if not res:
return False, f"文件 {item.get('name')} 下载失败!"
elif res.status_code != 200:
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}"
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - " \
f"{'超出速率限制请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
# 创建插件文件夹
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
if not file_path.parent.exists():

View File

@@ -225,7 +225,7 @@ class RssHelper:
}
@staticmethod
def parse(url, proxy: bool = False, timeout: int = 30) -> Union[List[dict], None]:
def parse(url, proxy: bool = False, timeout: int = 15) -> Union[List[dict], None]:
"""
解析RSS订阅URL获取RSS中的种子信息
:param url: RSS地址

122
app/helper/subscribe.py Normal file
View File

@@ -0,0 +1,122 @@
from threading import Thread
from typing import List
from cachetools import TTLCache, cached
from app.core.config import settings
from app.db.subscribe_oper import SubscribeOper
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class SubscribeHelper(metaclass=Singleton):
"""
订阅数据统计
"""
_sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add"
_sub_done = f"{settings.MP_SERVER_HOST}/subscribe/done"
_sub_report = f"{settings.MP_SERVER_HOST}/subscribe/report"
_sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic"
def __init__(self):
self.systemconfig = SystemConfigOper()
if settings.SUBSCRIBE_STATISTIC_SHARE:
if not self.systemconfig.get(SystemConfigKey.SubscribeReport):
if self.sub_report():
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
@cached(cache=TTLCache(maxsize=20, ttl=1800))
def get_statistic(self, stype: str, page: int = 1, count: int = 30) -> List[dict]:
"""
获取订阅统计数据
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return []
res = RequestUtils(timeout=15).get_res(self._sub_statistic, params={
"stype": stype,
"page": page,
"count": count
})
if res and res.status_code == 200:
return res.json()
return []
def sub_reg(self, sub: dict) -> bool:
"""
新增订阅统计
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = RequestUtils(timeout=5, headers={
"Content-Type": "application/json"
}).post_res(self._sub_reg, json=sub)
if res and res.status_code == 200:
return True
return False
def sub_done(self, sub: dict) -> bool:
"""
完成订阅统计
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
res = RequestUtils(timeout=5, headers={
"Content-Type": "application/json"
}).post_res(self._sub_done, json=sub)
if res and res.status_code == 200:
return True
return False
def sub_reg_async(self, sub: dict) -> bool:
"""
异步新增订阅统计
"""
# 开新线程处理
Thread(target=self.sub_reg, args=(sub,)).start()
return True
def sub_done_async(self, sub: dict) -> bool:
"""
异步完成订阅统计
"""
# 开新线程处理
Thread(target=self.sub_done, args=(sub,)).start()
return True
def sub_report(self) -> bool:
"""
上报存量订阅统计
"""
if not settings.SUBSCRIBE_STATISTIC_SHARE:
return False
subscribes = SubscribeOper().list()
if not subscribes:
return True
res = RequestUtils(content_type="application/json",
timeout=10).post(self._sub_report,
json={
"subscribes": [
{
"name": sub.name,
"year": sub.year,
"type": sub.type,
"tmdbid": sub.tmdbid,
"imdbid": sub.imdbid,
"tvdbid": sub.tvdbid,
"doubanid": sub.doubanid,
"bangumiid": sub.bangumiid,
"season": sub.season,
"poster": sub.poster,
"backdrop": sub.backdrop,
"vote": sub.vote,
"description": sub.description
} for sub in subscribes
]
})
return True if res else False

View File

@@ -322,6 +322,21 @@ class TorrentHelper(metaclass=Singleton):
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
return 0, 0
def __get_pubminutes(pubdate: str) -> float:
"""
将字符串转换为时间,并计算与当前时间差)(分钟)
"""
try:
if not pubdate:
return 0
pubdate = pubdate.replace("T", " ").replace("Z", "")
pubdate = datetime.datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S")
now = datetime.datetime.now()
return (now - pubdate).total_seconds() // 60
except Exception as e:
print(str(e))
return 0
if not filter_rule:
return True
@@ -334,20 +349,29 @@ class TorrentHelper(metaclass=Singleton):
# 最少做种人数
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
# 最少做种人数生效发布时间(分钟)(在设置发布时间之外的最少做种人数生效)
min_seeders_time = filter_rule.get("min_seeders_time") or 0
if min_seeders_time:
# 发布时间与当前时间差(分钟)
pubdate_minutes = __get_pubminutes(torrent_info.pubdate)
if pubdate_minutes > int(min_seeders_time):
logger.info(f"{torrent_info.title} 发布时间大于 {min_seeders_time} 分钟,做种人数不足 {min_seeders}")
return False
else:
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}")
logger.info(f"{content} 不匹配包含规则 {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}")
logger.info(f"{content} 匹配排除规则 {exclude}")
return False
# 质量
quality = filter_rule.get("quality")
@@ -404,15 +428,23 @@ class TorrentHelper(metaclass=Singleton):
return True
@staticmethod
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo,
torrent: TorrentInfo, logerror: bool = True) -> bool:
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
"""
检查种子是否匹配媒体信息
:param mediainfo: 需要匹配的媒体信息
:param torrent_meta: 种子识别信息
:param torrent: 种子信息
:param logerror: 是否记录错误日志
"""
# 比对词条指定的tmdbid
if torrent_meta.tmdbid or torrent_meta.doubanid:
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
logger.info(
f'{mediainfo.title} 通过词表指定TMDBID匹配到资源{torrent.site_name} - {torrent.title}')
return True
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
logger.info(
f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源{torrent.site_name} - {torrent.title}')
return True
# 要匹配的媒体标题、原标题
media_titles = {
StringUtils.clear_upper(mediainfo.title),
@@ -427,32 +459,28 @@ class TorrentHelper(metaclass=Singleton):
} - {""}
# 比对种子识别类型
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}')
logger.debug(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}')
logger.debug(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}')
logger.debug(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}')
# 电影年份上下浮动1年,没年份时不通过
if not torrent_meta.year or torrent_meta.year not in [str(int(mediainfo.year) - 1),
mediainfo.year,
str(int(mediainfo.year) + 1)]:
logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
return False
# 比对标题和原语种标题
if meta_names.intersection(media_titles):
@@ -464,21 +492,25 @@ class TorrentHelper(metaclass=Singleton):
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):
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
return True
# 在标题中判断是否存在标题、原语种标题、别名、译名
if torrent_meta.org_string:
# 只拆分出标题中的非英文单词进行匹配,英文单词容易误匹配(带空格的多个单词组合除外)
titles = [StringUtils.clear_upper(t) for t in re.split(
r'[\s/【】.\[\]\-]+',
torrent_meta.org_string
) if not StringUtils.is_english_word(t)]
# 在标题中判断是否存在标题、原语种标题
if media_titles.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}
subtitles = {StringUtils.clear_upper(t) for t in re.split(
r'[\s/【】|]+',
torrent.description) if not StringUtils.is_english_word(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}')
logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
return False

View File

@@ -98,7 +98,6 @@ class LoggerManager:
# 终端日志
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)
@@ -109,7 +108,6 @@ class LoggerManager:
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)
@@ -138,6 +136,7 @@ class LoggerManager:
if not _logger:
_logger = self.__setup_logger(logfile)
self._loggers[logfile] = _logger
# 调用logger的方法打印日志
if hasattr(_logger, method):
method = getattr(_logger, method)
method(f"{caller_name} - {msg}", *args, **kwargs)

View File

@@ -1,7 +1,9 @@
import multiprocessing
import os
import signal
import sys
import threading
from types import FrameType
import uvicorn as uvicorn
from PIL import Image
@@ -16,7 +18,7 @@ if SystemUtils.is_frozen():
sys.stdout = open(os.devnull, 'w')
sys.stderr = open(os.devnull, 'w')
from app.core.config import settings
from app.core.config import settings, global_vars
from app.core.module import ModuleManager
from app.core.plugin import PluginManager
from app.db.init import init_db, update_db, init_super_user
@@ -149,7 +151,7 @@ def check_auth():
"""
if SitesHelper().auth_level < 2:
err_msg = "用户认证失败,站点相关功能将无法使用!"
MessageHelper().put(f"注意:{err_msg}")
MessageHelper().put(f"注意:{err_msg}", title="用户认证", role="system")
CommandChian().post_message(
Notification(
mtype=NotificationType.Manual,
@@ -159,6 +161,22 @@ def check_auth():
)
def singal_handle():
"""
监听停止信号
"""
def stop_event(signum: int, _: FrameType):
"""
SIGTERM信号处理
"""
print(f"接收到停止信号:{signum},正在停止系统...")
global_vars.stop_system()
# 设置信号处理程序
signal.signal(signal.SIGTERM, stop_event)
signal.signal(signal.SIGINT, stop_event)
@App.on_event("shutdown")
def shutdown_server():
"""
@@ -168,6 +186,7 @@ def shutdown_server():
ModuleManager().stop()
# 停止插件
PluginManager().stop()
PluginManager().stop_monitor()
# 停止事件消费
Command().stop()
# 停止虚拟显示
@@ -209,6 +228,8 @@ def start_module():
start_frontend()
# 检查认证状态
check_auth()
# 监听停止信号
singal_handle()
if __name__ == '__main__':

View File

@@ -27,6 +27,14 @@ class _ModuleBase(metaclass=ABCMeta):
"""
pass
@staticmethod
@abstractmethod
def get_name() -> str:
"""
获取模块名称
"""
pass
@abstractmethod
def stop(self) -> None:
"""

View File

@@ -1,6 +1,9 @@
from typing import List, Optional, Tuple, Union
from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.log import logger
from app.modules import _ModuleBase
from app.modules.bangumi.bangumi import BangumiApi
@@ -20,16 +23,20 @@ class BangumiModule(_ModuleBase):
"""
测试模块连接性
"""
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}"
ret = RequestUtils().get_res("https://api.bgm.tv/")
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
@staticmethod
def get_name() -> str:
return "Bangumi"
def recognize_media(self, bangumiid: int = None,
**kwargs) -> Optional[MediaInfo]:
"""
@@ -53,6 +60,23 @@ class BangumiModule(_ModuleBase):
return None
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
"""
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
infos = self.bangumiapi.search(meta.name)
if infos:
return [MediaInfo(bangumi_info=info) for info in infos
if meta.name.lower() in str(info.get("name")).lower()
or meta.name.lower() in str(info.get("name_cn")).lower()]
return []
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
获取Bangumi信息
@@ -64,30 +88,58 @@ class BangumiModule(_ModuleBase):
logger.info(f"开始获取Bangumi信息{bangumiid} ...")
return self.bangumiapi.detail(bangumiid)
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
"""
获取Bangumi每日放送
:param page: 页码
:param count: 每页数量
"""
return self.bangumiapi.calendar(page, count)
infos = self.bangumiapi.calendar()
if infos:
return [MediaInfo(bangumi_info=info) for info in infos]
return []
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
"""
根据TMDBID查询电影演职员表
:param bangumiid: BangumiID
:param page: 页码
:param count: 数量
"""
persons = self.bangumiapi.persons(bangumiid) or []
persons = self.bangumiapi.credits(bangumiid)
if persons:
return persons[(page - 1) * count: page * count]
else:
return []
return [schemas.MediaPerson(source='bangumi', **person) for person in persons]
return []
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:
"""
根据BangumiID查询推荐电影
:param bangumiid: BangumiID
"""
return self.bangumiapi.subjects(bangumiid) or []
subjects = self.bangumiapi.subjects(bangumiid)
if subjects:
return [MediaInfo(bangumi_info=subject) for subject in subjects]
return []
def bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
获取人物详细信息
:param person_id: 豆瓣人物ID
"""
personinfo = self.bangumiapi.person_detail(person_id)
if personinfo:
return schemas.MediaPerson(source='bangumi', **{
"id": personinfo.get("id"),
"name": personinfo.get("name"),
"images": personinfo.get("images"),
"biography": personinfo.get("summary"),
"birthday": personinfo.get("birth_day"),
"gender": personinfo.get("gender")
})
return None
def bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:
"""
根据TMDBID查询人物参演作品
:param person_id: 人物ID
"""
credits_info = self.bangumiapi.person_credits(person_id=person_id)
if credits_info:
return [MediaInfo(bangumi_info=credit) for credit in credits_info]
return []

View File

@@ -12,11 +12,14 @@ class BangumiApi(object):
"""
_urls = {
"search": "search/subjects/%s?type=2",
"calendar": "calendar",
"detail": "v0/subjects/%s",
"persons": "v0/subjects/%s/persons",
"credits": "v0/subjects/%s/persons",
"subjects": "v0/subjects/%s/subjects",
"characters": "v0/subjects/%s/characters"
"characters": "v0/subjects/%s/characters",
"person_detail": "v0/persons/%s",
"person_credits": "v0/persons/%s/subjects",
}
_base_url = "https://api.bgm.tv/"
_req = RequestUtils(session=requests.Session())
@@ -38,7 +41,16 @@ class BangumiApi(object):
print(e)
return None
def calendar(self, page: int = 1, count: int = 30):
def search(self, name):
"""
搜索媒体信息
"""
result = self.__invoke("search/subject/%s" % name)
if result:
return result.get("list")
return []
def calendar(self):
"""
获取每日放送返回items
"""
@@ -134,7 +146,7 @@ class BangumiApi(object):
if result:
for item in result:
ret_list.extend(item.get("items") or [])
return ret_list[(page - 1) * count: page * count]
return ret_list
def detail(self, bid: int):
"""
@@ -142,7 +154,7 @@ class BangumiApi(object):
"""
return self.__invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
def persons(self, bid: int):
def credits(self, bid: int):
"""
获取番剧人物
"""
@@ -163,3 +175,20 @@ class BangumiApi(object):
获取关联条目信息
"""
return self.__invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
def person_detail(self, person_id: int):
"""
获取人物详细信息
"""
return self.__invoke(self._urls["person_detail"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
def person_credits(self, person_id: int):
"""
获取人物参演作品
"""
ret_list = []
result = self.__invoke(self._urls["person_credits"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
if result:
for item in result:
ret_list.append(item)
return ret_list

View File

@@ -4,6 +4,7 @@ from typing import List, Optional, Tuple, Union
import cn2an
from app import schemas
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
@@ -13,6 +14,7 @@ from app.modules import _ModuleBase
from app.modules.douban.apiv2 import DoubanApi
from app.modules.douban.douban_cache import DoubanCache
from app.modules.douban.scraper import DoubanScraper
from app.schemas import MediaPerson
from app.schemas.types import MediaType
from app.utils.common import retry
from app.utils.http import RequestUtils
@@ -36,16 +38,20 @@ class DoubanModule(_ModuleBase):
"""
测试模块连接性
"""
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}"
ret = RequestUtils().get_res("https://movie.douban.com/")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
return False, "豆瓣网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
@staticmethod
def get_name() -> str:
return "豆瓣"
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: str = None,
@@ -449,7 +455,7 @@ class DoubanModule(_ModuleBase):
return __douban_movie() or __douban_tv()
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
page: int = 1, count: int = 30) -> Optional[List[dict]]:
page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
发现豆瓣电影、剧集
:param mtype: 媒体类型
@@ -466,69 +472,75 @@ class DoubanModule(_ModuleBase):
else:
infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count,
sort=sort, tags=tags)
if not infos:
return []
return infos.get("items") or []
if infos and infos.get("items"):
medias = [MediaInfo(douban_info=info) for info in infos.get("items")]
return [media for media in medias if media.poster_path
and "movie_large.jpg" not in media.poster_path
and "tv_normal.png" not in media.poster_path
and "movie_large.jpg" not in media.poster_path
and "tv_normal.jpg" not in media.poster_path
and "tv_large.jpg" not in media.poster_path]
return []
def movie_showing(self, page: int = 1, count: int = 30) -> List[dict]:
def movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取正在上映的电影
"""
infos = self.doubanapi.movie_showing(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[dict]:
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取豆瓣本周口碑国产剧
"""
infos = self.doubanapi.tv_chinese_best_weekly(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos:
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[dict]:
def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取豆瓣本周口碑外国剧
"""
infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
def tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取豆瓣动画剧
"""
infos = self.doubanapi.tv_animation(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def movie_hot(self, page: int = 1, count: int = 30) -> List[dict]:
def movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取豆瓣热门电影
"""
infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def tv_hot(self, page: int = 1, count: int = 30) -> List[dict]:
def tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取豆瓣热门剧集
"""
infos = self.doubanapi.tv_hot(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
@@ -536,14 +548,12 @@ class DoubanModule(_ModuleBase):
:param meta: 识别的元数据
:reutrn: 媒体信息
"""
# 未启用豆瓣搜索时返回None
if settings.RECOGNIZE_SOURCE != "douban":
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
result = self.doubanapi.search(meta.name)
if not result:
if not result or not result.get("items"):
return []
# 返回数据
ret_medias = []
@@ -552,6 +562,8 @@ class DoubanModule(_ModuleBase):
continue
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue
if meta.name not in item_obj.get("target", {}).get("title"):
continue
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
# 将搜索词中的季写入标题中
if ret_medias and meta.begin_season:
@@ -563,6 +575,24 @@ class DoubanModule(_ModuleBase):
media.season = meta.begin_season
return ret_medias
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
"""
if not name:
return []
result = self.doubanapi.person_search(keyword=name)
if result and result.get('items'):
return [MediaPerson(source='douban', **{
'id': item.get('target_id'),
'name': item.get('target', {}).get('title'),
'url': item.get('target', {}).get('url'),
'images': item.get('target', {}).get('cover', {}),
'avatar': (item.get('target', {}).get('cover_img', {}).get('url')
or '').replace("/l/public/", "/s/public/"),
}) for item in result.get('items') if name in item.get('target', {}).get('title')]
return []
@retry(Exception, 5, 3, 3, logger=logger)
def match_doubaninfo(self, name: str, imdbid: str = None,
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
@@ -588,7 +618,7 @@ class DoubanModule(_ModuleBase):
# 搜索
logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...")
result = self.doubanapi.search(f"{name} {year or ''}".strip())
if not result:
if not result or not result.get("items"):
logger.warn(f"未找到 {name} 的豆瓣信息")
return {}
# 触发rate limit
@@ -618,15 +648,15 @@ class DoubanModule(_ModuleBase):
return item
return {}
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
def movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
"""
获取豆瓣电影TOP250
"""
infos = self.doubanapi.movie_top250(start=(page - 1) * count,
count=count)
if not infos:
return []
return infos.get("subject_collection_items")
if infos and infos.get("subject_collection_items"):
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
return []
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
@@ -767,48 +797,99 @@ class DoubanModule(_ModuleBase):
self.cache.clear()
logger.info("豆瓣缓存清除完成")
def douban_movie_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]:
"""
根据TMDBID查询电影演职员表
:param doubanid: 豆瓣ID
:param page: 页码
:param count: 数量
"""
result = self.doubanapi.movie_celebrities(subject_id=doubanid)
if not result:
return []
ret_list = result.get("actors") or []
if ret_list:
return ret_list[(page - 1) * count: page * count]
else:
return []
# 更新豆瓣演员信息中的ID从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id
for doubaninfo in ret_list:
doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1]
return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list]
return []
def douban_tv_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
def douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]:
"""
根据TMDBID查询电视剧演职员表
:param doubanid: 豆瓣ID
:param page: 页码
:param count: 数量
"""
result = self.doubanapi.tv_celebrities(subject_id=doubanid)
if not result:
return []
ret_list = result.get("actors") or []
if ret_list:
return ret_list[(page - 1) * count: page * count]
else:
return []
# 更新豆瓣演员信息中的ID从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id
for doubaninfo in ret_list:
doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1]
return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list]
return []
def douban_movie_recommend(self, doubanid: str) -> List[dict]:
def douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]:
"""
根据豆瓣ID查询推荐电影
:param doubanid: 豆瓣ID
"""
return self.doubanapi.movie_recommendations(subject_id=doubanid) or []
recommend = self.doubanapi.movie_recommendations(subject_id=doubanid)
if recommend:
return [MediaInfo(douban_info=info) for info in recommend]
return []
def douban_tv_recommend(self, doubanid: str) -> List[dict]:
def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
"""
根据豆瓣ID查询推荐电视剧
:param doubanid: 豆瓣ID
"""
return self.doubanapi.tv_recommendations(subject_id=doubanid) or []
recommend = self.doubanapi.tv_recommendations(subject_id=doubanid)
if recommend:
return [MediaInfo(douban_info=info) for info in recommend]
return []
def douban_person_detail(self, person_id: int) -> schemas.MediaPerson:
"""
获取人物详细信息
:param person_id: 豆瓣人物ID
"""
detail = self.doubanapi.person_detail(person_id)
if detail:
also_known_as = []
infos = detail.get("extra", {}).get("info")
if infos:
also_known_as = ["".join(info) for info in infos]
image = detail.get("cover_img", {}).get("url")
if image:
image = image.replace("/l/public/", "/s/public/")
return schemas.MediaPerson(source='douban', **{
"id": detail.get("id"),
"name": detail.get("title"),
"avatar": image,
"biography": detail.get("extra", {}).get("short_info"),
"also_known_as": also_known_as,
})
return schemas.MediaPerson(source='douban')
def douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
"""
根据TMDBID查询人物参演作品
:param person_id: 人物ID
:param page: 页码
"""
# 获取人物参演作品集
personinfo = self.doubanapi.person_detail(person_id)
if not personinfo:
return []
collection_id = None
for module in personinfo.get("modules"):
if module.get("type") == "work_collections":
collection_id = module.get("payload", {}).get("id")
# 查询作品集内容
if collection_id:
collections = self.doubanapi.person_work(subject_id=collection_id, start=(page - 1) * 20, count=20)
if collections:
works = collections.get("works")
return [MediaInfo(douban_info=work.get("subject")) for work in works]
return []

View File

@@ -22,6 +22,7 @@ class DoubanApi(metaclass=Singleton):
# 聚合搜索
"search": "/search/weixin",
"search_agg": "/search",
"search_subject": "/search/subjects",
"imdbid": "/movie/imdb/%s",
# 电影探索
@@ -137,6 +138,10 @@ class DoubanApi(metaclass=Singleton):
# doulist
"doulist": "/doulist/",
"doulist_items": "/doulist/%s/items",
# person
"person_detail": "/elessar/subject/",
"person_work": "/elessar/work_collections/%s/works",
}
_user_agents = [
@@ -176,7 +181,7 @@ class DoubanApi(metaclass=Singleton):
"""
req_url = self._base_url + url
params = {'apiKey': self._api_key}
params: dict = {'apiKey': self._api_key}
if kwargs:
params.update(kwargs)
@@ -194,7 +199,7 @@ class DoubanApi(metaclass=Singleton):
ua=choice(self._user_agents),
session=self._session
).get_res(url=req_url, params=params)
if resp.status_code == 400 and "rate_limit" in resp.text:
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
return resp.json()
return resp.json() if resp else {}
@@ -223,10 +228,17 @@ class DoubanApi(metaclass=Singleton):
ua=settings.USER_AGENT,
session=self._session,
).post_res(url=req_url, data=params)
if resp.status_code == 400 and "rate_limit" in resp.text:
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
return resp.json()
return resp.json() if resp else {}
def imdbid(self, imdbid: str,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
IMDBID搜索
"""
return self.__post(self._urls["imdbid"] % imdbid, _ts=ts)
def search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
"""
@@ -235,13 +247,6 @@ class DoubanApi(metaclass=Singleton):
return self.__invoke(self._urls["search"], q=keyword,
start=start, count=count, _ts=ts)
def imdbid(self, imdbid: str,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
IMDBID搜索
"""
return self.__post(self._urls["imdbid"] % imdbid, _ts=ts)
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
@@ -274,6 +279,14 @@ class DoubanApi(metaclass=Singleton):
return self.__invoke(self._urls["group_search"], q=keyword,
start=start, count=count, _ts=ts)
def person_search(self, keyword: str, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
人物搜索
"""
return self.__invoke(self._urls["search_subject"], type="person", q=keyword,
start=start, count=count, _ts=ts)
def movie_showing(self, start: int = 0, count: int = 20,
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
@@ -475,6 +488,30 @@ class DoubanApi(metaclass=Singleton):
return self.__invoke(self._urls["tv_photos"] % subject_id,
start=start, count=count, _ts=ts)
def person_detail(self, subject_id: int):
"""
用户详情
:param subject_id: 人物 id
:return:
"""
return self.__invoke(self._urls["person_detail"] + str(subject_id))
def person_work(self, subject_id: int, start: int = 0, count: int = 20, sort_by: str = "time",
collection_title: str = "影视",
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
用户作品集
:param subject_id: work_collection id
:param start: 开始页
:param count: 数量
:param sort_by: collection or time or vote
:param collection_title: 影视 or 图书 or 音乐
:param ts: 时间戳
:return:
"""
return self.__invoke(self._urls["person_work"] % subject_id, sortby=sort_by, collection_title=collection_title,
start=start, count=count, _ts=ts)
def clear_cache(self):
"""
清空LRU缓存

View File

@@ -193,15 +193,15 @@ class DoubanScraper:
url = url.replace("/format/webp", "/format/jpg")
file_path.with_suffix(".jpg")
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
with RequestUtils().get_res(url=url) as r:
if r:
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content)
else:
file_path.write_bytes(r.content)
logger.info(f"图片已保存:{file_path}")
r = RequestUtils().get_res(url=url)
if r:
if self._transfer_type in ['rclone_move', 'rclone_copy']:
self.__save_remove_file(file_path, r.content)
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
file_path.write_bytes(r.content)
logger.info(f"图片已保存:{file_path}")
else:
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
except Exception as err:
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")

View File

@@ -14,6 +14,10 @@ class EmbyModule(_ModuleBase):
def init_module(self) -> None:
self.emby = Emby()
@staticmethod
def get_name() -> str:
return "Emby"
def stop(self):
pass

View File

@@ -56,12 +56,12 @@ class Emby:
return []
req_url = "%semby/Library/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 []
res = RequestUtils().get_res(req_url)
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
@@ -74,29 +74,29 @@ class Emby:
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'))
res = RequestUtils().get_res(req_url)
if res:
library_items = res.json().get("Items")
librarys = []
for library_item in library_items:
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
if library_name and library_paths:
librarys.append({
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
return []
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 []
@@ -113,12 +113,12 @@ class Emby:
user = self.user
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Items")
else:
logger.error(f"User/Views 未获取到返回数据")
return []
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Items")
else:
logger.error(f"User/Views 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接User/Views 出错:" + str(e))
return []
@@ -164,20 +164,20 @@ class Emby:
return None
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
# 查询管理员
res = RequestUtils().get_res(req_url)
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
if user.get("Name") == user_name:
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
# 查询管理员
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return None
@@ -227,11 +227,11 @@ class Emby:
return None
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
except Exception as e:
logger.error(f"连接System/Info出错" + str(e))
@@ -245,12 +245,12 @@ class Emby:
return 0
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("TotalRecordCount")
else:
logger.error(f"Users/Query 未获取到返回数据")
return 0
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("TotalRecordCount")
else:
logger.error(f"Users/Query 未获取到返回数据")
return 0
except Exception as e:
logger.error(f"连接Users/Query出错" + str(e))
return 0
@@ -264,17 +264,17 @@ class Emby:
return schemas.Statistic()
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
res = RequestUtils().get_res(req_url)
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
except Exception as e:
logger.error(f"连接Items/Counts出错" + str(e))
return schemas.Statistic()
@@ -299,14 +299,14 @@ class Emby:
"&api_key=%s") % (
self._host, name, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
except Exception as e:
logger.error(f"连接Items出错" + str(e))
return None
@@ -329,36 +329,36 @@ class Emby:
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
self._host, title, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for res_item in res_items:
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="emby",
library=res_item.get("ParentId"),
item_id=res_item.get("Id"),
item_type=res_item.get("Type"),
title=res_item.get("Name"),
original_title=res_item.get("OriginalTitle"),
year=res_item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
path=res_item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
continue
if (mediaserver_item.title == title
and (not year or str(mediaserver_item.year) == str(year))):
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for res_item in res_items:
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="emby",
library=res_item.get("ParentId"),
item_id=res_item.get("Id"),
item_type=res_item.get("Type"),
title=res_item.get("Name"),
original_title=res_item.get("OriginalTitle"),
year=res_item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
path=res_item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
return ret_movies
continue
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:
logger.error(f"连接Items出错" + str(e))
return None
@@ -401,25 +401,25 @@ class Emby:
try:
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
self._host, item_id, season, self._apikey)
with RequestUtils().get_res(req_url) as res_json:
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if season_index not in season_episodes:
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
# 返回
return item_id, season_episodes
res_json = RequestUtils().get_res(req_url)
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if season_index not in season_episodes:
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
# 返回
return item_id, season_episodes
except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None
@@ -463,13 +463,13 @@ class Emby:
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(f"影片图片链接:{res.url}")
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
res = RequestUtils().get_res(req_url)
if res and res.status_code != 404:
logger.info(f"影片图片链接:{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
@@ -482,11 +482,11 @@ class Emby:
return False
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
try:
with RequestUtils().post_res(req_url) as res:
if res:
return True
else:
logger.info(f"刷新媒体库对象 {item_id} 失败无法连接Emby")
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库对象 {item_id} 失败无法连接Emby")
except Exception as e:
logger.error(f"连接Items/Id/Refresh出错" + str(e))
return False
@@ -500,11 +500,11 @@ class Emby:
return False
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().post_res(req_url) as res:
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Emby")
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Emby")
except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e))
return False
@@ -579,23 +579,23 @@ class Emby:
return None
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="emby",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="emby",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
except Exception as e:
logger.error(f"连接Items/Id出错" + str(e))
return None
@@ -610,17 +610,17 @@ class Emby:
yield None
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(parent=result.get('Id')):
yield item
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(parent=result.get('Id')):
yield item
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
yield None
@@ -1032,52 +1032,52 @@ class Emby:
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")
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = item.get("ProductionYear")
else:
title = f'{item.get("SeriesName")}'
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
if item_type == MediaType.MOVIE.value:
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
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 未获取到返回数据")
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 []
@@ -1095,35 +1095,35 @@ class Emby:
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 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []

View File

@@ -321,16 +321,20 @@ class FanartModule(_ModuleBase):
"""
测试模块连接性
"""
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}"
ret = RequestUtils().get_res("https://webservice.fanart.tv")
if ret and ret.status_code == 200:
return True, ""
elif ret:
return False, f"无法连接fanart错误码{ret.status_code}"
return False, "fanart网络连接失败"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FANART_API_KEY", True
@staticmethod
def get_name() -> str:
return "Fanart"
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
获取图片

View File

@@ -9,21 +9,34 @@ from app.core.config import settings
from app.core.context import MediaInfo
from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.helper.directory import DirectoryHelper
from app.helper.message import MessageHelper
from app.log import logger
from app.modules import _ModuleBase
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, MediaDirectory
from app.schemas.types import MediaType
from app.utils.string import StringUtils
from app.utils.system import SystemUtils
lock = Lock()
class FileTransferModule(_ModuleBase):
"""
文件整理模块
"""
def __init__(self):
super().__init__()
self.directoryhelper = DirectoryHelper()
self.messagehelper = MessageHelper()
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "文件整理"
def stop(self):
pass
@@ -31,34 +44,37 @@ class FileTransferModule(_ModuleBase):
"""
测试模块连接性
"""
if not settings.DOWNLOAD_PATH:
return False, "下载目录未设置"
directoryhelper = DirectoryHelper()
# 检查下载目录
download_paths: List[str] = []
for path in [settings.DOWNLOAD_PATH,
settings.DOWNLOAD_MOVIE_PATH,
settings.DOWNLOAD_TV_PATH,
settings.DOWNLOAD_ANIME_PATH]:
download_paths = directoryhelper.get_download_dirs()
if not download_paths:
return False, "下载目录未设置"
for d_path in download_paths:
path = d_path.path
if not path:
continue
return False, f"下载目录 {d_path.name} 对应路径未设置"
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]
return False, f"下载目录 {d_path.name} 对应路径 {path} 不存在"
# 检查媒体库目录
if not settings.LIBRARY_PATH:
libaray_dirs = directoryhelper.get_library_dirs()
if not libaray_dirs:
return False, "媒体库目录未设置"
# 比较媒体库目录的设备ID
for path in settings.LIBRARY_PATHS:
for l_path in libaray_dirs:
path = l_path.path
if not path:
return False, f"媒体库目录 {l_path.name} 对应路径未设置"
library_path = Path(path)
if not library_path.exists():
return False, f"媒体库目录不存在:{library_path}"
return False, f"媒体库目录{l_path.name} 对应的路径 {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)} 不在同一设备,将无法硬链接"
for d_path in download_paths:
download_path = Path(d_path.path)
if l_path.media_type == d_path.media_type and l_path.category == d_path.category:
if not SystemUtils.is_same_disk(library_path, download_path):
return False, f"媒体库目录 {library_path} " \
f"与下载目录 {download_path} 不在同一磁盘/存储空间/映射路径,将无法硬链接"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
@@ -66,7 +82,8 @@ class FileTransferModule(_ModuleBase):
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
transfer_type: str, target: Path = None,
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
episodes_info: List[TmdbEpisode] = None,
scrape: bool = None) -> TransferInfo:
"""
文件转移
:param path: 文件路径
@@ -75,14 +92,24 @@ class FileTransferModule(_ModuleBase):
:param transfer_type: 转移方式
:param target: 目标路径
:param episodes_info: 当前季的全部集信息
:param scrape: 是否刮削元数据
:return: {path, target_path, message}
"""
# 获取目标路径
if not target:
# 未指定目的目录,根据源目录选择一个媒体库
target = self.get_target_path(in_path=path)
# 未指定目的目录,选择一个媒体库
dir_info = DirectoryHelper().get_library_dir(mediainfo, in_path=path)
if not dir_info:
logger.error(f"{mediainfo.type.value} {mediainfo.title_year} 未找到有效的媒体库目录,无法转移文件,源路径:{path}")
return TransferInfo(success=False,
path=path,
message="未找到有效的媒体库目录")
# 拼装媒体库一、二级子目录
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target)
if scrape is None:
need_scrape = dir_info.scrape
else:
need_scrape = scrape
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dir_info)
else:
# 指定了目的目录
if target.is_file():
@@ -90,24 +117,18 @@ class FileTransferModule(_ModuleBase):
return TransferInfo(success=False,
path=path,
message=f"{target} 不是有效目录")
# 只拼装二级子目录(不要一级目录)
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target, typename_dir=False)
if not target:
logger.error("未找到媒体库目录,无法转移文件")
return TransferInfo(success=False,
path=path,
message="未找到媒体库目录")
else:
logger.info(f"获取转移目标路径:{target}")
# FIXME 指定了目的目录时,拿不到是否需要刮削的配置了
need_scrape = False
logger.info(f"获取转移目标路径:{target}")
# 转移
return self.transfer_media(in_path=path,
in_meta=meta,
mediainfo=mediainfo,
transfer_type=transfer_type,
target_dir=target,
episodes_info=episodes_info)
episodes_info=episodes_info,
need_scrape=need_scrape)
@staticmethod
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
@@ -380,43 +401,24 @@ class FileTransferModule(_ModuleBase):
over_flag=over_flag)
@staticmethod
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path, typename_dir: bool = True) -> Path:
def __get_dest_dir(mediainfo: MediaInfo, target_dir: MediaDirectory) -> Path:
"""
根据设置并装媒体库目录
:param mediainfo: 媒体信息
:target_dir: 媒体库根目录
:typename_dir: 是否加上类型目录
"""
if not target_dir:
return target_dir
if not target_dir.media_type and target_dir.auto_category:
# 一级自动分类
download_dir = Path(target_dir.path) / mediainfo.type.value
else:
download_dir = Path(target_dir.path)
if mediainfo.type == MediaType.MOVIE:
# 电影
if typename_dir:
# 目的目录加上类型和二级分类
target_dir = target_dir / settings.LIBRARY_MOVIE_NAME / mediainfo.category
else:
# 目的目录加上二级分类
target_dir = target_dir / mediainfo.category
if not target_dir.category and target_dir.auto_category and mediainfo.category:
# 二级自动分类
download_dir = download_dir / mediainfo.category
if mediainfo.type == MediaType.TV:
# 电视剧
if mediainfo.genre_ids \
and set(mediainfo.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
# 动漫
if typename_dir:
target_dir = target_dir / (settings.LIBRARY_ANIME_NAME
or settings.LIBRARY_TV_NAME) / mediainfo.category
else:
target_dir = target_dir / mediainfo.category
else:
# 电视剧
if typename_dir:
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
else:
target_dir = target_dir / mediainfo.category
return target_dir
return download_dir
def transfer_media(self,
in_path: Path,
@@ -424,7 +426,8 @@ class FileTransferModule(_ModuleBase):
mediainfo: MediaInfo,
transfer_type: str,
target_dir: Path,
episodes_info: List[TmdbEpisode] = None
episodes_info: List[TmdbEpisode] = None,
need_scrape: bool = False
) -> TransferInfo:
"""
识别并转移一个文件或者一个目录下的所有文件
@@ -434,6 +437,7 @@ class FileTransferModule(_ModuleBase):
:param target_dir: 媒体库根目录
:param transfer_type: 文件转移方式
:param episodes_info: 当前季的全部集信息
:param need_scrape: 是否需要刮削
:return: TransferInfo、错误信息
"""
# 检查目录路径
@@ -486,7 +490,8 @@ class FileTransferModule(_ModuleBase):
path=in_path,
target_path=new_path,
total_size=file_size,
is_bluray=bluray_flag)
is_bluray=bluray_flag,
need_scrape=need_scrape)
else:
# 转移单个文件
if mediainfo.type == MediaType.TV:
@@ -585,7 +590,8 @@ class FileTransferModule(_ModuleBase):
total_size=file_size,
is_bluray=False,
file_list=[str(in_path)],
file_list_new=[str(new_file)])
file_list_new=[str(new_file)],
need_scrape=need_scrape)
@staticmethod
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
@@ -627,7 +633,7 @@ class FileTransferModule(_ModuleBase):
# 原语种标题
"original_title": __convert_invalid_characters(mediainfo.original_title),
# 原文件名
"original_name": f"{meta.org_string}{file_ext}",
"original_name": meta.title,
# 识别名称(优先使用中文)
"name": meta.name,
# 识别的英文名称(可能为空)
@@ -685,86 +691,21 @@ class FileTransferModule(_ModuleBase):
else:
return Path(render_str)
@staticmethod
def get_library_path(path: Path):
"""
根据文件路径查询其所在的媒体库目录,查询不到的返回输入目录
"""
if not path:
return None
if not settings.LIBRARY_PATHS:
return path
# 目的路径,多路径以,分隔
dest_paths = settings.LIBRARY_PATHS
for libpath in dest_paths:
try:
if path.is_relative_to(libpath):
return libpath
except Exception as e:
logger.debug(f"计算媒体库路径时出错:{str(e)}")
continue
return path
@staticmethod
def get_target_path(in_path: Path = None) -> Optional[Path]:
"""
计算一个最好的目的目录有in_path时找与in_path同路径的没有in_path时顺序查找1个符合大小要求的没有in_path和size时返回第1个
:param in_path: 源目录
"""
if not settings.LIBRARY_PATHS:
return None
# 目的路径,多路径以,分隔
dest_paths = settings.LIBRARY_PATHS
# 只有一个路径,直接返回
if len(dest_paths) == 1:
return dest_paths[0]
# 匹配有最长共同上级路径的目录
max_length = 0
target_path = None
if in_path:
for path in dest_paths:
try:
# 计算in_path和path的公共字符串长度
relative = StringUtils.find_common_prefix(str(in_path), str(path))
if len(str(path)) == len(relative):
# 目录完整匹配的,直接返回
return path
if len(relative) > max_length:
# 更新最大长度
max_length = len(relative)
target_path = path
except Exception as e:
logger.debug(f"计算目标路径时出错:{str(e)}")
continue
if target_path:
return target_path
# 顺序匹配第1个满足空间存储要求的目录
if in_path.exists():
file_size = in_path.stat().st_size
for path in dest_paths:
if SystemUtils.free_space(path) > file_size:
return path
# 默认返回第1个
return dest_paths[0]
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
"""
判断媒体文件是否存在于本地文件系统,只支持标准媒体库结构
:param mediainfo: 识别的媒体信息
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if not settings.LIBRARY_PATHS:
return None
# 目的路径
dest_paths = settings.LIBRARY_PATHS
dest_paths = DirectoryHelper().get_library_dirs()
# 检查每一个媒体库目录
for dest_path in dest_paths:
# 媒体库路径
target_dir = self.get_target_path(dest_path)
if not target_dir:
if not dest_path.path:
continue
# 媒体分类路径
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dest_path)
# 重命名格式
rename_format = settings.TV_RENAME_FORMAT \
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
@@ -818,8 +759,6 @@ class FileTransferModule(_ModuleBase):
删除目录下的所有版本文件
:param path: 目录路径
"""
if not path.exists():
return False
# 识别文件中的季集信息
meta = MetaInfoPath(path)
season = meta.season
@@ -832,7 +771,7 @@ class FileTransferModule(_ModuleBase):
return False
# 删除文件
for media_file in media_files:
if media_file == path:
if str(media_file) == str(path):
continue
# 识别文件中的季集信息
filemeta = MetaInfoPath(media_file)

View File

@@ -1,30 +1,42 @@
import threading
from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults
class RuleParser:
_lock = threading.Lock()
_thread_local = threading.local()
def __init__(self):
"""
定义语法规则
"""
# 表达式
expr: Forward = Forward()
# 原子
atom: Combine = Combine(Word(alphas, alphanums) | Word(nums) + Word(alphas, alphanums))
# 逻辑非操作符
operator_not: Literal = Literal('!').setParseAction(lambda: 'not')
# 逻辑操作符
operator_or: Literal = Literal('|').setParseAction(lambda: 'or')
# 逻辑操作符
operator_and: Literal = Literal('&').setParseAction(lambda: 'and')
# 定义表达式的语法规则
expr <<= operator_not + expr | operator_or | operator_and | atom | ('(' + expr + ')')
with self._lock:
if not hasattr(self._thread_local, 'initialized'):
# 表达式
expr: Forward = Forward()
# 原子
atom: Combine = Combine(Word(alphas, alphanums) | (Word(nums) + Word(alphas, alphanums)))
# 逻辑操作符
operator_not: Literal = Literal('!').setParseAction(lambda t: 'not')
# 逻辑操作符
operator_or: Literal = Literal('|').setParseAction(lambda t: 'or')
# 逻辑与操作符
operator_and: Literal = Literal('&').setParseAction(lambda t: 'and')
# 定义表达式的语法规则
expr <<= (operator_not + expr) | atom | ('(' + expr + ')')
# 运算符优先级
self.expr = infixNotation(expr,
[(operator_not, 1, opAssoc.RIGHT),
(operator_and, 2, opAssoc.LEFT),
(operator_or, 2, opAssoc.LEFT)])
# 运算符优先级
self.expr = infixNotation(expr,
[(operator_not, 1, opAssoc.RIGHT),
(operator_and, 2, opAssoc.LEFT),
(operator_or, 2, opAssoc.LEFT)])
self._thread_local.expr = self.expr
self._thread_local.initialized = True
else:
self.expr = self._thread_local.expr
def parse(self, expression: str) -> ParseResults:
"""
@@ -41,7 +53,9 @@ class RuleParser:
if __name__ == '__main__':
# 测试代码
expression_str = "!BLU & 4K & CN > !BLU & 1080P & CN > !BLU & 4K > !BLU & 1080P"
expression_str = """
SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D
"""
for exp in expression_str.split('>'):
parsed_expr = RuleParser().parse(exp)
print(parsed_expr.as_list())
parsed_expr = RuleParser().parse(exp.strip())
print(parsed_expr.asList())

View File

@@ -136,6 +136,10 @@ class FilterModule(_ModuleBase):
def init_module(self) -> None:
self.parser = RuleParser()
@staticmethod
def get_name() -> str:
return "过滤器"
def stop(self):
pass
@@ -169,6 +173,7 @@ class FilterModule(_ModuleBase):
continue
# 能命中优先级的才返回
if not self.__get_order(torrent, rule_string):
logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description} 不匹配优先级规则")
continue
ret_torrents.append(torrent)
@@ -191,7 +196,7 @@ class FilterModule(_ModuleBase):
torrent_episodes = meta.episode_list
if not set(torrent_seasons).issubset(set(seasons)):
# 种子季不在过滤季中
logger.info(f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {seasons}")
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}")
return False
if not torrent_episodes:
# 整季按匹配处理
@@ -201,7 +206,7 @@ class FilterModule(_ModuleBase):
if need_episodes \
and not set(torrent_episodes).intersection(set(need_episodes)):
# 单季集没有交集的不要
logger.info(f"种子 {torrent.site_name} - {torrent.title} "
logger.debug(f"种子 {torrent.site_name} - {torrent.title} "
f"{torrent_episodes} 没有需要的集:{need_episodes}")
return False
return True
@@ -223,7 +228,7 @@ class FilterModule(_ModuleBase):
if self.__match_group(torrent, parsed_group.as_list()[0]):
# 出现匹配时中断
matched = True
logger.info(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}")
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}")
torrent.pri_order = res_order
break
# 优先级降低,继续匹配

View File

@@ -5,6 +5,7 @@ from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.core.context import TorrentInfo
from app.db.sitestatistic_oper import SiteStatisticOper
from app.helper.sites import SitesHelper
from app.log import logger
from app.modules import _ModuleBase
@@ -12,6 +13,7 @@ from app.modules.indexer.mtorrent import MTorrentSpider
from app.modules.indexer.spider import TorrentSpider
from app.modules.indexer.tnode import TNodeSpider
from app.modules.indexer.torrentleech import TorrentLeech
from app.modules.indexer.yema import YemaSpider
from app.schemas.types import MediaType
from app.utils.string import StringUtils
@@ -24,6 +26,10 @@ class IndexerModule(_ModuleBase):
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "站点索引"
def stop(self):
pass
@@ -70,10 +76,12 @@ class IndexerModule(_ModuleBase):
# 开始索引
result_array = []
# 开始计时
start_time = datetime.now()
# 搜索多个关键字
error_flag = False
for search_word in keywords:
# 可能为关键字或ttxxxx
if search_word \
@@ -104,6 +112,12 @@ class IndexerModule(_ModuleBase):
mtype=mtype,
page=page
)
elif site.get('parser') == "Yema":
error_flag, result = YemaSpider(site).search(
keyword=search_word,
mtype=mtype,
page=page
)
else:
error_flag, result = self.__spider_search(
search_word=search_word,
@@ -126,7 +140,14 @@ class IndexerModule(_ModuleBase):
logger.error(f"{site.get('name')} 搜索出错:{str(err)}")
# 索引花费的时间
seconds = round((datetime.now() - start_time).seconds, 1)
seconds = (datetime.now() - start_time).seconds
# 统计索引情况
domain = StringUtils.get_url_domain(site.get("domain"))
if error_flag:
SiteStatisticOper().fail(domain)
else:
SiteStatisticOper().success(domain=domain, seconds=seconds)
# 返回结果
if not result_array or len(result_array) == 0:

View File

@@ -15,7 +15,7 @@ from app.utils.string import StringUtils
class MTorrentSpider:
"""
mTorrent API需要缓存ApiKey
mTorrent API
"""
_indexerid = None
_domain = None
@@ -27,6 +27,7 @@ class MTorrentSpider:
_searchurl = "%sapi/torrent/search"
_downloadurl = "%sapi/torrent/genDlToken"
_pageurl = "%sdetail/%s"
_timeout = 15
# 电影分类
_movie_category = ['401', '419', '420', '421', '439', '405', '404']
@@ -34,6 +35,8 @@ class MTorrentSpider:
# API KEY
_apikey = None
# JWT Token
_token = None
# 标签
_labels = {
@@ -58,49 +61,15 @@ class MTorrentSpider:
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
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
self._apikey = indexer.get('apikey')
self._token = indexer.get('token')
self._timeout = indexer.get('timeout') or 15
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, []
@@ -125,7 +94,7 @@ class MTorrentSpider:
},
proxies=self._proxy,
referer=f"{self._domain}browse",
timeout=15
timeout=self._timeout
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:

View File

@@ -63,8 +63,8 @@ class TorrentSpider:
torrents_info: dict = {}
# 种子列表
torrents_info_array: list = []
# 搜索超时, 默认: 30
_timeout = 30
# 搜索超时, 默认: 15
_timeout = 15
def __init__(self,
indexer: CommentedMap,

View File

@@ -18,6 +18,7 @@ class TNodeSpider:
_ua = None
_token = None
_size = 100
_timeout = 15
_searchurl = "%sapi/torrent/advancedSearch"
_downloadurl = "%sapi/torrent/download/%s"
_pageurl = "%storrent/info/%s"
@@ -32,6 +33,7 @@ class TNodeSpider:
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self._timeout = indexer.get('timeout') or 15
self.init_config()
def init_config(self):
@@ -43,7 +45,7 @@ class TNodeSpider:
res = RequestUtils(ua=self._ua,
cookies=self._cookie,
proxies=self._proxy,
timeout=15).get_res(url=self._domain)
timeout=self._timeout).get_res(url=self._domain)
if res and res.status_code == 200:
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
if csrf_token:
@@ -77,7 +79,7 @@ class TNodeSpider:
},
cookies=self._cookie,
proxies=self._proxy,
timeout=15
timeout=self._timeout
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:

View File

@@ -17,11 +17,13 @@ class TorrentLeech:
_browseurl = "%storrents/browse/list/page/2%s"
_downloadurl = "%sdownload/%s/%s"
_pageurl = "%storrent/%s"
_timeout = 15
def __init__(self, indexer: CommentedMap):
self._indexer = indexer
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._timeout = indexer.get('timeout') or 15
def search(self, keyword: str, page: int = 0) -> Tuple[bool, List[dict]]:
@@ -40,7 +42,7 @@ class TorrentLeech:
},
cookies=self._indexer.get('cookie'),
proxies=self._proxy,
timeout=15
timeout=self._timeout
).get_res(url)
torrents = []
if res and res.status_code == 200:

148
app/modules/indexer/yema.py Normal file
View File

@@ -0,0 +1,148 @@
from typing import Tuple, List
from ruamel.yaml import CommentedMap
from app.core.config import settings
from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger
from app.schemas import MediaType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class YemaSpider:
"""
YemaPT API
"""
_indexerid = None
_domain = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_size = 40
_searchurl = "%sapi/torrent/fetchCategoryOpenTorrentList"
_downloadurl = "%sapi/torrent/download?id=%s"
_pageurl = "%s#/torrent/detail/%s/"
_timeout = 15
# 分类
_movie_category = 4
_tv_category = 5
def __init__(self, indexer: CommentedMap):
self.systemconfig = SystemConfigOper()
if indexer:
self._indexerid = indexer.get('id')
self._domain = indexer.get('domain')
self._searchurl = self._searchurl % self._domain
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self._timeout = indexer.get('timeout') or 15
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
"""
搜索
"""
if not mtype:
categoryId = self._movie_category
elif mtype == MediaType.TV:
categoryId = self._tv_category
else:
categoryId = self._movie_category
params = {
"categoryId": categoryId,
"pageParam": {
"current": page + 1,
"pageSize": self._size,
"total": self._size
},
"sorter": {}
}
if keyword:
params.update({
"keyword": keyword,
})
res = RequestUtils(
headers={
"Content-Type": "application/json",
"User-Agent": f"{self._ua}",
"Accept": "application/json, text/plain, */*"
},
cookies=self._cookie,
proxies=self._proxy,
referer=f"{self._domain}",
timeout=self._timeout
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:
results = res.json().get('data', []) or []
for result in results:
category_value = result.get('categoryId')
if category_value == self._tv_category:
category = MediaType.TV.value
elif category_value == self._movie_category:
category = MediaType.MOVIE.value
else:
category = MediaType.UNKNOWN.value
torrent = {
'title': result.get('showName'),
'description': result.get('shortDesc'),
'enclosure': self.__get_download_url(result.get('id')),
'pubdate': StringUtils.unify_datetime_str(result.get('gmtCreate')),
'size': result.get('fileSize'),
'seeders': result.get('seedNum'),
'peers': result.get('leechNum'),
'grabs': result.get('completedNum'),
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('downloadPromotion')),
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
'page_url': self._pageurl % (self._domain, result.get('id')),
'labels': [],
'category': category
}
torrents.append(torrent)
elif res is not None:
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
return True, []
else:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
return False, torrents
@staticmethod
def __get_downloadvolumefactor(discount: str) -> float:
"""
获取下载系数
"""
discount_dict = {
"free": 0,
"half": 0.5,
"none": 1
}
if discount:
return discount_dict.get(discount, 1)
return 1
@staticmethod
def __get_uploadvolumefactor(discount: str) -> float:
"""
获取上传系数
"""
discount_dict = {
"none": 1,
"one_half": 1.5,
"double_upload": 2
}
if discount:
return discount_dict.get(discount, 1)
return 1
def __get_download_url(self, torrent_id: str) -> str:
"""
获取下载链接
"""
return self._downloadurl % (self._domain, torrent_id)

View File

@@ -14,6 +14,10 @@ class JellyfinModule(_ModuleBase):
def init_module(self) -> None:
self.jellyfin = Jellyfin()
@staticmethod
def get_name() -> str:
return "Jellyfin"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "jellyfin"

View File

@@ -52,12 +52,12 @@ class Jellyfin:
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 []
res = RequestUtils().get_res(req_url)
if res:
return res.json()
else:
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
return []
@@ -70,29 +70,29 @@ class Jellyfin:
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'))
res = RequestUtils().get_res(req_url)
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 []
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 []
@@ -109,12 +109,12 @@ class Jellyfin:
user = self.user
req_url = f"{self._host}Users/{user}/Views?api_key={self._apikey}"
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Items")
else:
logger.error(f"Users/Views 未获取到返回数据")
return []
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Items")
else:
logger.error(f"Users/Views 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Users/Views 出错:" + str(e))
return []
@@ -138,10 +138,10 @@ class Jellyfin:
case _:
continue
image = self.__get_local_image_by_id(library.get("Id"))
link = f"{self._playhost or self._host}web/index.html#" \
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#" \
else f"{self._playhost or self._host}web/index.html#!" \
f"/tv.html?topParentId={library.get('Id')}"
libraries.append(
schemas.MediaServerLibrary(
@@ -163,12 +163,12 @@ class Jellyfin:
return 0
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return len(res.json())
else:
logger.error(f"Users 未获取到返回数据")
return 0
res = RequestUtils().get_res(req_url)
if res:
return len(res.json())
else:
logger.error(f"Users 未获取到返回数据")
return 0
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return 0
@@ -181,20 +181,20 @@ class Jellyfin:
return None
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Name") == user_name:
return user.get("Id")
# 查询管理员
res = RequestUtils().get_res(req_url)
if res:
users = res.json()
# 先查询是否有与当前用户名称匹配的
if user_name:
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
if user.get("Name") == user_name:
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
# 查询管理员
for user in users:
if user.get("Policy", {}).get("IsAdministrator"):
return user.get("Id")
else:
logger.error(f"Users 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return None
@@ -244,11 +244,11 @@ class Jellyfin:
return None
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
return res.json().get("Id")
else:
logger.error(f"System/Info 未获取到返回数据")
except Exception as e:
logger.error(f"连接System/Info出错" + str(e))
return None
@@ -262,17 +262,17 @@ class Jellyfin:
return schemas.Statistic()
req_url = "%sItems/Counts?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
res = RequestUtils().get_res(req_url)
if res:
result = res.json()
return schemas.Statistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0
)
else:
logger.error(f"Items/Counts 未获取到返回数据")
return schemas.Statistic()
except Exception as e:
logger.error(f"连接Items/Counts出错" + str(e))
return schemas.Statistic()
@@ -287,14 +287,14 @@ class Jellyfin:
"api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true") % (
self._host, self.user, self._apikey, name)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get('Name') == name and (
not year or str(res_item.get('ProductionYear')) == str(year)):
return res_item.get('Id')
except Exception as e:
logger.error(f"连接Items出错" + str(e))
return None
@@ -317,36 +317,36 @@ class Jellyfin:
"api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true") % (
self._host, self.user, self._apikey, title)
try:
with RequestUtils().get_res(req_url) as res:
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for item in res_items:
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
continue
if mediaserver_item.title == title and (
not year or str(mediaserver_item.year) == str(year)):
res = RequestUtils().get_res(req_url)
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for item in res_items:
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
mediaserver_item = schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(item_tmdbid) if item_tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
if tmdb_id and item_tmdbid:
if str(item_tmdbid) != str(tmdb_id):
continue
else:
ret_movies.append(mediaserver_item)
return ret_movies
continue
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:
logger.error(f"连接Items出错" + str(e))
return None
@@ -387,25 +387,25 @@ class Jellyfin:
try:
req_url = "%sShows/%s/Episodes?season=%s&&userId=%s&isMissing=false&api_key=%s" % (
self._host, item_id, season, self.user, self._apikey)
with RequestUtils().get_res(req_url) as res_json:
if res_json:
tv_info = res_json.json()
res_items = tv_info.get("Items")
# 返回的季集信息
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if not season_episodes.get(season_index):
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
return item_id, season_episodes
res_json = RequestUtils().get_res(req_url)
if res_json:
tv_info = res_json.json()
res_items = tv_info.get("Items")
# 返回的季集信息
season_episodes = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if not season_index:
continue
if season and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if not episode_index:
continue
if not season_episodes.get(season_index):
season_episodes[season_index] = []
season_episodes[season_index].append(episode_index)
return item_id, season_episodes
except Exception as e:
logger.error(f"连接Shows/Id/Episodes出错" + str(e))
return None, None
@@ -458,13 +458,13 @@ class Jellyfin:
_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(f"影片图片链接:{res.url}")
return res.url
else:
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
res = RequestUtils().get_res(req_url)
if res and res.status_code != 404:
logger.info(f"影片图片链接:{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
@@ -479,12 +479,12 @@ class Jellyfin:
"""
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
res = RequestUtils().get_res(req_url)
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
@@ -497,11 +497,11 @@ class Jellyfin:
return False
req_url = "%sLibrary/Refresh?api_key=%s" % (self._host, self._apikey)
try:
with RequestUtils().post_res(req_url) as res:
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Jellyfin")
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Jellyfin")
except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e))
return False
@@ -589,6 +589,8 @@ class Jellyfin:
eventItem.item_id = message.get('ItemId')
eventItem.tmdb_id = message.get('Provider_tmdb')
eventItem.overview = message.get('Overview')
eventItem.item_favorite = message.get('Favorite')
eventItem.save_reason = message.get('SaveReason')
eventItem.device_name = message.get('DeviceName')
eventItem.user_name = message.get('NotificationUsername')
eventItem.client = message.get('ClientName')
@@ -611,6 +613,11 @@ class Jellyfin:
eventItem.item_name = "%s %s" % (
message.get('Name'), "(" + str(message.get('Year')) + ")")
playback_position_ticks = message.get('PlaybackPositionTicks')
runtime_ticks = message.get('RunTimeTicks')
if playback_position_ticks is not None and runtime_ticks is not None:
eventItem.percentage = playback_position_ticks / runtime_ticks * 100
# 获取消息图片
if eventItem.item_id:
# 根据返回的item_id去调用媒体服务器获取
@@ -632,23 +639,23 @@ class Jellyfin:
req_url = "%sUsers/%s/Items/%s?api_key=%s" % (
self._host, self.user, itemid, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
item = res.json()
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
return schemas.MediaServerItem(
server="jellyfin",
library=item.get("ParentId"),
item_id=item.get("Id"),
item_type=item.get("Type"),
title=item.get("Name"),
original_title=item.get("OriginalTitle"),
year=item.get("ProductionYear"),
tmdbid=int(tmdbid) if tmdbid else None,
imdbid=item.get("ProviderIds", {}).get("Imdb"),
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
path=item.get("Path")
)
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
return None
@@ -663,17 +670,17 @@ class Jellyfin:
yield None
req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
try:
with RequestUtils().get_res(req_url) as res:
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(result.get("Id")):
yield item
res = RequestUtils().get_res(req_url)
if res and res.status_code == 200:
results = res.json().get("Items") or []
for result in results:
if not result:
continue
if result.get("Type") in ["Movie", "Series"]:
yield self.get_iteminfo(result.get("Id"))
elif "Folder" in result.get("Type"):
for item in self.get_items(result.get("Id")):
yield item
except Exception as e:
logger.error(f"连接Users/Items出错" + str(e))
yield None
@@ -761,50 +768,50 @@ class Jellyfin:
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 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
result = res.json().get("Items") or []
ret_resume = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
if item.get("BackdropImageTags"):
image = self.__get_backdrop_url(item_id=item.get("Id"),
image_tag=item.get("BackdropImageTags")[0])
else:
image = self.__get_local_image_by_id(item.get("Id"))
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
image_res = RequestUtils().get_res(image)
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 []
@@ -822,35 +829,35 @@ class Jellyfin:
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 未获取到返回数据")
res = RequestUtils().get_res(req_url)
if res:
result = res.json() or []
ret_latest = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if item_path and library_folders and not any(
str(item_path).startswith(folder) for folder in library_folders):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
link = self.get_play_url(item.get("Id"))
image = self.__get_local_image_by_id(item_id=item.get("Id"))
ret_latest.append(schemas.MediaServerPlayItem(
id=item.get("Id"),
title=item.get("Name"),
subtitle=item.get("ProductionYear"),
type=item_type,
image=image,
link=link
))
return ret_latest
else:
logger.error(f"Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error(f"连接Users/Items/Latest出错" + str(e))
return []

View File

@@ -14,6 +14,10 @@ class PlexModule(_ModuleBase):
def init_module(self) -> None:
self.plex = Plex()
@staticmethod
def get_name() -> str:
return "Plex"
def stop(self):
pass

View File

@@ -142,7 +142,11 @@ class Plex:
return schemas.Statistic()
sections = self._plex.library.sections()
MovieCount = SeriesCount = EpisodeCount = 0
# 媒体库白名单
allow_library = [lib.id for lib in self.get_librarys()]
for sec in sections:
if str(sec.key) not in allow_library:
continue
if sec.type == "movie":
MovieCount += sec.totalSize
if sec.type == "show":
@@ -232,16 +236,19 @@ class Plex:
if item_id:
videos = self._plex.fetchItem(item_id)
else:
# 兼容年份为空的场景
kwargs = {"year": year} if year else {}
# 根据标题和年份模糊搜索,该结果不够准确
videos = self._plex.library.search(title=title,
year=year,
libtype="show")
libtype="show",
**kwargs)
if (not videos
and original_title
and str(original_title) != str(title)):
videos = self._plex.library.search(title=original_title,
year=year,
libtype="show")
libtype="show",
**kwargs)
if not videos:
return None, {}
if isinstance(videos, list):
@@ -371,26 +378,34 @@ class Plex:
@staticmethod
def __get_ids(guids: List[Any]) -> dict:
def parse_tmdb_id(value: str) -> (bool, int):
"""尝试将TMDB ID字符串转换为整数。如果成功返回(True, int),失败则返回(False, None)。"""
try:
int_value = int(value)
return True, int_value
except ValueError:
return False, None
guid_mapping = {
"imdb://": "imdb_id",
"tmdb://": "tmdb_id",
"tvdb://": "tvdb_id"
}
ids = {}
for prefix, varname in guid_mapping.items():
ids[varname] = None
ids = {varname: None for varname in guid_mapping.values()}
for guid in guids:
guid_id = guid['id'] if isinstance(guid, dict) else guid.id
for prefix, varname in guid_mapping.items():
if isinstance(guid, dict):
if guid['id'].startswith(prefix):
# 找到匹配的ID
ids[varname] = guid['id'][len(prefix):]
break
else:
if guid.id.startswith(prefix):
# 找到匹配的ID
ids[varname] = guid.id[len(prefix):]
break
if guid_id.startswith(prefix):
clean_id = guid_id[len(prefix):]
if varname == "tmdb_id":
# tmdb_id为intPlex可能存在脏数据特别处理tmdb_id
success, parsed_id = parse_tmdb_id(clean_id)
if success:
ids[varname] = parsed_id
else:
ids[varname] = clean_id
break
return ids
def get_items(self, parent: str) -> Generator:
@@ -405,25 +420,29 @@ class Plex:
section = self._plex.library.sectionByID(int(parent))
if section:
for item in section.all():
if not item:
try:
if not item:
continue
ids = self.__get_ids(item.guids)
path = None
if item.locations:
path = item.locations[0]
yield schemas.MediaServerItem(
server="plex",
library=item.librarySectionID,
item_id=item.key,
item_type=item.type,
title=item.title,
original_title=item.originalTitle,
year=item.year,
tmdbid=ids['tmdb_id'],
imdbid=ids['imdb_id'],
tvdbid=ids['tvdb_id'],
path=path,
)
except Exception as e:
logger.error(f"处理媒体项目时出错:{str(e)}, 跳过此项目。")
continue
ids = self.__get_ids(item.guids)
path = None
if item.locations:
path = item.locations[0]
yield schemas.MediaServerItem(
server="plex",
library=item.librarySectionID,
item_id=item.key,
item_type=item.type,
title=item.title,
original_title=item.originalTitle,
year=item.year,
tmdbid=ids['tmdb_id'],
imdbid=ids['imdb_id'],
tvdbid=ids['tvdb_id'],
path=path,
)
except Exception as err:
logger.error(f"获取媒体库列表出错:{str(err)}")
yield None
@@ -610,7 +629,10 @@ class Plex:
"""
if not self._plex:
return []
items = self._plex.fetchItems('/hubs/continueWatching/items', container_start=0, container_size=num)
# 媒体库白名单
allow_library = ",".join([lib.id for lib in self.get_librarys()])
params = {'contentDirectoryID': allow_library}
items = self._plex.fetchItems("/hubs/continueWatching/items", container_start=0, container_size=num, params=params)
ret_resume = []
for item in items:
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
@@ -639,20 +661,65 @@ class Plex:
"""
if not self._plex:
return None
items = self._plex.fetchItems('/library/recentlyAdded', container_start=0, container_size=num)
# 请求参数(除黑名单)
allow_library = ",".join([lib.id for lib in self.get_librarys()])
params = {
"contentDirectoryID": allow_library,
"count": num,
"excludeContinueWatching": 1
}
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
))
sub_result = []
offset = 0
while True:
if len(ret_resume) >= num:
break
# 获取所有资料库
hubs = self._plex.fetchItems(
'/hubs/promoted',
container_start=offset,
container_size=num,
maxresults=num,
params=params
)
if len(hubs) == 0:
break
# 合并排序
for hub in hubs:
for item in hub.items:
sub_result.append(item)
sub_result.sort(key=lambda x: x.addedAt, reverse=True)
for item in sub_result:
if len(ret_resume) >= num:
break
item_type, title, image = "", "", ""
if item.TYPE == "movie":
item_type = MediaType.MOVIE.value
title = item.title
image = item.posterUrl
elif item.TYPE == "season":
item_type = MediaType.TV.value
title = "%s%s" % (item.parentTitle, item.index)
image = item.posterUrl
elif item.TYPE == "episode":
item_type = MediaType.TV.value
title = "%s%s季 第%s" % (item.grandparentTitle, item.parentIndex, item.index)
thumb = (item.parentThumb or item.grandparentThumb or '').lstrip('/')
image = (self._host + thumb + f"?X-Plex-Token={self._token}")
elif item.TYPE == "show":
item_type = MediaType.TV.value
title = "%s%s" % (item.title, item.seasonCount)
image = item.posterUrl
link = self.get_play_url(item.key)
ret_resume.append(schemas.MediaServerPlayItem(
id=item.key,
title=title,
subtitle=item.year,
type=item_type,
image=image,
link=link
))
offset += num
return ret_resume[:num]

View File

@@ -23,6 +23,10 @@ class QbittorrentModule(_ModuleBase):
def init_module(self) -> None:
self.qbittorrent = Qbittorrent()
@staticmethod
def get_name() -> str:
return "Qbittorrent"
def stop(self):
pass
@@ -188,7 +192,7 @@ class QbittorrentModule(_ModuleBase):
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = settings.SAVE_PATH / torrent.get('name')
torrent_path = torrent.get('save_path') / torrent.get('name')
ret_torrents.append(TransferTorrent(
title=torrent.get('name'),
path=torrent_path,
@@ -207,7 +211,7 @@ class QbittorrentModule(_ModuleBase):
if content_path:
torrent_path = Path(content_path)
else:
torrent_path = settings.SAVE_PATH / torrent.get('name')
torrent_path = torrent.get('save_path') / torrent.get('name')
ret_torrents.append(TransferTorrent(
title=torrent.get('name'),
path=torrent_path,

View File

@@ -16,6 +16,10 @@ class SlackModule(_ModuleBase):
def init_module(self) -> None:
self.slack = Slack()
@staticmethod
def get_name() -> str:
return "Slack"
def stop(self):
self.slack.stop()

View File

@@ -28,6 +28,10 @@ class SubtitleModule(_ModuleBase):
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "站点字幕"
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass

View File

@@ -13,6 +13,10 @@ class SynologyChatModule(_ModuleBase):
def init_module(self) -> None:
self.synologychat = SynologyChat()
@staticmethod
def get_name() -> str:
return "Synology Chat"
def stop(self):
pass

View File

@@ -15,6 +15,10 @@ class TelegramModule(_ModuleBase):
def init_module(self) -> None:
self.telegram = Telegram()
@staticmethod
def get_name() -> str:
return "Telegram"
def stop(self):
self.telegram.stop()

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Optional, List, Tuple, Union
from typing import Optional, List, Tuple, Union, Dict
import cn2an
@@ -13,6 +13,7 @@ from app.modules.themoviedb.category import CategoryHelper
from app.modules.themoviedb.scraper import TmdbScraper
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.modules.themoviedb.tmdbapi import TmdbApi
from app.schemas import MediaPerson
from app.schemas.types import MediaType, MediaImageType
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils
@@ -38,6 +39,10 @@ class TheMovieDbModule(_ModuleBase):
self.category = CategoryHelper()
self.scraper = TmdbScraper(self.tmdb)
@staticmethod
def get_name() -> str:
return "TheMovieDb"
def stop(self):
self.cache.save()
self.tmdb.close()
@@ -220,16 +225,25 @@ class TheMovieDbModule(_ModuleBase):
"""
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
def media_category(self) -> Optional[Dict[str, list]]:
"""
获取媒体分类
:return: 获取二级分类配置字典项,需包括电影、电视剧、动漫
"""
return {
"电影": list(self.category.movie_categorys),
"电视剧": list(self.category.tv_categorys),
"动漫": list(self.category.anime_categorys)
}
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息列表
"""
# 未启用时返回None
if settings.RECOGNIZE_SOURCE != "themoviedb":
if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
return None
if not meta.name:
return []
if meta.type == MediaType.UNKNOWN and not meta.year:
@@ -261,6 +275,17 @@ class TheMovieDbModule(_ModuleBase):
return medias
return []
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
"""
搜索人物信息
"""
if not name:
return []
results = self.tmdb.search_persons(name)
if results:
return [MediaPerson(source='themoviedb', **person) for person in results]
return []
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
"""
@@ -309,7 +334,7 @@ class TheMovieDbModule(_ModuleBase):
logger.info(f"{path} 刮削完成")
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str,
page: int = 1) -> Optional[List[dict]]:
page: int = 1) -> Optional[List[MediaInfo]]:
"""
:param mtype: 媒体类型
:param sort_by: 排序方式
@@ -319,25 +344,31 @@ class TheMovieDbModule(_ModuleBase):
:return: 媒体信息列表
"""
if mtype == MediaType.MOVIE:
return self.tmdb.discover_movies(sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
infos = self.tmdb.discover_movies(sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
elif mtype == MediaType.TV:
return self.tmdb.discover_tvs(sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
infos = self.tmdb.discover_tvs(sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
else:
return None
return []
if infos:
return [MediaInfo(tmdb_info=info) for info in infos]
return []
def tmdb_trending(self, page: int = 1) -> List[dict]:
def tmdb_trending(self, page: int = 1) -> List[MediaInfo]:
"""
TMDB流行趋势
:param page: 第几页
:return: TMDB信息列表
"""
return self.tmdb.trending.all_week(page=page)
trending = self.tmdb.trending.all_week(page=page)
if trending:
return [MediaInfo(tmdb_info=info) for info in trending]
return []
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
"""
@@ -445,64 +476,88 @@ class TheMovieDbModule(_ModuleBase):
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/{image_prefix}{image_path}"
return None
def tmdb_movie_similar(self, tmdbid: int) -> List[dict]:
def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询类似电影
:param tmdbid: TMDBID
"""
return self.tmdb.get_movie_similar(tmdbid=tmdbid)
similar = self.tmdb.get_movie_similar(tmdbid=tmdbid)
if similar:
return [MediaInfo(tmdb_info=info) for info in similar]
return []
def tmdb_tv_similar(self, tmdbid: int) -> List[dict]:
def tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询类似电视剧
:param tmdbid: TMDBID
"""
return self.tmdb.get_tv_similar(tmdbid=tmdbid)
similar = self.tmdb.get_tv_similar(tmdbid=tmdbid)
if similar:
return [MediaInfo(tmdb_info=info) for info in similar]
return []
def tmdb_movie_recommend(self, tmdbid: int) -> List[dict]:
def tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询推荐电影
:param tmdbid: TMDBID
"""
return self.tmdb.get_movie_recommend(tmdbid=tmdbid)
recommend = self.tmdb.get_movie_recommend(tmdbid=tmdbid)
if recommend:
return [MediaInfo(tmdb_info=info) for info in recommend]
return []
def tmdb_tv_recommend(self, tmdbid: int) -> List[dict]:
def tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]:
"""
根据TMDBID查询推荐电视剧
:param tmdbid: TMDBID
"""
return self.tmdb.get_tv_recommend(tmdbid=tmdbid)
recommend = self.tmdb.get_tv_recommend(tmdbid=tmdbid)
if recommend:
return [MediaInfo(tmdb_info=info) for info in recommend]
return []
def tmdb_movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
def tmdb_movie_credits(self, tmdbid: int, page: int = 1) -> List[schemas.MediaPerson]:
"""
根据TMDBID查询电影演职员表
:param tmdbid: TMDBID
:param page: 页码
"""
return self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)
credit_infos = self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)
if credit_infos:
return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos]
return []
def tmdb_tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
def tmdb_tv_credits(self, tmdbid: int, page: int = 1) -> List[schemas.MediaPerson]:
"""
根据TMDBID查询电视剧演职员表
:param tmdbid: TMDBID
:param page: 页码
"""
return self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)
credit_infos = self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)
if credit_infos:
return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos]
return []
def tmdb_person_detail(self, person_id: int) -> dict:
def tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson:
"""
根据TMDBID查询人物详情
:param person_id: 人物ID
"""
return self.tmdb.get_person_detail(person_id=person_id)
detail = self.tmdb.get_person_detail(person_id=person_id)
if detail:
return schemas.MediaPerson(source="themoviedb", **detail)
return schemas.MediaPerson()
def tmdb_person_credits(self, person_id: int, page: int = 1) -> List[dict]:
def tmdb_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
"""
根据TMDBID查询人物参演作品
:param person_id: 人物ID
:param page: 页码
"""
return self.tmdb.get_person_credits(person_id=person_id, page=page)
infos = self.tmdb.get_person_credits(person_id=person_id, page=page)
if infos:
return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos]
return []
def clear_cache(self):
"""

View File

@@ -95,6 +95,14 @@ class TmdbApi:
ret_infos.append(tv)
return ret_infos
def search_persons(self, name: str) -> List[dict]:
"""
查询模糊匹配的所有人物TMDB信息
"""
if not name:
return []
return self.search.people(term=name) or []
@staticmethod
def __compare_names(file_name: str, tmdb_names: list) -> bool:
"""
@@ -168,7 +176,7 @@ class TmdbApi:
return None
# TMDB搜索
info = {}
if mtype == MediaType.MOVIE:
if mtype != MediaType.TV:
year_range = [year]
if year:
year_range.append(str(int(year) + 1))
@@ -751,10 +759,10 @@ class TmdbApi:
if not self.movie:
return {}
try:
logger.info("正在查询TMDB电影%s ..." % tmdbid)
logger.debug("正在查询TMDB电影%s ..." % tmdbid)
tmdbinfo = self.movie.details(tmdbid, append_to_response)
if tmdbinfo:
logger.info(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
return tmdbinfo or {}
except Exception as e:
print(str(e))
@@ -934,10 +942,10 @@ class TmdbApi:
if not self.tv:
return {}
try:
logger.info("正在查询TMDB电视剧%s ..." % tmdbid)
logger.debug("正在查询TMDB电视剧%s ..." % tmdbid)
tmdbinfo = self.tv.details(tv_id=tmdbid, append_to_response=append_to_response)
if tmdbinfo:
logger.info(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
return tmdbinfo or {}
except Exception as e:
print(str(e))
@@ -1010,7 +1018,7 @@ class TmdbApi:
if not self.season:
return {}
try:
logger.info("正在查询TMDB电视剧%s,季:%s ..." % (tmdbid, season))
logger.debug("正在查询TMDB电视剧%s,季:%s ..." % (tmdbid, season))
tmdbinfo = self.season.details(tv_id=tmdbid, season_num=season)
return tmdbinfo or {}
except Exception as e:
@@ -1027,7 +1035,7 @@ class TmdbApi:
if not self.episode:
return {}
try:
logger.info("正在查询TMDB集详情%s,季:%s,集:%s ..." % (tmdbid, season, episode))
logger.debug("正在查询TMDB集详情%s,季:%s,集:%s ..." % (tmdbid, season, episode))
tmdbinfo = self.episode.details(tv_id=tmdbid, season_num=season, episode_num=episode)
return tmdbinfo or {}
except Exception as e:
@@ -1043,7 +1051,7 @@ class TmdbApi:
if not self.discover:
return []
try:
logger.info(f"正在发现电影:{kwargs}...")
logger.debug(f"正在发现电影:{kwargs}...")
tmdbinfo = self.discover.discover_movies(kwargs)
if tmdbinfo:
for info in tmdbinfo:
@@ -1062,7 +1070,7 @@ class TmdbApi:
if not self.discover:
return []
try:
logger.info(f"正在发现电视剧:{kwargs}...")
logger.debug(f"正在发现电视剧:{kwargs}...")
tmdbinfo = self.discover.discover_tv_shows(kwargs)
if tmdbinfo:
for info in tmdbinfo:
@@ -1079,7 +1087,7 @@ class TmdbApi:
if not self.movie:
return {}
try:
logger.info(f"正在获取电影图片:{tmdbid}...")
logger.debug(f"正在获取电影图片:{tmdbid}...")
return self.movie.images(movie_id=tmdbid) or {}
except Exception as e:
print(str(e))
@@ -1092,7 +1100,7 @@ class TmdbApi:
if not self.tv:
return {}
try:
logger.info(f"正在获取电视剧图片:{tmdbid}...")
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
return self.tv.images(tv_id=tmdbid) or {}
except Exception as e:
print(str(e))
@@ -1105,7 +1113,7 @@ class TmdbApi:
if not self.movie:
return []
try:
logger.info(f"正在获取相似电影:{tmdbid}...")
logger.debug(f"正在获取相似电影:{tmdbid}...")
return self.movie.similar(movie_id=tmdbid) or []
except Exception as e:
print(str(e))
@@ -1118,7 +1126,7 @@ class TmdbApi:
if not self.tv:
return []
try:
logger.info(f"正在获取相似电视剧:{tmdbid}...")
logger.debug(f"正在获取相似电视剧:{tmdbid}...")
return self.tv.similar(tv_id=tmdbid) or []
except Exception as e:
print(str(e))
@@ -1131,7 +1139,7 @@ class TmdbApi:
if not self.movie:
return []
try:
logger.info(f"正在获取推荐电影:{tmdbid}...")
logger.debug(f"正在获取推荐电影:{tmdbid}...")
return self.movie.recommendations(movie_id=tmdbid) or []
except Exception as e:
print(str(e))
@@ -1144,7 +1152,7 @@ class TmdbApi:
if not self.tv:
return []
try:
logger.info(f"正在获取推荐电视剧:{tmdbid}...")
logger.debug(f"正在获取推荐电视剧:{tmdbid}...")
return self.tv.recommendations(tv_id=tmdbid) or []
except Exception as e:
print(str(e))
@@ -1157,7 +1165,7 @@ class TmdbApi:
if not self.movie:
return []
try:
logger.info(f"正在获取电影演职人员:{tmdbid}...")
logger.debug(f"正在获取电影演职人员:{tmdbid}...")
info = self.movie.credits(movie_id=tmdbid) or {}
cast = info.get('cast') or []
if cast:
@@ -1174,7 +1182,7 @@ class TmdbApi:
if not self.tv:
return []
try:
logger.info(f"正在获取电视剧演职人员:{tmdbid}...")
logger.debug(f"正在获取电视剧演职人员:{tmdbid}...")
info = self.tv.credits(tv_id=tmdbid) or {}
cast = info.get('cast') or []
if cast:
@@ -1211,7 +1219,7 @@ class TmdbApi:
if not self.person:
return {}
try:
logger.info(f"正在获取人物详情:{person_id}...")
logger.debug(f"正在获取人物详情:{person_id}...")
return self.person.details(person_id=person_id) or {}
except Exception as e:
print(str(e))
@@ -1224,10 +1232,14 @@ class TmdbApi:
if not self.person:
return []
try:
logger.info(f"正在获取人物参演作品:{person_id}...")
info = self.person.movie_credits(person_id=person_id) or {}
cast = info.get('cast') or []
logger.debug(f"正在获取人物参演作品:{person_id}...")
movies = self.person.movie_credits(person_id=person_id) or {}
tvs = self.person.tv_credits(person_id=person_id) or {}
cast = (movies.get('cast') or []) + (tvs.get('cast') or [])
if cast:
# 按年份降序排列
cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01',
reverse=True)
return cast[(page - 1) * count: page * count]
return []
except Exception as e:
@@ -1250,7 +1262,7 @@ class TmdbApi:
return {}
episode_years = {}
for episode_group in episode_groups:
logger.info(f"正在获取剧集组年份:{episode_group.get('id')}...")
logger.debug(f"正在获取剧集组年份:{episode_group.get('id')}...")
if episode_group.get('type') != 6:
# 只处理剧集部分
continue

View File

@@ -1,4 +1,5 @@
from ..tmdb import TMDb
from cachetools import cached, TTLCache
try:
from urllib import urlencode
@@ -12,14 +13,16 @@ class Discover(TMDb):
"tv": "/discover/tv"
}
@cached(cache=TTLCache(maxsize=1, ttl=43200))
def discover_movies(self, params):
"""
Discover movies by different types of data like average rating, number of votes, genres and certifications.
:param params: dict
:return:
"""
return self._request_obj(self._urls["movies"], urlencode(params), key="results")
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
@cached(cache=TTLCache(maxsize=1, ttl=43200))
def discover_tv_shows(self, params):
"""
Discover TV shows by different types of data like average rating, number of votes, genres,
@@ -27,4 +30,4 @@ class Discover(TMDb):
:param params: dict
:return:
"""
return self._request_obj(self._urls["tv"], urlencode(params), key="results")
return self._request_obj(self._urls["tv"], urlencode(params), key="results", call_cached=False)

View File

@@ -14,7 +14,6 @@ class Person(TMDb):
"translations": "/person/%s/translations",
"latest": "/person/latest",
"popular": "/person/popular",
"search_people": "/search/person",
}
def details(self, person_id, append_to_response="videos,images"):

View File

@@ -1,14 +1,21 @@
from cachetools import cached, TTLCache
from ..tmdb import TMDb
class Trending(TMDb):
_urls = {"trending": "/trending/%s/%s"}
@cached(cache=TTLCache(maxsize=1, ttl=43200))
def _trending(self, media_type="all", time_window="day", page=1):
"""
Get trending, TTLCache 12 hours
"""
return self._request_obj(
self._urls["trending"] % (media_type, time_window),
params="page=%s" % page,
key="results",
call_cached=False
)
def all_day(self, page=1):

View File

@@ -141,7 +141,7 @@ class TMDb(object):
def cached_request(self, method, url, data, json,
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
缓存请求时间默认1天None不缓存
缓存请求
"""
return self.request(method, url, data, json)

View File

@@ -16,6 +16,10 @@ class TheTvDbModule(_ModuleBase):
select_first=True,
proxies=settings.PROXY)
@staticmethod
def get_name() -> str:
return "TheTvDb"
def stop(self):
self.tvdb.close()

View File

@@ -23,6 +23,10 @@ class TransmissionModule(_ModuleBase):
def init_module(self) -> None:
self.transmission = Transmission()
@staticmethod
def get_name() -> str:
return "Transmission"
def stop(self):
pass

View File

@@ -15,6 +15,10 @@ class VoceChatModule(_ModuleBase):
def init_module(self) -> None:
self.vocechat = VoceChat()
@staticmethod
def get_name() -> str:
return "VoceChat"
def stop(self):
pass

View File

@@ -17,6 +17,10 @@ class WechatModule(_ModuleBase):
def init_module(self) -> None:
self.wechat = WeChat()
@staticmethod
def get_name() -> str:
return "微信"
def stop(self):
pass

View File

@@ -71,18 +71,18 @@ class WeChat:
return None
try:
token_url = self._token_url % (self._corpid, self._appsecret)
with RequestUtils().get_res(token_url) as res:
if res:
ret_json = res.json()
if ret_json.get('errcode') == 0:
self._access_token = ret_json.get('access_token')
self._expires_in = ret_json.get('expires_in')
self._access_token_time = datetime.now()
elif res is not None:
logger.error(f"获取微信access_token失败错误码{res.status_code},错误原因:{res.reason}")
else:
logger.error(f"获取微信access_token失败未获取到返回信息")
raise Exception("获取微信access_token失败网络连接失败")
res = RequestUtils().get_res(token_url)
if res:
ret_json = res.json()
if ret_json.get('errcode') == 0:
self._access_token = ret_json.get('access_token')
self._expires_in = ret_json.get('expires_in')
self._access_token_time = datetime.now()
elif res is not None:
logger.error(f"获取微信access_token失败错误码{res.status_code},错误原因:{res.reason}")
else:
logger.error(f"获取微信access_token失败未获取到返回信息")
raise Exception("获取微信access_token失败网络连接失败")
except Exception as e:
logger.error(f"获取微信access_token失败错误信息{str(e)}")
return None

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