Compare commits

...

100 Commits

Author SHA1 Message Date
jxxghp
d77ea8f0a0 - 修复榜单、订阅、目录匹配细节问题 2024-06-10 09:55:20 +08:00
jxxghp
bbba9813a2 Merge pull request #2307 from InfinityPacer/main 2024-06-10 09:50:26 +08:00
jxxghp
220cbc3072 fix #2291 2024-06-10 09:49:31 +08:00
InfinityPacer
fcbdef5e66 fix 插件重载找不到__init__.py的场景及部分细节调整 2024-06-10 09:45:56 +08:00
jxxghp
e2e1c7642d fix 订阅重置 & TMDB电视剧榜单 2024-06-10 09:38:12 +08:00
jxxghp
33813ecf1d Merge pull request #2302 from xcehnz/main 2024-06-09 16:03:18 +08:00
xcehnz
ef656fcc67 fix 同目录优先无效 2024-06-09 15:41:48 +08:00
jxxghp
8fe7e015dd Merge pull request #2299 from thsrite/main 2024-06-09 12:54:27 +08:00
thsrite
7132fdbb26 fix plugin command args 2024-06-09 12:41:42 +08:00
jxxghp
0f57b39345 fix webpush switch 2024-06-09 11:15:35 +08:00
jxxghp
d13b5622c7 remove js/css cache 2024-06-09 08:04:53 +08:00
jxxghp
b5eaba26da 更新 __init__.py 2024-06-08 21:05:53 +08:00
jxxghp
60007cf398 Merge pull request #2295 from thsrite/main 2024-06-08 20:47:51 +08:00
thsrite
65cc169391 fix 2024-06-08 19:56:08 +08:00
thsrite
68a9fc4a13 fix 订阅新增支持填充规则 2024-06-08 19:54:06 +08:00
jxxghp
08870a67ec v1.9.4
- 优化了硬链接的处理逻辑,兼容极空间的同时跨盘不再会自动变成复制了
- 订阅增加了重置按钮,手动删除了订阅下载过的任务或媒体库文件时,可通过重置订阅重新下载
- 消息通知新增超链接跳转功能,需在设定中维护好访问域名
- 新增WebPush通知推送功能,实现类客户端的通知提醒效果(无需第三方软件)。使用方法:
   1. 设定 -> 通知 中打开WebPush开关
   2. 将MoviePilot浏览器(Safari、Chrome)网页发送到桌面图标,打开登录时根据提示允许消息通知权限

📢:功能无效或UI出错请清理浏览器缓存(无需清理Cookie)。
2024-06-08 14:45:49 +08:00
jxxghp
518206c34a fix wechat link 2024-06-08 11:21:03 +08:00
jxxghp
e05c643a6b add notification link 2024-06-08 10:47:50 +08:00
jxxghp
748de0ff00 fix webpush link 2024-06-08 07:45:13 +08:00
jxxghp
29b94e859f 更新 config.py 2024-06-08 07:09:21 +08:00
jxxghp
ed3bd0ddef Merge pull request #2289 from InfinityPacer/main 2024-06-08 06:21:57 +08:00
InfinityPacer
3cdbdc2f78 fix mailto移除空格和尖括号 2024-06-08 00:40:14 +08:00
jxxghp
f8fbf9b5eb fix webpush 2024-06-07 21:53:48 +08:00
jxxghp
9e0751367b Merge pull request #2288 from DDS-Derek/main
Revert "feat: refactor docker http proxy"
2024-06-07 21:48:48 +08:00
jxxghp
bc689074e0 fix webpush 2024-06-07 21:47:43 +08:00
DDSRem
7e442650b0 Revert "feat: refactor docker http proxy"
This reverts commit 48a860bfd4.
2024-06-07 21:43:36 +08:00
jxxghp
0a9a391eb3 add webpush log 2024-06-07 21:31:03 +08:00
jxxghp
ea1e600474 Merge pull request #2286 from honue/main 2024-06-07 16:25:33 +08:00
honue
b0a2c1b957 重置插件时删除插件数据 2024-06-07 15:33:17 +08:00
jxxghp
624363476a Merge pull request #2284 from DDS-Derek/main 2024-06-07 13:42:51 +08:00
DDSRem
48a860bfd4 feat: refactor docker http proxy 2024-06-07 11:08:41 +08:00
jxxghp
2d4fb5d52e fix webpush switch 2024-06-07 08:15:30 +08:00
jxxghp
c0c787f7ed fix 2024-06-07 08:01:23 +08:00
jxxghp
03d6834471 fix #2264 2024-06-06 14:21:20 +08:00
jxxghp
947d0d6d4b add 订阅重置api 2024-06-06 07:56:16 +08:00
jxxghp
7611c88aa6 Merge pull request #2272 from InfinityPacer/main 2024-06-05 22:28:13 +08:00
InfinityPacer
7be262b182 fix #2265 优化硬链接的判断逻辑 2024-06-05 22:24:17 +08:00
jxxghp
a7a06a9a75 fix NotificationSwitch 2024-06-05 22:00:39 +08:00
jxxghp
6aa5a836b9 fix webpush api 2024-06-05 18:40:00 +08:00
jxxghp
efd0fc39c6 fix github proxy && add webpush api 2024-06-05 18:08:34 +08:00
jxxghp
7e1951b8e4 Merge pull request #2265 from InfinityPacer/main 2024-06-04 22:00:00 +08:00
InfinityPacer
27c6392b66 fix #667 优化历史兼容极空间硬链接逻辑 2024-06-04 21:54:37 +08:00
jxxghp
0fc7d883c0 v1.9.3
- 搜索功能全面升级,支持搜索多种类型数据,支持模糊搜索站点资源(不匹配不过滤)
- 设置目录时支持浏览选择路径
- 支持配置Github代理地址,以加快版本及插件更新下载
- 优化了插件去重显示
- 优化了目录匹配同路径优先处理逻辑
- 设定等表单中的提示信息强制显示
- 修复了个别插件安装后会消失的问题

注意:前端变化升级后清理浏览器缓存文件(无需清理cookie)
2024-06-03 17:38:31 +08:00
jxxghp
95b480af6d fix #2235 2024-06-03 12:40:35 +08:00
jxxghp
abe7795105 Merge pull request #2259 from thsrite/main 2024-06-03 09:59:52 +08:00
thsrite
74c71390c9 fix unhashable type: 'dict' 2024-06-03 09:54:32 +08:00
jxxghp
1ddd844c17 fix 2024-06-03 08:20:16 +08:00
jxxghp
de3ff2db2e remove api async 2024-06-03 08:17:53 +08:00
jxxghp
655e73f829 fix dir match 2024-06-03 08:03:40 +08:00
jxxghp
2232e51509 add GITHUB_PROXY 2024-06-03 07:09:30 +08:00
jxxghp
44f1a321d2 fix #2249 2024-06-02 21:15:58 +08:00
jxxghp
c05223846f fix api 2024-06-02 21:09:15 +08:00
jxxghp
45945bd025 feat:增加删除下载任务事件,历史记录中删除源文件时主程序会同步删除种子,同时会发出该事件(以便处理辅种等) 2024-06-01 07:47:00 +08:00
jxxghp
acff7e0610 fix log 2024-05-31 20:03:48 +08:00
jxxghp
e97ae488fd fix 线上插件去重 2024-05-31 16:03:27 +08:00
jxxghp
a7689e1e10 fix 2024-05-31 15:08:22 +08:00
jxxghp
9a4d537543 Merge pull request #2237 from hotlcc/develop-20240531 2024-05-31 15:05:22 +08:00
Allen
1b09bb8d22 去掉主动创建下载目录的逻辑,解耦下载器,避免在容器环境当下载器目录与MP映射不一致时导致的目录权限异常 2024-05-31 15:00:47 +08:00
jxxghp
13832a51e0 add listdir api 2024-05-31 13:55:36 +08:00
jxxghp
a09b2fa88a fix log 2024-05-31 09:10:26 +08:00
jxxghp
6361f8654c fix README 2024-05-30 14:13:15 +08:00
jxxghp
db4bda3b73 Merge remote-tracking branch 'origin/main' 2024-05-30 12:39:02 +08:00
jxxghp
3f557ee43c fix README 2024-05-30 12:38:55 +08:00
jxxghp
9e7e0a8730 Merge pull request #2223 from thsrite/main 2024-05-30 10:42:16 +08:00
thsrite
07de1eaa0d fix 插件版本比较 2024-05-30 10:02:26 +08:00
jxxghp
c872043bf4 Merge pull request #2226 from InfinityPacer/main 2024-05-30 06:33:48 +08:00
InfinityPacer
7ed194a62c fix 优先加载子模块 2024-05-30 00:58:20 +08:00
jxxghp
882da68903 Merge pull request #2224 from InfinityPacer/main 2024-05-29 23:06:31 +08:00
InfinityPacer
2798700f71 fix 插件重载时,支持reload一级子模块 2024-05-29 23:01:58 +08:00
thsrite
34e70adabb fix 插件库相同ID的插件保留版本号最大版本 2024-05-29 20:40:04 +08:00
jxxghp
fe999aa346 fix dir match 2024-05-29 17:30:00 +08:00
jxxghp
f7ca4abb01 fix dir match 2024-05-29 17:28:49 +08:00
jxxghp
8a4202cee5 fix dir match 2024-05-29 17:16:35 +08:00
jxxghp
55a85b87dd fix README 2024-05-29 16:47:03 +08:00
jxxghp
3470f96e39 feat:系统错误时发出事件 2024-05-29 16:29:47 +08:00
jxxghp
74980911fe feat:系统错误时发出事件 2024-05-29 16:28:17 +08:00
jxxghp
4c5366f8b4 fix 订阅类型错误日志 2024-05-29 13:41:19 +08:00
jxxghp
8eb89eec86 fix message 2024-05-28 15:39:17 +08:00
jxxghp
cfd7208cda Merge remote-tracking branch 'origin/main' 2024-05-28 12:25:39 +08:00
jxxghp
0c6684a572 fix #2208 下载历史错误数据兼容 2024-05-28 12:25:33 +08:00
jxxghp
f0692b2fb8 更新 __init__.py 2024-05-28 11:50:11 +08:00
jxxghp
c29ee4fb07 Merge pull request #2203 from BrettDean/main 2024-05-28 11:49:07 +08:00
jxxghp
dd40ef54c0 fix #1838 2024-05-28 08:16:51 +08:00
Dean
84d5e2a6b3 fix: Plex刷新媒体库无用 2024-05-28 02:11:42 +08:00
jxxghp
7defcff0e5 v1.9.2
- 修复了目录匹配时同路径优先无效的问题
- 修复了文件管理默认路径还在使用旧变量的问题
- 修复了会删除空媒体库目录的问题
- 修复了TR下载器整理后会覆盖任务原有标签的问题
- 修复了Windows打包,并默认内置了几个主要的插件库
- 优化了资源搜索页面剧集的过滤使用体验
- 硬链接模式下,如果硬链接失败(实际为复制)会发送通知提醒,同时历史记录的整理方式会显示为`复制`
- 消息类型新增了插件消息,专用于需要发送消息类的插件选择使用
2024-05-27 12:46:58 +08:00
jxxghp
d9e767f87d fix 新增消息类型显示 2024-05-27 12:31:48 +08:00
jxxghp
2b82173fba fix windows build 2024-05-27 12:27:15 +08:00
jxxghp
1425b15333 fix #2192 2024-05-27 11:16:41 +08:00
jxxghp
8d82d0f4fd Merge remote-tracking branch 'origin/main' 2024-05-27 10:26:21 +08:00
jxxghp
d352f09d4e fix #2193 2024-05-27 10:26:09 +08:00
jxxghp
aebd121939 Merge pull request #2195 from hotlcc/develop-20240527
Develop 20240527
2024-05-27 10:13:39 +08:00
jxxghp
81eed0d06d fix windows build 2024-05-27 10:11:41 +08:00
Allen
bacb7aaeb4 模块管理种新增根据模块id精确获取模块的方法,以便在插件等场景精确获取qb/tr等由系统统一维护的模块,而不需要插件各自为政 2024-05-27 10:09:18 +08:00
Allen
b238c6ad11 消息类型增加插件消息 2024-05-27 10:06:59 +08:00
jxxghp
5c8b843030 fix 目录健康检查 2024-05-27 08:46:44 +08:00
jxxghp
58acc62e16 feat:硬链接转复制时发系统通知提醒 2024-05-27 08:11:44 +08:00
jxxghp
ca5a240fc4 fix 同路径优先 2024-05-27 07:52:36 +08:00
jxxghp
dd5887d18d fix 同路径优先 2024-05-26 13:28:32 +08:00
jxxghp
97669405d0 fix #2185 2024-05-26 13:15:34 +08:00
jxxghp
bf2ea271b6 fix 硬链接检测 2024-05-26 12:48:52 +08:00
68 changed files with 919 additions and 233 deletions

View File

@@ -80,11 +80,13 @@ jobs:
- name: Prepare Frontend
run: |
# 下载nginx
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
Remove-Item -Path "nginx.zip"
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
# 下载前端
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
@@ -96,11 +98,31 @@ jobs:
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
New-Item -Path "nginx/logs" -ItemType Directory -Force
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
# 下载插件 jxxghp
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 thsrite
Invoke-WebRequest -Uri "https://github.com/thsrite/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 honue
Invoke-WebRequest -Uri "https://github.com/honue/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 InfinityPacer
Invoke-WebRequest -Uri "https://github.com/InfinityPacer/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载资源
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
@@ -137,6 +159,7 @@ jobs:
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
find app/plugins -name requirements.txt -exec pip install -r {} \;
- name: Prepare Frontend
run: |

View File

@@ -6,6 +6,8 @@
发布频道https://t.me/moviepilot_channel
Wikihttps://wiki.movie-pilot.org
## 主要特性
- 前后端分离基于FastApi + Vue3前端项目地址[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)APIhttp://localhost:3001/docs
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
@@ -82,7 +84,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **❗AUTH_SITE** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
认证资源`v1.2.8+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`/`discfan`
认证资源`v1.2.8+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`/`discfan`
| 站点 | 参数 |
|:------------:|:-----------------------------------------------------:|
@@ -97,7 +99,6 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
| leaves | `LEAVES_UID`用户ID<br/>`LEAVES_PASSKEY`:密钥 |
| ptba | `PTBA_UID`用户ID<br/>`PTBA_PASSKEY`:密钥 |
| icc2022 | `ICC2022_UID`用户ID<br/>`ICC2022_PASSKEY`:密钥 |
| ptlsp | `PTLSP_UID`用户ID<br/>`PTLSP_PASSKEY`:密钥 |
| xingtan | `XINGTAN_UID`用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
| ptvicomo | `PTVICOMO_UID`用户ID<br/>`PTVICOMO_PASSKEY`:密钥 |
| agsvpt | `AGSVPT_UID`用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
@@ -112,10 +113,12 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- **❗SUPERUSER** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面,**注意:启动一次后再次修改该值不会生效,除非删除数据库文件!**
- **❗API_TOKEN** API密钥默认`moviepilot`在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
- **APP_DOMAIN** MoviePilot WEB使用的域名用于生成跳转链接等
- **BIG_MEMORY_MODE** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
- **DOH_ENABLE** DNS over HTTPS开关`true`/`false`,默认`true`开启后会使用DOH对api.themoviedb.org等域名进行解析以减少被DNS污染的情况提升网络连通性
- **META_CACHE_EXPIRE** 元数据识别缓存过期时间小时数字型不配置或者配置为0时使用系统默认大内存模式为7天否则为3天调大该值可减少themoviedb的访问次数
- **GITHUB_TOKEN** Github token提高自动更新、插件安装等请求Github Api的限流阈值格式ghp_****
- **GITHUB_PROXY** Github代理地址用于加速版本及插件升级安装格式`https://mirror.ghproxy.com/`
- **DEV:** 开发者模式,`true`/`false`,默认`false`,仅用于本地开发使用,开启后会暂停所有定时任务,且插件代码文件的修改无需重启会自动重载生效
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`需要能正常连接Github仅支持Docker镜像
---
@@ -207,16 +210,17 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
- 通过CookieCloud同步快速添加站点不需要使用的站点可在WEB管理界面中禁用或删除无法同步的站点也可手动新增。
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
### 3. **文件整理**
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关且仅会处理通过MoviePilot添加下载的任务。
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关,并在设定中维护好下载目录和媒体库目录,且仅会处理通过MoviePilot添加下载的任务(含`MOVIEPILOT`标签)
- 下载器监控默认轮循间隔为5分钟如果是使用qbittorrent可在 `QB设置`->`下载完成时运行外部程序` 处填入:`curl "http://localhost:3000/api/v1/transfer/now?token=moviepilot" `实现无需等待轮循下载完成后立即整理入库地址、端口和token按实际调整curl也可更换为wget
- 使用`目录监控`等插件实现更灵活的自动整理。
- 使用`目录监控`等插件实现更灵活的自动整理使用MoviePilot整理其它途径下载的资源时使用
### 4. **通知交互**
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
- `微信`回调地址、`SynologyChat`传入地址地址相对路径均为:`/api/v1/message/``VoceChat`的Webhook地址相对路径为`/api/v1/message/?token=moviepilot`其中moviepilot为设置的`API_TOKEN`。
- 插件市场中有其它渠道的通知插件(仅支持单向通知),可安装使用。
### 5. **订阅与搜索**
- 通过MoviePilot管理后台搜索和订阅。
- 将MoviePilot做为`Radarr`或`Sonarr`服务器添加到`Overseerr`或`Jellyseerr`,可使用`Overseerr/Jellyseerr`浏览和添加订阅。
- 安装`豆瓣榜单订阅`、`猫眼订阅`等插件,实现自动订阅豆瓣榜单、猫眼榜单等
- 安装`豆瓣榜单订阅`、`猫眼订阅`、`热门订阅`等插件,实现自动订阅各类榜单
### 6. **其他**
- 通过设置媒体服务器Webhook指向MoviePilot相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`可实现通过MoviePilot发送播放通知以及配合各类插件实现播放限速等功能。
- 映射宿主机`docker.sock`文件到容器`/var/run/docker.sock`,可支持应用内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。

View File

@@ -46,7 +46,9 @@ def download(
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name)
return schemas.Response(success=True if did else False, data={
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
"download_id": did
})
@@ -74,7 +76,9 @@ def add(
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name)
return schemas.Response(success=True if did else False, data={
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
"download_id": did
})

View File

@@ -49,7 +49,7 @@ def list_path(path: str,
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.error(f"目录不存在:{path}")
logger.warn(f"目录不存在:{path}")
return []
# 如果是文件
@@ -98,6 +98,47 @@ def list_path(path: str,
return ret_items
@router.get("/listdir", summary="所有目录(不含文件)", response_model=List[schemas.FileItem])
def list_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录
"""
# 返回结果
ret_items = []
if not path or path == "/":
if SystemUtils.is_windows():
partitions = SystemUtils.get_windows_drives() or ["C:/"]
for partition in partitions:
ret_items.append(schemas.FileItem(
type="dir",
path=partition + "/",
name=partition,
children=[]
))
return ret_items
else:
path = "/"
else:
if not SystemUtils.is_windows() and not path.startswith("/"):
path = "/" + path
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.warn(f"目录不存在:{path}")
return []
# 扁历所有目录
for item in SystemUtils.list_sub_directory(path_obj):
ret_items.append(schemas.FileItem(
type="dir",
path=str(item).replace("\\", "/") + "/",
name=item.name,
children=[]
))
return ret_items
@router.get("/mkdir", summary="创建目录", response_model=schemas.Response)
def mkdir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""

View File

@@ -27,7 +27,10 @@ def play_item(itemid: str) -> schemas.Response:
return schemas.Response(success=False, msg="参数错误")
if not settings.MEDIASERVER:
return schemas.Response(success=False, msg="未配置媒体服务器")
mediaserver = settings.MEDIASERVER.split(",")[0]
# 查找一个不为空的值
mediaserver = next((server for server in settings.MEDIASERVER.split(",") if server), None)
if not mediaserver:
return schemas.Response(success=False, msg="未配置媒体服务器")
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
# 重定向到play_url
if not play_url:

View File

@@ -1,13 +1,15 @@
import json
from typing import Union, Any, List
from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import Request
from pywebpush import WebPushException, webpush
from sqlalchemy.orm import Session
from starlette.responses import PlainTextResponse
from app import schemas
from app.chain.message import MessageChain
from app.core.config import settings
from app.core.config import settings, global_vars
from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
@@ -134,6 +136,11 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
else:
for switch in switchs:
return_list.append(NotificationSwitch(**switch))
for noti in NotificationType:
if not any([x.mtype == noti.value for x in return_list]):
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True,
synologychat=True, vocechat=True))
return return_list
@@ -150,3 +157,34 @@ def set_switchs(switchs: List[NotificationSwitch],
SystemConfigOper().set(SystemConfigKey.NotificationChannels, switch_list)
return schemas.Response(success=True)
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
"""
客户端webpush通知订阅
"""
global_vars.push_subscription(subscription.dict())
logger.debug(f"通知订阅成功: {subscription.dict()}")
return schemas.Response(success=True)
@router.post("/webpush/send", summary="发送webpush通知", response_model=schemas.Response)
def send_notification(payload: schemas.SubscriptionMessage, _: schemas.TokenPayload = Depends(verify_token)):
"""
发送webpush通知
"""
for sub in global_vars.get_subscriptions():
try:
webpush(
subscription_info=sub,
data=json.dumps(payload.dict()),
vapid_private_key=settings.VAPID.get("privateKey"),
vapid_claims={
"sub": settings.VAPID.get("subject")
},
)
except WebPushException as err:
logger.error(f"WebPush发送失败: {str(err)}")
continue
return schemas.Response(success=True)

View File

@@ -176,13 +176,15 @@ def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None,
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据插件ID重置插件配置
根据插件ID重置插件配置及数据
"""
# 删除配置
PluginManager().delete_plugin_config(plugin_id)
# 删除插件所有数据
PluginManager().delete_plugin_data(plugin_id)
# 重新生效插件
PluginManager().init_plugin(plugin_id, {
"enabled": False,

View File

@@ -13,7 +13,7 @@ router = APIRouter()
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询搜索结果
"""
@@ -85,13 +85,15 @@ def search_by_id(mediaid: str,
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])
@router.get("/title", summary="模糊搜索资源", response_model=List[schemas.TorrentInfo])
async def search_by_title(keyword: str = None,
page: int = 0,
site: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
def search_by_title(keyword: str = None,
page: int = 0,
site: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
return [torrent.to_dict() for torrent in torrents]
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])

View File

@@ -189,6 +189,24 @@ def refresh_subscribes(
return schemas.Response(success=True)
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
def reset_subscribes(
subid: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重置订阅
"""
subscribe = Subscribe.get(db, subid)
if subscribe:
subscribe.update(db, {
"note": "",
"lack_episode": subscribe.total_episode
})
return schemas.Response(success=True)
return schemas.Response(success=False, message="订阅不存在")
@router.get("/check", summary="刷新订阅 TMDB 信息", response_model=schemas.Response)
def check_subscribes(
_: schemas.TokenPayload = Depends(verify_token)) -> Any:

View File

@@ -144,7 +144,7 @@ def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
@router.get("/message", summary="实时消息")
def get_message(token: str, role: str = "sys"):
def get_message(token: str, role: str = "system"):
"""
实时获取系统消息返回格式为SSE
"""

View File

@@ -87,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(...),
_: User = Depends(get_current_active_user)):
def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
_: User = Depends(get_current_active_user)):
"""
上传用户头像
"""

View File

@@ -32,8 +32,8 @@ async def webhook_message(background_tasks: BackgroundTasks,
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
request: Request, _: str = Depends(verify_uri_token)) -> Any:
def webhook_message(background_tasks: BackgroundTasks,
request: Request, _: str = Depends(verify_uri_token)) -> Any:
"""
Webhook响应
"""

View File

@@ -94,11 +94,12 @@ class ChainBase(metaclass=ABCMeta):
result = None
modules = self.modulemanager.get_running_modules(method)
for module in modules:
module_id = module.__class__.__name__
try:
module_name = module.get_name()
except Exception as err:
logger.error(f"获取模块名称出错:{str(err)}")
module_name = module.__class__.__name__
module_name = module_id
try:
func = getattr(module, method)
if is_result_empty(result):
@@ -117,10 +118,21 @@ class ChainBase(metaclass=ABCMeta):
break
except Exception as err:
logger.error(
f"运行模块 {module.__class__.__name__}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
f"运行模块 {module_id}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
self.messagehelper.put(title=f"{module_name}发生了错误",
message=str(err),
role="system")
self.eventmanager.send_event(
EventType.SystemError,
{
"type": "module",
"module_id": module_id,
"module_name": module_name,
"module_method": method,
"error": str(err),
"traceback": traceback.format_exc()
}
)
return result
def recognize_media(self, meta: MetaBase = None,
@@ -375,7 +387,7 @@ class ChainBase(metaclass=ABCMeta):
transfer_type=transfer_type, target=target, episodes_info=episodes_info,
scrape=scrape)
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
def transfer_completed(self, hashs: str, path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
"""
转移完成后的处理

View File

@@ -6,6 +6,7 @@ import time
from pathlib import Path
from typing import List, Optional, Tuple, Set, Dict, Union
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context
@@ -88,7 +89,8 @@ class DownloadChain(ChainBase):
title=f"{mediainfo.title_year} "
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
text=msg_text,
image=mediainfo.get_message_image()))
image=mediainfo.get_message_image(),
link=settings.MP_DOMAIN('/#/downloading')))
def download_torrent(self, torrent: TorrentInfo,
channel: MessageChannel = None,
@@ -840,7 +842,9 @@ class DownloadChain(ChainBase):
channel=channel,
mtype=NotificationType.Download,
title="没有正在下载的任务!",
userid=userid))
userid=userid,
link=settings.MP_DOMAIN('#/downloading')
))
return
# 发送消息
title = f"{len(torrents)} 个任务正在下载:"
@@ -852,8 +856,13 @@ class DownloadChain(ChainBase):
f"{round(torrent.progress, 1)}%")
index += 1
self.post_message(Notification(
channel=channel, mtype=NotificationType.Download,
title=title, text="\n".join(messages), userid=userid))
channel=channel,
mtype=NotificationType.Download,
title=title,
text="\n".join(messages),
userid=userid,
link=settings.MP_DOMAIN('#/downloading')
))
def downloading(self) -> List[DownloadingTorrent]:
"""
@@ -908,4 +917,14 @@ class DownloadChain(ChainBase):
if not hash_str:
return
logger.warn(f"检测到下载源文件被删除,删除下载任务(不含文件):{hash_str}")
self.remove_torrents(hashs=[hash_str], delete_file=False)
# 先查询种子
torrents: List[schemas.TransferTorrent] = self.list_torrents(hashs=[hash_str])
if torrents:
self.remove_torrents(hashs=[hash_str], delete_file=False)
# 发出下载任务删除事件,如需处理辅种,可监听该事件
self.eventmanager.send_event(EventType.DownloadDeleted, {
"hash": hash_str,
"torrents": [torrent.dict() for torrent in torrents]
})
else:
logger.info(f"没有在下载器中查询到 {hash_str} 对应的下载任务")

View File

@@ -80,6 +80,8 @@ class MediaServerChain(ChainBase):
self.dboper.empty()
# 遍历媒体服务器
for mediaserver in mediaservers:
if not mediaserver:
continue
logger.info(f"开始同步媒体库 {mediaserver} 的数据 ...")
for library in self.librarys(mediaserver):
# 同步黑名单 跳过

View File

@@ -520,5 +520,6 @@ class MessageChain(ChainBase):
self.post_torrents_message(Notification(
channel=channel,
title=title,
userid=userid
userid=userid,
link=settings.MP_DOMAIN('#/resource')
), torrents=items)

View File

@@ -53,12 +53,12 @@ class SearchChain(ChainBase):
}
}
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
# 保存结果
# 保存结果
bytes_results = pickle.dumps(results)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
return results
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[TorrentInfo]:
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[Context]:
"""
根据标题搜索资源,不识别不过滤,直接返回站点内容
:param title: 标题,为空时返回所有站点首页内容
@@ -70,7 +70,17 @@ class SearchChain(ChainBase):
else:
logger.info(f'开始浏览资源,站点:{site} ...')
# 搜索
return self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
torrents = self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
torrent_info=torrent) for torrent in torrents]
# 保存结果
bytes_results = pickle.dumps(contexts)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
return contexts
def last_search_results(self) -> List[Context]:
"""

View File

@@ -109,11 +109,11 @@ 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
}
"Content-Type": "application/json",
"User-Agent": user_agent,
"Accept": "application/json, text/plain, */*",
"Authorization": site.token
}
res = RequestUtils(
headers=headers,
proxies=settings.PROXY if site.proxy else None,
@@ -457,7 +457,8 @@ class SiteChain(ChainBase):
self.post_message(Notification(
channel=channel,
title="没有维护任何站点信息!",
userid=userid))
userid=userid,
link=settings.MP_DOMAIN('#/site')))
title = f"共有 {len(site_list)} 个站点,回复对应指令操作:" \
f"\n- 禁用站点:/site_disable [id]" \
f"\n- 启用站点:/site_enable [id]" \
@@ -475,7 +476,8 @@ class SiteChain(ChainBase):
# 发送列表
self.post_message(Notification(
channel=channel,
title=title, text="\n".join(messages), userid=userid))
title=title, text="\n".join(messages), userid=userid,
link=settings.MP_DOMAIN('#/site')))
def remote_disable(self, arg_str, channel: MessageChannel, userid: Union[str, int] = None):
"""

View File

@@ -139,15 +139,24 @@ class SubscribeChain(ChainBase):
mediainfo.bangumi_id = bangumiid
# 添加订阅
kwargs.update({
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality"),
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution"),
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect"),
'include': self.__get_default_subscribe_config(mediainfo.type, "include"),
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude"),
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version") 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"),
'quality': self.__get_default_subscribe_config(mediainfo.type, "quality") if not kwargs.get(
"quality") else kwargs.get("quality"),
'resolution': self.__get_default_subscribe_config(mediainfo.type, "resolution") if not kwargs.get(
"resolution") else kwargs.get("resolution"),
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect") if not kwargs.get(
"effect") else kwargs.get("effect"),
'include': self.__get_default_subscribe_config(mediainfo.type, "include") if not kwargs.get(
"include") else kwargs.get("include"),
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude") if not kwargs.get(
"exclude") else kwargs.get("exclude"),
'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") if not kwargs.get(
"search_imdbid") else kwargs.get("search_imdbid"),
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None if not kwargs.get(
"sites") else kwargs.get("sites"),
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path") if not kwargs.get(
"save_path") else kwargs.get("save_path")
})
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
if not sid:
@@ -169,10 +178,15 @@ class SubscribeChain(ChainBase):
else:
text = f"评分:{mediainfo.vote_average}"
# 群发
if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub')
else:
link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub')
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
text=text,
image=mediainfo.get_message_image()))
image=mediainfo.get_message_image(),
link=link))
# 发送事件
EventManager().send_event(EventType.SubscribeAdded, {
"subscribe_id": sid,
@@ -243,7 +257,11 @@ class SubscribeChain(ChainBase):
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
try:
meta.type = MediaType(subscribe.type)
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
@@ -460,14 +478,29 @@ class SubscribeChain(ChainBase):
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
"""
获取订阅中涉及的站点清单
:param subscribe: 订阅信息对象
:return: 涉及的站点清单
"""
if subscribe.sites:
try:
return json.loads(subscribe.sites)
except JSONDecodeError:
return []
# 默认站点
return self.systemconfig.get(SystemConfigKey.RssSites) or []
# 从系统配置获取默认订阅站点
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
# 如果订阅未指定站点信息,直接返回默认站点
if not subscribe.sites:
return default_sites
try:
# 尝试解析订阅中的站点数据
user_sites = json.loads(subscribe.sites)
# 计算 user_sites 和 default_sites 的交集
intersection_sites = [site for site in user_sites if site in default_sites]
# 如果交集与原始订阅不一致,更新数据库
if set(intersection_sites) != set(user_sites):
self.subscribeoper.update(subscribe.id, {
"sites": json.dumps(intersection_sites)
})
# 如果交集为空,返回默认站点
return intersection_sites if intersection_sites else default_sites
except JSONDecodeError:
# 如果 JSON 解析失败,返回默认站点
return default_sites
def get_subscribed_sites(self) -> Optional[List[int]]:
"""
@@ -526,7 +559,11 @@ class SubscribeChain(ChainBase):
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
try:
meta.type = MediaType(subscribe.type)
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 订阅的站点域名列表
domains = []
if subscribe.sites:
@@ -762,7 +799,11 @@ class SubscribeChain(ChainBase):
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
meta.begin_season = subscribe.season or None
meta.type = MediaType(subscribe.type)
try:
meta.type = MediaType(subscribe.type)
except ValueError:
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
@@ -895,9 +936,14 @@ class SubscribeChain(ChainBase):
# 删除订阅
self.subscribeoper.delete(subscribe.id)
# 发送通知
if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe-tv?tab=mysub')
else:
link = settings.MP_DOMAIN('#/subscribe-movie?tab=mysub')
self.post_message(Notification(mtype=NotificationType.Subscribe,
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
image=mediainfo.get_message_image()))
image=mediainfo.get_message_image(),
link=link))
# 发送事件
EventManager().send_event(EventType.SubscribeComplete, {
"subscribe_id": subscribe.id,

View File

@@ -99,7 +99,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址')
return []
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False,
timeout=int(site.get("timeout") or 30))
if rss_items is None:
# rss过期尝试保留原配置生成新的rss
self.__renew_rss_url(domain=domain, site=site)
@@ -253,10 +254,14 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
else:
# 发送消息
self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=settings.MP_DOMAIN('#/site'))
)
else:
self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=settings.MP_DOMAIN('#/site')))
except Exception as e:
logger.error(f"站点 {domain} RSS链接自动获取失败{str(e)} - {traceback.format_exc()}")
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期",
link=settings.MP_DOMAIN('#/site')))

View File

@@ -18,6 +18,7 @@ from app.db.systemconfig_oper import SystemConfigOper
from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.directory import DirectoryHelper
from app.helper.format import FormatParser
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.log import logger
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat
@@ -43,6 +44,7 @@ class TransferChain(ChainBase):
self.tmdbchain = TmdbChain()
self.systemconfig = SystemConfigOper()
self.directoryhelper = DirectoryHelper()
self.messagehelper = MessageHelper()
def process(self) -> bool:
"""
@@ -65,7 +67,10 @@ class TransferChain(ChainBase):
downloadhis: DownloadHistory = self.downloadhis.get_by_hash(torrent.hash)
if downloadhis:
# 类型
mtype = MediaType(downloadhis.type)
try:
mtype = MediaType(downloadhis.type)
except ValueError:
mtype = MediaType.TV
# 按TMDBID识别
mediainfo = self.recognize_media(mtype=mtype,
tmdbid=downloadhis.tmdbid,
@@ -264,7 +269,8 @@ class TransferChain(ChainBase):
self.post_message(Notification(
mtype=NotificationType.Manual,
title=f"{file_path.name} 未识别到媒体信息,无法入库!",
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。",
link=settings.MP_DOMAIN('#/history')
))
# 计数
processed_num += 1
@@ -327,7 +333,8 @@ class TransferChain(ChainBase):
mtype=NotificationType.Manual,
title=f"{file_mediainfo.title_year} {file_meta.season_episode} 入库失败!",
text=f"原因:{transferinfo.message or '未知'}",
image=file_mediainfo.get_message_image()
image=file_mediainfo.get_message_image(),
link=settings.MP_DOMAIN('#/history')
))
# 计数
processed_num += 1
@@ -352,10 +359,22 @@ class TransferChain(ChainBase):
transfers[mkey].file_list_new.extend(transferinfo.file_list_new)
transfers[mkey].fail_list.extend(transferinfo.fail_list)
# 硬链接检查
temp_transfer_type = transfer_type
if transfer_type == "link":
if not SystemUtils.is_hardlink(file_path, transferinfo.target_path):
logger.warn(
f"{file_path}{transferinfo.target_path} 不是同一硬链接文件路径,请检查存储空间占用和整理耗时,确认是否为复制")
self.messagehelper.put(
f"{file_path}{transferinfo.target_path} 不是同一硬链接文件路径,疑似硬链接失败,请检查是否为复制",
title="硬链接失败",
role="system")
temp_transfer_type = "copy"
# 新增转移成功历史记录
self.transferhis.add_success(
src_path=file_path,
mode=transfer_type,
mode=temp_transfer_type,
download_hash=download_hash,
meta=file_meta,
mediainfo=file_mediainfo,
@@ -365,7 +384,7 @@ class TransferChain(ChainBase):
if transferinfo.need_scrape:
self.scrape_metadata(path=transferinfo.target_path,
mediainfo=file_mediainfo,
transfer_type=transfer_type,
transfer_type=temp_transfer_type,
metainfo=file_meta)
# 更新进度
processed_num += 1
@@ -489,7 +508,7 @@ class TransferChain(ChainBase):
mediaid=media_id)
if not state:
self.post_message(Notification(channel=channel, title="手动整理失败",
text=errmsg, userid=userid))
text=errmsg, userid=userid, link=settings.MP_DOMAIN('#/history')))
return
def re_transfer(self, logid: int, mtype: MediaType = None,
@@ -625,7 +644,8 @@ class TransferChain(ChainBase):
# 发送
self.post_message(Notification(
mtype=NotificationType.Organize,
title=msg_title, text=msg_str, image=mediainfo.get_message_image()))
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
link=settings.MP_DOMAIN('#/history')))
def delete_files(self, path: Path) -> Tuple[bool, str]:
"""
@@ -657,22 +677,31 @@ class TransferChain(ChainBase):
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
# 媒体库二级分类根路径
# 所有媒体库根目录的名称
library_roots = self.directoryhelper.get_library_dirs()
library_root_names = [Path(library_root.path).name for library_root in library_roots if library_root.path]
# 所有二级分类的名称
category_names = []
category_conf = self.media_category()
if category_conf:
category_names += list(category_conf.keys())
for cats in category_conf.values():
category_names += cats
# 判断父目录是否为空, 为空则删除
for parent_path in path.parents:
# 遍历父目录到媒体库二级分类根路径
if parent_path.name in library_root_names:
break
if parent_path.name in category_names:
continue
if str(parent_path.parent) != str(path.root):
# 父目录非根目录,才删除父目录
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
# 当前路径下没有媒体文件则删除
try:
shutil.rmtree(parent_path)
logger.warn(f"目录 {parent_path} 已删除")
except Exception as e:
logger.error(f"删除目录 {parent_path} 失败:{str(e)}")
return False, f"删除目录 {parent_path} 失败:{str(e)}"
logger.warn(f"目录 {parent_path} 已删除")
return True, ""

View File

@@ -224,6 +224,16 @@ class Command(metaclass=Singleton):
self.messagehelper.put(title=f"{event.event_type} 事件处理出错",
message=f"{class_name}.{method_name}{str(e)}",
role="system")
self.eventmanager.send_event(
EventType.SystemError,
{
"type": "event",
"event_type": event.event_type,
"event_handle": f"{class_name}.{method_name}",
"error": str(e),
"traceback": traceback.format_exc()
}
)
def __run_command(self, command: Dict[str, any],
data_str: str = "",
@@ -263,6 +273,8 @@ class Command(metaclass=Singleton):
data = cmd_data.get("data") or {}
data['channel'] = channel
data['user'] = userid
if data_str:
data['args'] = data_str
cmd_data['data'] = data
command['func'](**cmd_data)
elif args_num == 2:

View File

@@ -2,7 +2,7 @@ import secrets
import sys
import threading
from pathlib import Path
from typing import Optional
from typing import Optional, List
from pydantic import BaseSettings, validator
@@ -15,6 +15,8 @@ class Settings(BaseSettings):
"""
# 项目名称
PROJECT_NAME = "MoviePilot"
# 域名 格式https://movie-pilot.org
APP_DOMAIN: str = ""
# API路径
API_V1_STR: str = "/api/v1"
# 前端资源路径
@@ -93,8 +95,8 @@ class Settings(BaseSettings):
AUTH_SITE: str = ""
# 交互搜索自动下载用户ID使用,分割
AUTO_DOWNLOAD_USER: Optional[str] = None
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat多个通知渠道用,分隔
MESSAGER: str = "telegram"
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat/webpush,多个通知渠道用,分隔
MESSAGER: str = "webpush"
# WeChat企业ID
WECHAT_CORPID: Optional[str] = None
# WeChat应用Secret
@@ -220,6 +222,8 @@ class Settings(BaseSettings):
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
# Github代理服务器格式https://mirror.ghproxy.com/
GITHUB_PROXY: Optional[str] = ''
# 自动检查和更新站点资源包(站点索引、认证等)
AUTO_UPDATE_RESOURCE: bool = True
# 元数据识别缓存过期时间(小时)
@@ -300,7 +304,7 @@ class Settings(BaseSettings):
@property
def LOG_PATH(self):
return self.CONFIG_PATH / "logs"
@property
def COOKIE_PATH(self):
return self.CONFIG_PATH / "cookies"
@@ -359,7 +363,7 @@ class Settings(BaseSettings):
"""
if not self.DOWNLOADER:
return None
return self.DOWNLOADER.split(",")[0]
return next((d for d in settings.DOWNLOADER.split(",") if d), None)
@property
def DOWNLOADERS(self):
@@ -368,7 +372,25 @@ class Settings(BaseSettings):
"""
if not self.DOWNLOADER:
return []
return self.DOWNLOADER.split(",")
return [d for d in settings.DOWNLOADER.split(",") if d]
@property
def VAPID(self):
return {
"subject": f"mailto:{self.SUPERUSER}@movie-pilot.org",
"publicKey": "BH3w49sZA6jXUnE-yt4jO6VKh73lsdsvwoJ6Hx7fmPIDKoqGiUl2GEoZzy-iJfn4SfQQcx7yQdHf9RknwrL_lSM",
"privateKey": "JTixnYY0vEw97t9uukfO3UWKfHKJdT5kCQDiv3gu894"
}
def MP_DOMAIN(self, url: str = None):
if not self.APP_DOMAIN:
return None
domain = self.APP_DOMAIN.rstrip("/")
if not domain.startswith("http"):
domain = "http://" + domain
if not url:
return domain
return domain + "/" + url.lstrip("/")
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -398,6 +420,8 @@ class GlobalVar(object):
"""
# 系统停止事件
STOP_EVENT: threading.Event = threading.Event()
# webpush订阅
SUBSCRIPTIONS: List[dict] = []
def stop_system(self):
"""
@@ -411,6 +435,18 @@ class GlobalVar(object):
"""
return self.STOP_EVENT.is_set()
def get_subscriptions(self):
"""
获取webpush订阅
"""
return self.SUBSCRIPTIONS
def push_subscription(self, subscription: dict):
"""
添加webpush订阅
"""
self.SUBSCRIPTIONS.append(subscription)
# 实例化配置
settings = Settings(

View File

@@ -1,5 +1,5 @@
import traceback
from typing import Generator, Optional, Tuple
from typing import Generator, Optional, Tuple, Any
from app.core.config import settings
from app.helper.module import ModuleHelper
@@ -97,6 +97,16 @@ class ModuleManager(metaclass=Singleton):
return True
return False
def get_running_module(self, module_id: str) -> Any:
"""
根据模块id获取模块运行实例
"""
if not module_id:
return None
if not self._running_modules:
return None
return self._running_modules.get(module_id)
def get_running_modules(self, method: str) -> Generator:
"""
获取实现了同一方法的模块列表
@@ -108,6 +118,16 @@ class ModuleManager(metaclass=Singleton):
and ObjectUtils.check_method(getattr(module, method)):
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块
"""
if not module_id:
return None
if not self._modules:
return None
return self._modules.get(module_id)
def get_modules(self) -> dict:
"""
获取模块列表

View File

@@ -1,11 +1,11 @@
import concurrent
import concurrent.futures
import inspect
import os
import threading
import time
import traceback
from typing import List, Any, Dict, Tuple, Optional, Callable
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@@ -13,6 +13,7 @@ from watchdog.observers import Observer
from app import schemas
from app.core.config import settings
from app.core.event import eventmanager
from app.db.plugindata_oper import PluginDataOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.module import ModuleHelper
from app.helper.plugin import PluginHelper
@@ -26,7 +27,6 @@ from app.utils.system import SystemUtils
class PluginMonitorHandler(FileSystemEventHandler):
# 计时器
__reload_timer = None
# 防抖时间间隔
@@ -42,21 +42,35 @@ class PluginMonitorHandler(FileSystemEventHandler):
"""
if event.is_directory:
return
# 使用 pathlib 处理文件路径,跳过非 .py 文件以及 pycache 目录中的文件
event_path = Path(event.src_path)
if not event_path.name.endswith(".py") or "pycache" in event_path.parts:
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"
plugins_root = settings.ROOT_PATH / "app" / "plugins"
# 确保修改的文件在 plugins 目录下
if plugins_root not in event_path.parents:
return
# 获取插件目录路径没有找到__init__.py时说明不是有效包跳过插件重载
# 插件重载目前没有支持app/plugins/plugin/package/__init__.py的场景这里也不做支持
plugin_dir = event_path.parent
init_file = plugin_dir / "__init__.py"
if not init_file.exists():
logger.debug(f"{plugin_dir} 下没有找到 __init__.py跳过插件重载")
return
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]
pid = line.split("class ")[1].split("(_PluginBase)")[0].strip()
if pid:
# 防抖处理,通过计时器延迟加载
if self.__reload_timer:
@@ -97,6 +111,7 @@ class PluginManager(metaclass=Singleton):
self.siteshelper = SitesHelper()
self.pluginhelper = PluginHelper()
self.systemconfig = SystemConfigOper()
self.plugindata = PluginDataOper()
# 开发者模式监测插件修改
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
self.__start_monitor()
@@ -320,6 +335,16 @@ class PluginManager(metaclass=Singleton):
return False
return self.systemconfig.delete(self._config_key % pid)
def delete_plugin_data(self, pid: str) -> bool:
"""
删除插件数据
:param pid: 插件ID
"""
if not self._plugins.get(pid):
return False
self.plugindata.del_data(pid)
return True
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
"""
获取插件表单
@@ -350,6 +375,7 @@ class PluginManager(metaclass=Singleton):
:param pid: 插件ID
:param key: 仪表盘key
"""
def __get_params_count(func: Callable):
"""
获取函数的参数信息
@@ -619,24 +645,27 @@ class PluginManager(metaclass=Singleton):
with concurrent.futures.ThreadPoolExecutor() as executor:
futures = []
for m in settings.PLUGIN_MARKET.split(","):
if not m:
continue
futures.append(executor.submit(__get_plugin_info, m))
for future in concurrent.futures.as_completed(futures):
plugins = future.result()
if plugins:
all_plugins.extend(plugins)
# 去重
all_plugins = list({f"{p.id}{p.plugin_version}": p for p in all_plugins}.values())
# 所有插件按repo在设置中的顺序排序
all_plugins.sort(
key=lambda x: settings.PLUGIN_MARKET.split(",").index(x.repo_url) if x.repo_url else 0
)
# 按插件ID和版本号去重相同插件以前面的为准
result = []
_dup = []
# 相同ID的插件保留版本号最大版本
max_versions = {}
for p in all_plugins:
key = f"{p.id}v{p.plugin_version}"
if key not in _dup:
_dup.append(key)
result.append(p)
logger.info(f"共获取到 {len(result)}第三方插件")
if p.id not in max_versions or StringUtils.compare_version(p.plugin_version, max_versions[p.id]) > 0:
max_versions[p.id] = p.plugin_version
result = [p for p in all_plugins if
p.plugin_version == max_versions[p.id]]
logger.info(f"共获取到 {len(result)}线上插件")
return result
def get_local_plugins(self) -> List[schemas.Plugin]:

View File

@@ -29,6 +29,11 @@ class PluginData(Base):
def del_plugin_data_by_key(db: Session, plugin_id: str, key: str):
db.query(PluginData).filter(PluginData.plugin_id == plugin_id, PluginData.key == key).delete()
@staticmethod
@db_update
def del_plugin_data(db: Session, plugin_id: str):
db.query(PluginData).filter(PluginData.plugin_id == plugin_id).delete()
@staticmethod
@db_query
def get_plugin_data_by_plugin_id(db: Session, plugin_id: str):

View File

@@ -44,13 +44,16 @@ class PluginDataOper(DbOper):
else:
return PluginData.get_plugin_data(self._db, plugin_id)
def del_data(self, plugin_id: str, key: str) -> Any:
def del_data(self, plugin_id: str, key: str = None) -> Any:
"""
删除插件数据
:param plugin_id: 插件id
:param key: 数据key
"""
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
if key:
PluginData.del_plugin_data_by_key(self._db, plugin_id, key)
else:
PluginData.del_plugin_data(self._db, plugin_id)
def truncate(self):
"""

View File

@@ -5,6 +5,7 @@ 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.log import logger
from app.schemas.types import SystemConfigKey, MediaType
from app.utils.system import SystemUtils
@@ -46,23 +47,24 @@ class DirectoryHelper:
media_type = media.type.value
else:
media_type = MediaType.UNKNOWN.value
media_dirs = self.get_download_dirs()
download_dirs = self.get_download_dirs()
# 按照配置顺序查找(保存后的数据已经排序)
for media_dir in media_dirs:
if not media_dir.path:
for download_dir in download_dirs:
if not download_dir.path:
continue
download_path = Path(download_dir.path)
# 有目标目录,但目标目录与当前目录不相等时不要
if to_path and Path(media_dir.path) != to_path:
if to_path and download_path != to_path:
continue
# 目录类型为全部的,符合条件
if not media_dir.media_type:
return media_dir
if not download_dir.media_type:
return download_dir
# 目录类型相等,目录类别为全部,符合条件
if media_dir.media_type == media_type and not media_dir.category:
return media_dir
if download_dir.media_type == media_type and not download_dir.category:
return download_dir
# 目录类型相等,目录类别相等,符合条件
if media_dir.media_type == media_type and media_dir.category == media.category:
return media_dir
if download_dir.media_type == media_type and download_dir.category == media.category:
return download_dir
return None
@@ -74,11 +76,28 @@ class DirectoryHelper:
:param in_path: 源目录
:param to_path: 目标目录
"""
def __comman_parts(path1: Path, path2: Path) -> int:
"""
计算两个路径的公共路径长度
"""
parts1 = path1.parts
parts2 = path2.parts
root_flag = parts1[0] == '/' and parts2[0] == '/'
length = min(len(parts1), len(parts2))
for i in range(length):
if parts1[i] == '/' and parts2[i] == '/':
continue
if parts1[i] != parts2[i]:
return i - 1 if root_flag else i
return length - 1 if root_flag else length
# 处理类型
if media:
media_type = media.type.value
else:
media_type = MediaType.UNKNOWN.value
# 匹配的目录
matched_dirs = []
library_dirs = self.get_library_dirs()
@@ -103,13 +122,43 @@ class DirectoryHelper:
if not matched_dirs:
return None
# 优先同盘
# 没有目录则创建
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 len(matched_dirs) == 1:
return matched_dirs[0]
# 有源路径,且开启同盘/同目录优先时
if in_path and settings.TRANSFER_SAME_DISK:
# 优先同根路径
max_length = 0
target_dirs = []
for matched_dir in matched_dirs:
try:
# 计算in_path和path的公共路径长度
relative_len = __comman_parts(in_path, Path(matched_dir.path))
if relative_len and relative_len >= max_length:
max_length = relative_len
target_dirs.append({
'path': matched_dir,
'relative_len': relative_len
})
except Exception as e:
logger.debug(f"计算目标路径时出错:{str(e)}")
continue
if target_dirs:
target_dirs.sort(key=lambda x: x['relative_len'], reverse=True)
matched_dirs = [x['path'] for x in target_dirs]
# 优先同盘
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

@@ -52,12 +52,12 @@ class MessageHelper(metaclass=Singleton):
content['note'] = note
self.user_queue.put(json.dumps(content))
def get(self, role: str = "sys") -> Optional[str]:
def get(self, role: str = "system") -> Optional[str]:
"""
取消息
:param role: 消息通道 sys/user
:param role: 消息通道 systm系统消息plugin插件消息user用户消息
"""
if role == "sys":
if role == "system":
if not self.sys_queue.empty():
return self.sys_queue.get(block=False)
else:

View File

@@ -18,7 +18,7 @@ class ModuleHelper:
导入模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数入参为模块名和模块对象返回True则导入否则不导入
:return:
:return: 导入的模块对象列表
"""
submodules: list = []
@@ -46,27 +46,47 @@ class ModuleHelper:
导入子模块
:param package_path: 父包名
:param filter_func: 子模块过滤函数入参为模块名和模块对象返回True则导入否则不导入
:return:
:return: 导入的模块对象列表
"""
submodules: list = []
packages = importlib.import_module(package_path)
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
def reload_module_objects(target_module):
"""加载模块并返回对象"""
importlib.reload(target_module)
# reload后重新过滤已经重新加载后的模块中的对象
return [
obj for name, obj in target_module.__dict__.items()
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj)
]
def reload_sub_modules(parent_module, parent_module_name):
"""重新加载一级子模块"""
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__):
full_sub_module_name = f'{parent_module_name}.{sub_module_name}'
try:
full_sub_module = importlib.import_module(full_sub_module_name)
importlib.reload(full_sub_module)
except Exception as sub_err:
logger.debug(f'加载子模块 {full_sub_module_name} 失败:{str(sub_err)} - {traceback.format_exc()}')
# 遍历包中的所有子模块
for importer, package_name, is_pkg in pkgutil.iter_modules(packages.__path__):
if package_name.startswith('_'):
continue
full_package_name = f'{package_path}.{package_name}'
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)
# 如果子模块是包,重新加载其子模块
if is_pkg:
reload_sub_modules(module, full_package_name)
submodules.extend(reload_module_objects(module))
except Exception as err:
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')

View File

@@ -20,7 +20,7 @@ class PluginHelper(metaclass=Singleton):
插件市场管理,下载安装插件到本地
"""
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
_base_url = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/%s/%s/main/"
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/%s"
@@ -35,6 +35,10 @@ class PluginHelper(metaclass=Singleton):
if self.install_report():
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
@property
def proxies(self):
return None if settings.GITHUB_PROXY else settings.PROXY
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
"""
@@ -47,7 +51,7 @@ class PluginHelper(metaclass=Singleton):
if not user or not repo:
return {}
raw_url = self._base_url % (user, repo)
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
timeout=10).get_res(f"{raw_url}package.json")
if res:
try:
@@ -157,9 +161,10 @@ class PluginHelper(metaclass=Singleton):
return False, "文件列表为空"
for item in _l:
if item.get("download_url"):
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载插件文件
res = RequestUtils(proxies=settings.PROXY,
headers=settings.GITHUB_HEADERS, timeout=60).get_res(item["download_url"])
res = RequestUtils(proxies=self.proxies,
headers=settings.GITHUB_HEADERS, timeout=60).get_res(download_url)
if not res:
return False, f"文件 {item.get('name')} 下载失败!"
elif res.status_code != 200:

View File

@@ -15,7 +15,7 @@ class ResourceHelper(metaclass=Singleton):
检测和更新资源包
"""
# 资源包的git仓库地址
_repo = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
_repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources"
_base_dir: Path = settings.ROOT_PATH
@@ -23,6 +23,10 @@ class ResourceHelper(metaclass=Singleton):
self.siteshelper = SitesHelper()
self.check()
@property
def proxies(self):
return None if settings.GITHUB_PROXY else settings.PROXY
def check(self):
"""
检测是否有更新,如有则下载安装
@@ -32,7 +36,7 @@ class ResourceHelper(metaclass=Singleton):
if SystemUtils.is_frozen():
return
logger.info("开始检测资源包版本...")
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
if res:
try:
resource_info = json.loads(res.text)
@@ -86,9 +90,11 @@ class ResourceHelper(metaclass=Singleton):
if not save_path:
continue
if item.get("download_url"):
logger.info(f"开始更新资源文件:{item.get('name')} ...")
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
# 下载资源文件
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(item["download_url"])
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
timeout=180).get_res(download_url)
if not res:
logger.error(f"文件 {item.get('name')} 下载失败!")
elif res.status_code != 200:

View File

@@ -156,7 +156,8 @@ def check_auth():
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证",
text=err_msg
text=err_msg,
link=settings.MP_DOMAIN('#/site')
)
)
@@ -165,6 +166,7 @@ def singal_handle():
"""
监听停止信号
"""
def stop_event(signum: int, _: FrameType):
"""
SIGTERM信号处理

View File

@@ -77,6 +77,8 @@ def checkMessage(channel_type: MessageChannel):
return None
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
return None
if channel_type == MessageChannel.WebPush and not switch.get("webpush"):
return None
return func(self, message, *args, **kwargs)
return wrapper

View File

@@ -57,24 +57,27 @@ class FileTransferModule(_ModuleBase):
if not download_path.exists():
return False, f"下载目录 {d_path.name} 对应路径 {path} 不存在"
# 检查媒体库目录
libaray_dirs = directoryhelper.get_library_dirs()
if not libaray_dirs:
libaray_paths = directoryhelper.get_library_dirs()
if not libaray_paths:
return False, "媒体库目录未设置"
# 比较媒体库目录的设备ID
for l_path in libaray_dirs:
for l_path in libaray_paths:
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"媒体库目录{l_path.name} 对应的路径 {path} 不存在"
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
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} 不在同一磁盘/存储空间/映射路径,将无法硬链接"
# 检查硬链接条件
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
for d_path in download_paths:
link_ok = False
for l_path in libaray_paths:
if SystemUtils.is_same_disk(Path(d_path.path), Path(l_path.path)):
link_ok = True
break
if not link_ok:
return False, f"媒体库目录中未找到" \
f"与下载目录 {d_path.path} 在同一磁盘/存储空间/映射路径的目录,将无法硬链接"
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
@@ -117,7 +120,7 @@ class FileTransferModule(_ModuleBase):
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dir_info)
elif target:
# 自定义目标路径
need_scrape = False
need_scrape = scrape or False
else:
# 未找到有效的媒体库目录
logger.error(

View File

@@ -338,7 +338,7 @@ class FilterModule(_ModuleBase):
info_values = [str(info_value).upper()]
# 过滤值转化为数组
if value.find(",") != -1:
values = [str(val).upper() for val in value.split(",")]
values = [str(val).upper() for val in value.split(",") if val]
else:
values = [str(value).upper()]
# 没有交集为不匹配

View File

@@ -674,7 +674,9 @@ class TorrentSpider:
try:
args = filter_item.get("args")
if method_name == "re_search" and isinstance(args, list):
text = re.search(r"%s" % args[0], text).group(args[-1])
rematch = re.search(r"%s" % args[0], text)
if rematch:
text = rematch.group(args[-1])
elif method_name == "split" and isinstance(args, list):
text = text.split(r"%s" % args[0])[args[-1]]
elif method_name == "replace" and isinstance(args, list):

View File

@@ -317,7 +317,7 @@ class Plex:
# 否则一个一个刷新
for path, lib_key in result_dict.items():
logger.info(f"刷新媒体库:{lib_key} - {path}")
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(path)}')
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(str(Path(path).parent))}')
@staticmethod
def __find_librarie(path: Path, libraries: List[Any]) -> Tuple[str, str]:

View File

@@ -197,6 +197,7 @@ class QbittorrentModule(_ModuleBase):
title=torrent.get('name'),
path=torrent_path,
hash=torrent.get('hash'),
size=torrent.get('total_size'),
tags=torrent.get('tags')
))
elif status == TorrentStatus.TRANSFER:
@@ -242,7 +243,7 @@ class QbittorrentModule(_ModuleBase):
return None
return ret_torrents
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
def transfer_completed(self, hashs: str, path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
"""
转移完成后的处理

View File

@@ -202,7 +202,7 @@ class SlackModule(_ModuleBase):
:return: 成功或失败
"""
self.slack.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Slack)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:

View File

@@ -93,13 +93,13 @@ class Slack:
"""
return True if self._client else False
def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""):
def send_msg(self, title: str, text: str = "", image: str = "", link: str = "", userid: str = ""):
"""
发送Telegram消息
:param title: 消息标题
:param text: 消息内容
:param image: 消息图片地址
:param url: 点击消息转转的URL
:param link: 点击消息转转的URL
:param userid: 用户ID如有则只发消息给该用户
:user_id: 发送消息的目标用户ID为空则发给管理员
"""
@@ -132,7 +132,7 @@ class Slack:
"alt_text": f"{title}"
}})
# 链接
if url:
if link:
blocks.append({
"type": "actions",
"elements": [
@@ -144,7 +144,7 @@ class Slack:
"emoji": True
},
"value": "click_me_url",
"url": f"{url}",
"url": f"{link}",
"action_id": "actionId-url"
}
]

View File

@@ -74,7 +74,7 @@ class SynologyChatModule(_ModuleBase):
:return: 成功或失败
"""
self.synologychat.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.SynologyChat)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -95,4 +95,5 @@ class SynologyChatModule(_ModuleBase):
:param torrents: 种子列表
:return: 成功或失败
"""
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, link=message.link)

View File

@@ -36,7 +36,8 @@ class SynologyChat:
return True
return False
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
def send_msg(self, title: str, text: str = "", image: str = "",
userid: str = "", link: str = None) -> Optional[bool]:
"""
发送Telegram消息
:param title: 消息标题
@@ -44,6 +45,7 @@ class SynologyChat:
:param image: 消息图片地址
:param userid: 用户ID如有则只发消息给该用户
:user_id: 发送消息的目标用户ID为空则发给管理员
:param link: 链接地址
"""
if not title and not text:
logger.error("标题和内容不能同时为空")
@@ -64,6 +66,10 @@ class SynologyChat:
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
else:
caption = title
if link:
caption = f"{caption}\n[查看详情]({link})"
payload_data = {'text': quote(caption)}
if image:
payload_data['file_url'] = quote(image)
@@ -127,7 +133,7 @@ class SynologyChat:
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
userid: str = "", title: str = "", link: str = None) -> Optional[bool]:
"""
发送列表消息
"""
@@ -157,6 +163,9 @@ class SynologyChat:
f"_{description}_"
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
userids = [int(userid)]
else:

View File

@@ -110,7 +110,7 @@ class TelegramModule(_ModuleBase):
:return: 成功或失败
"""
self.telegram.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Telegram)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -121,7 +121,7 @@ class TelegramModule(_ModuleBase):
:return: 成功或失败
"""
return self.telegram.send_meidas_msg(title=message.title, medias=medias,
userid=message.userid)
userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Telegram)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
@@ -131,7 +131,8 @@ class TelegramModule(_ModuleBase):
:param torrents: 种子列表
:return: 成功或失败
"""
return self.telegram.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
return self.telegram.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, link=message.link)
def register_commands(self, commands: Dict[str, dict]):
"""

View File

@@ -67,13 +67,15 @@ class Telegram:
"""
return self._bot is not None
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
def send_msg(self, title: str, text: str = "", image: str = "",
userid: str = "", link: str = None) -> Optional[bool]:
"""
发送Telegram消息
:param title: 消息标题
:param text: 消息内容
:param image: 消息图片地址
:param userid: 用户ID如有则只发消息给该用户
:param link: 跳转链接
:userid: 发送消息的目标用户ID为空则发给管理员
"""
if not self._telegram_token or not self._telegram_chat_id:
@@ -89,6 +91,9 @@ class Telegram:
else:
caption = f"*{title}*"
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -100,7 +105,8 @@ class Telegram:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]:
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "",
title: str = "", link: str = None) -> Optional[bool]:
"""
发送媒体列表消息
"""
@@ -127,6 +133,9 @@ class Telegram:
f"类型:{media.type.value}")
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -139,7 +148,7 @@ class Telegram:
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
userid: str = "", title: str = "", link: str = None) -> Optional[bool]:
"""
发送列表消息
"""
@@ -168,6 +177,9 @@ class Telegram:
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:

View File

@@ -126,7 +126,7 @@ class CategoryHelper(metaclass=Singleton):
info_values = [str(info_value).upper()]
if value.find(",") != -1:
values = [str(val).upper() for val in value.split(",")]
values = [str(val).upper() for val in value.split(",") if val]
else:
values = [str(value).upper()]

View File

@@ -1052,7 +1052,8 @@ class TmdbApi:
return []
try:
logger.debug(f"正在发现电影:{kwargs}...")
tmdbinfo = self.discover.discover_movies(kwargs)
params_tuple = tuple(kwargs.items())
tmdbinfo = self.discover.discover_movies(params_tuple)
if tmdbinfo:
for info in tmdbinfo:
info['media_type'] = MediaType.MOVIE
@@ -1071,7 +1072,8 @@ class TmdbApi:
return []
try:
logger.debug(f"正在发现电视剧:{kwargs}...")
tmdbinfo = self.discover.discover_tv_shows(kwargs)
params_tuple = tuple(kwargs.items())
tmdbinfo = self.discover.discover_tv_shows(params_tuple)
if tmdbinfo:
for info in tmdbinfo:
info['media_type'] = MediaType.TV

View File

@@ -14,20 +14,21 @@ class Discover(TMDb):
}
@cached(cache=TTLCache(maxsize=1, ttl=43200))
def discover_movies(self, params):
def discover_movies(self, params_tuple):
"""
Discover movies by different types of data like average rating, number of votes, genres and certifications.
:param params: dict
:param params_tuple: dict
:return:
"""
params = dict(params_tuple)
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):
def discover_tv_shows(self, params_tuple):
"""
Discover TV shows by different types of data like average rating, number of votes, genres,
the network they aired on and air dates.
:param params: dict
:param params_tuple: dict
:return:
"""
return self._request_obj(self._urls["tv"], urlencode(params), key="results", call_cached=False)
return self._request_obj(self._urls["tv"], urlencode(params_tuple), key="results", call_cached=False)

View File

@@ -185,6 +185,7 @@ class TransmissionModule(_ModuleBase):
title=torrent.name,
path=Path(torrent.download_dir) / torrent.name,
hash=torrent.hashString,
size=torrent.total_size,
tags=",".join(torrent.labels or [])
))
elif status == TorrentStatus.TRANSFER:
@@ -230,7 +231,7 @@ class TransmissionModule(_ModuleBase):
return None
return ret_torrents
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
def transfer_completed(self, hashs: str, path: Path = None,
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
"""
转移完成后的处理
@@ -241,7 +242,14 @@ class TransmissionModule(_ModuleBase):
"""
if downloader != "transmission":
return None
self.transmission.set_torrent_tag(ids=hashs, tags=['已整理'])
# 获取原标签
org_tags = self.transmission.get_torrent_tags(ids=hashs)
# 种子打上已整理标签
if org_tags:
tags = org_tags + ['已整理']
else:
tags = ['已整理']
self.transmission.set_torrent_tag(ids=hashs, tags=tags)
# 移动模式删除种子
if settings.TRANSFER_TYPE == "move":
if self.remove_torrents(hashs):

View File

@@ -2,7 +2,7 @@ from typing import Optional, Union, Tuple, List, Dict
import transmission_rpc
from transmission_rpc import Client, Torrent, File
from transmission_rpc.session import SessionStats
from transmission_rpc.session import SessionStats, Session
from app.core.config import settings
from app.log import logger
@@ -130,21 +130,38 @@ class Transmission:
logger.error(f"获取正在下载的种子列表出错:{str(err)}")
return None
def set_torrent_tag(self, ids: str, tags: list) -> bool:
def set_torrent_tag(self, ids: str, tags: list, org_tags: list = None) -> bool:
"""
设置种子标签
设置种子标签注意TR默认会覆盖原有标签如需追加需传入原有标签
"""
if not self.trc:
return False
if not ids or not tags:
return False
try:
self.trc.change_torrent(labels=tags, ids=ids)
self.trc.change_torrent(labels=list(set((org_tags or []) + tags)), ids=ids)
return True
except Exception as err:
logger.error(f"设置种子标签出错:{str(err)}")
return False
def get_torrent_tags(self, ids: str) -> List[str]:
"""
获取所有种子标签
"""
if not self.trc:
return []
try:
torrent = self.trc.get_torrents(ids=ids, arguments=self._trarg)
if torrent:
labels = [str(tag).strip()
for tag in torrent.labels] if hasattr(torrent, "labels") else []
return labels
except Exception as err:
logger.error(f"获取种子标签出错:{str(err)}")
return []
return []
def add_torrent(self, content: Union[str, bytes],
is_paused: bool = False,
download_dir: str = None,
@@ -397,15 +414,15 @@ class Transmission:
logger.error(f"修改tracker出错{str(err)}")
return False
def get_session(self) -> Dict[str, Union[int, bool, str]]:
def get_session(self) -> Optional[Session]:
"""
获取Transmission当前的会话信息和配置设置
:return dict or False
:return dict
"""
if not self.trc:
return False
return None
try:
return self.trc.get_session()
except Exception as err:
logger.error(f"获取session出错{str(err)}")
return False
return None

View File

@@ -103,7 +103,8 @@ class VoceChatModule(_ModuleBase):
:param message: 消息内容
:return: 成功或失败
"""
self.vocechat.send_msg(title=message.title, text=message.text, userid=message.userid)
self.vocechat.send_msg(title=message.title, text=message.text,
userid=message.userid, link=message.link)
@checkMessage(MessageChannel.VoceChat)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -116,7 +117,8 @@ class VoceChatModule(_ModuleBase):
# 先发送标题
self.vocechat.send_msg(title=message.title, userid=message.userid)
# 再发送内容
return self.vocechat.send_medias_msg(title=message.title, medias=medias, userid=message.userid)
return self.vocechat.send_medias_msg(title=message.title, medias=medias,
userid=message.userid, link=message.link)
@checkMessage(MessageChannel.VoceChat)
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
@@ -126,7 +128,8 @@ class VoceChatModule(_ModuleBase):
:param torrents: 种子列表
:return: 成功或失败
"""
return self.vocechat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
return self.vocechat.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, link=message.link)
def register_commands(self, commands: Dict[str, dict]):
pass

View File

@@ -58,12 +58,14 @@ class VoceChat:
if result and result.status_code == 200:
return result.json()
def send_msg(self, title: str, text: str = "", userid: str = None) -> Optional[bool]:
def send_msg(self, title: str, text: str = "",
userid: str = None, link: str = None) -> Optional[bool]:
"""
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
:param title: 消息标题
:param text: 消息内容
:param userid: 消息发送对象的ID为空则发给所有人
:param link: 消息链接
:return: 发送状态,错误信息
"""
if not self._client:
@@ -79,6 +81,9 @@ class VoceChat:
else:
caption = f"**{title}**"
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -90,7 +95,8 @@ class VoceChat:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_medias_msg(self, title: str, medias: List[MediaInfo], userid: str = "") -> Optional[bool]:
def send_medias_msg(self, title: str, medias: List[MediaInfo],
userid: str = "", link: str = None) -> Optional[bool]:
"""
发送列表类消息
"""
@@ -115,6 +121,9 @@ class VoceChat:
f"类型:{media.type.value}")
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:
@@ -127,7 +136,7 @@ class VoceChat:
return False
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
userid: str = "", title: str = "", link: str = None) -> Optional[bool]:
"""
发送列表消息
"""
@@ -155,6 +164,9 @@ class VoceChat:
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
index += 1
if link:
caption = f"{caption}\n[查看详情]({link})"
if userid:
chat_id = userid
else:

View File

@@ -0,0 +1,68 @@
import json
from typing import Union, Tuple
from pywebpush import webpush, WebPushException
from app.core.config import global_vars, settings
from app.log import logger
from app.modules import _ModuleBase, checkMessage
from app.schemas import MessageChannel, Notification
class WebPushModule(_ModuleBase):
def init_module(self) -> None:
pass
@staticmethod
def get_name() -> str:
return "WebPush"
def stop(self):
pass
def test(self) -> Tuple[bool, str]:
"""
测试模块连接性
"""
return True, ""
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "webpush"
@checkMessage(MessageChannel.WebPush)
def post_message(self, message: Notification) -> None:
"""
发送消息
:param message: 消息内容
:return: 成功或失败
"""
if not message.title and not message.text:
logger.warn("标题和内容不能同时为空")
return
try:
if message.title:
caption = message.title
content = message.text
else:
caption = message.text
content = ""
for sub in global_vars.get_subscriptions():
logger.debug(f"{sub} 发送WebPush{caption} {content}")
try:
webpush(
subscription_info=sub,
data=json.dumps({
"title": caption,
"body": content,
"url": message.link or "/?shotcut=message"
}),
vapid_private_key=settings.VAPID.get("privateKey"),
vapid_claims={
"sub": settings.VAPID.get("subject")
},
)
except WebPushException as err:
logger.error(f"WebPush发送失败: {str(err)}")
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")

View File

@@ -141,7 +141,7 @@ class WechatModule(_ModuleBase):
:return: 成功或失败
"""
self.wechat.send_msg(title=message.title, text=message.text,
image=message.image, userid=message.userid)
image=message.image, userid=message.userid, link=message.link)
@checkMessage(MessageChannel.Wechat)
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
@@ -152,7 +152,7 @@ class WechatModule(_ModuleBase):
:return: 成功或失败
"""
# 先发送标题
self.wechat.send_msg(title=message.title, userid=message.userid)
self.wechat.send_msg(title=message.title, userid=message.userid, link=message.link)
# 再发送内容
return self.wechat.send_medias_msg(medias=medias, userid=message.userid)
@@ -164,7 +164,8 @@ class WechatModule(_ModuleBase):
:param torrents: 种子列表
:return: 成功或失败
"""
return self.wechat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
return self.wechat.send_torrents_msg(title=message.title, torrents=torrents,
userid=message.userid, link=message.link)
def register_commands(self, commands: Dict[str, dict]):
"""

View File

@@ -88,12 +88,14 @@ class WeChat:
return None
return self._access_token
def __send_message(self, title: str, text: str = None, userid: str = None) -> Optional[bool]:
def __send_message(self, title: str, text: str = None,
userid: str = None, link: str = None) -> Optional[bool]:
"""
发送文本消息
:param title: 消息标题
:param text: 消息内容
:param userid: 消息发送对象的ID为空则发给所有人
:param link: 跳转链接
:return: 发送状态,错误信息
"""
message_url = self._send_msg_url % self.__get_access_token()
@@ -102,8 +104,12 @@ class WeChat:
else:
conent = title
if link:
conent = f"{conent}\n点击查看:{link}"
if not userid:
userid = "@all"
req_json = {
"touser": userid,
"msgtype": "text",
@@ -117,13 +123,15 @@ class WeChat:
}
return self.__post_request(message_url, req_json)
def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None) -> Optional[bool]:
def __send_image_message(self, title: str, text: str, image_url: str,
userid: str = None, link: str = None) -> Optional[bool]:
"""
发送图文消息
:param title: 消息标题
:param text: 消息内容
:param image_url: 图片地址
:param userid: 消息发送对象的ID为空则发给所有人
:param link: 跳转链接
:return: 发送状态,错误信息
"""
message_url = self._send_msg_url % self.__get_access_token()
@@ -141,20 +149,22 @@ class WeChat:
"title": title,
"description": text,
"picurl": image_url,
"url": ''
"url": link
}
]
}
}
return self.__post_request(message_url, req_json)
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = None) -> Optional[bool]:
def send_msg(self, title: str, text: str = "", image: str = "",
userid: str = None, link: str = None) -> Optional[bool]:
"""
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
:param title: 消息标题
:param text: 消息内容
:param image: 图片地址
:param userid: 消息发送对象的ID为空则发给所有人
:param link: 跳转链接
:return: 发送状态,错误信息
"""
if not self.__get_access_token():
@@ -162,9 +172,9 @@ class WeChat:
return None
if image:
ret_code = self.__send_image_message(title, text, image, userid)
ret_code = self.__send_image_message(title=title, text=text, image_url=image, userid=userid, link=link)
else:
ret_code = self.__send_message(title, text, userid)
ret_code = self.__send_message(title=title, text=text, userid=userid, link=link)
return ret_code
@@ -205,7 +215,7 @@ class WeChat:
return self.__post_request(message_url, req_json)
def send_torrents_msg(self, torrents: List[Context],
userid: str = "", title: str = "") -> Optional[bool]:
userid: str = "", title: str = "", link: str = None) -> Optional[bool]:
"""
发送列表消息
"""
@@ -215,7 +225,7 @@ class WeChat:
# 先发送标题
if title:
self.__send_message(title=title, userid=userid)
self.__send_message(title=title, userid=userid, link=link)
# 发送列表
message_url = self._send_msg_url % self.__get_access_token()

View File

@@ -229,6 +229,8 @@ class _PluginBase(metaclass=ABCMeta):
"""
发送消息
"""
if not link:
link = settings.MP_DOMAIN(f"#/plugins?tab=installed&id={self.__class__.__name__}")
self.chain.post_message(Notification(
channel=channel, mtype=mtype, title=title, text=text,
image=image, link=link, userid=userid

View File

@@ -18,10 +18,12 @@ from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.event import EventManager
from app.core.plugin import PluginManager
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas import Notification, NotificationType
from app.schemas.types import EventType
from app.utils.singleton import Singleton
from app.utils.timer import TimerUtils
@@ -88,7 +90,8 @@ class Scheduler(metaclass=Singleton):
Notification(
mtype=NotificationType.Manual,
title="MoviePilot用户认证成功",
text=f"使用站点:{msg}"
text=f"使用站点:{msg}",
link=settings.MP_DOMAIN('#/site')
)
)
else:
@@ -370,6 +373,16 @@ class Scheduler(metaclass=Singleton):
SchedulerChain().messagehelper.put(title=f"{job_name} 执行失败",
message=str(e),
role="system")
EventManager().send_event(
EventType.SystemError,
{
"type": "scheduler",
"scheduler_id": job_id,
"scheduler_name": job_name,
"error": str(e),
"traceback": traceback.format_exc()
}
)
# 运行结束
with self._lock:
try:

View File

@@ -18,3 +18,5 @@ class FileItem(BaseModel):
size: Optional[int] = None
# 修改时间
modify_time: Optional[float] = None
# 子节点
children: Optional[list] = []

View File

@@ -84,3 +84,24 @@ class NotificationSwitch(BaseModel):
synologychat: Optional[bool] = False
# VoceChat开关
vocechat: Optional[bool] = False
# WebPush开关
webpush: Optional[bool] = False
class Subscription(BaseModel):
"""
客户端消息订阅
"""
endpoint: Optional[str]
keys: Optional[dict] = {}
class SubscriptionMessage(BaseModel):
"""
客户端订阅消息体
"""
title: Optional[str]
body: Optional[str]
icon: Optional[str]
url: Optional[str]
data: Optional[dict] = {}

View File

@@ -12,6 +12,7 @@ class TransferTorrent(BaseModel):
path: Optional[Path] = None
hash: Optional[str] = None
tags: Optional[str] = None
size: Optional[int] = 0
userid: Optional[str] = None

View File

@@ -32,6 +32,8 @@ class EventType(Enum):
HistoryDeleted = "history.deleted"
# 删除下载源文件
DownloadFileDeleted = "downloadfile.deleted"
# 删除下载任务
DownloadDeleted = "download.deleted"
# 收到用户外来消息
UserMessage = "user.message"
# 收到Webhook消息
@@ -46,6 +48,8 @@ class EventType(Enum):
SubscribeAdded = "subscribe.added"
# 订阅已完成
SubscribeComplete = "subscribe.complete"
# 系统错误
SystemError = "system.error"
# 系统配置Key字典
@@ -120,6 +124,8 @@ class NotificationType(Enum):
MediaServer = "媒体服务器通知"
# 处理失败需要人工干预
Manual = "手动处理通知"
# 插件消息
Plugin = "插件消息"
class MessageChannel(Enum):
@@ -132,6 +138,7 @@ class MessageChannel(Enum):
SynologyChat = "SynologyChat"
VoceChat = "VoceChat"
Web = "Web"
WebPush = "WebPush"
# 用户配置Key字典

View File

@@ -35,20 +35,24 @@ class ObjectUtils:
"""
检查函数是否已实现
"""
source = inspect.getsource(func)
in_comment = False
for line in source.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith('"""') or line.startswith("'''"):
in_comment = not in_comment
continue
if not in_comment and not (line.startswith('#')
or line == "pass"
or line.startswith('@')
or line.startswith('def ')):
return True
try:
source = inspect.getsource(func)
in_comment = False
for line in source.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith('"""') or line.startswith("'''"):
in_comment = not in_comment
continue
if not in_comment and not (line.startswith('#')
or line == "pass"
or line.startswith('@')
or line.startswith('def ')):
return True
except Exception as err:
print(str(err))
return func.__code__.co_code not in [b'd\x01S\x00', b'\x97\x00d\x00S\x00']
return False
@staticmethod

View File

@@ -604,19 +604,27 @@ class StringUtils:
def get_domain_address(address: str, prefix: bool = True) -> Tuple[Optional[str], Optional[int]]:
"""
从地址中获取域名和端口号
:param address: 地址
:param prefix返回域名是否要包含协议前缀
"""
if not address:
return None, None
# 去掉末尾的/
address = address.rstrip("/")
if prefix and not address.startswith("http"):
# 如果需要包含协议前缀,但地址不包含协议前缀,则添加
address = "http://" + address
elif not prefix and address.startswith("http"):
# 如果不需要包含协议前缀,但地址包含协议前缀,则去掉
address = address.split("://")[-1]
# 拆分域名和端口号
parts = address.split(":")
if len(parts) > 3:
# 处理不希望包含多个冒号的情况(除了协议后的冒号)
return None, None
domain = ":".join(parts[:-1])
if domain.endswith("/"):
domain = domain[:-1]
# 检查是否包含端口号
# 不含端口地址
domain = ":".join(parts[:-1]).rstrip('/')
# 端口号
try:
port = int(parts[-1])
except ValueError:

View File

@@ -118,12 +118,13 @@ class SystemUtils:
硬链接
"""
try:
# link到当前目录并改名
tmp_path = src.parent / (dest.name + ".mp")
# 准备目标路径,增加后缀 .mp
tmp_path = dest.with_suffix(dest.suffix + ".mp")
# 检查目标路径是否已存在如果存在则先unlink
if tmp_path.exists():
tmp_path.unlink()
tmp_path.hardlink_to(src)
# 移动到目标目录
# 硬链接完成,移除 .mp 后缀
shutil.move(tmp_path, dest)
return 0, ""
except Exception as err:
@@ -466,6 +467,30 @@ class SystemUtils:
print(str(err))
return False, f"重启时发生错误:{str(err)}"
@staticmethod
def is_hardlink(src: Path, dest: Path) -> bool:
"""判断是否为硬链接"""
try:
if not src.exists() or not dest.exists():
return False
if src.is_file():
# 如果是文件,直接比较文件
return src.samefile(dest)
else:
for src_file in src.glob("**/*"):
if src_file.is_dir():
continue
# 计算目标文件路径
relative_path = src_file.relative_to(src)
target_file = dest.joinpath(relative_path)
# 检查是否是硬链接
if not target_file.exists() or not src_file.samefile(target_file):
return False
return True
except (PermissionError, FileNotFoundError, ValueError, OSError) as e:
print(f"Error occurred: {e}")
return False
@staticmethod
def is_same_disk(src: Path, dest: Path) -> bool:
"""

View File

@@ -41,7 +41,7 @@ http {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
location ~* \.(png|jpg|jpeg|gif|ico|svg)$ {
# 静态资源
expires 1y;
add_header Cache-Control "public, immutable";

View File

@@ -56,4 +56,5 @@ cachetools~=5.3.1
fast-bencode~=1.1.3
pystray~=0.19.5
pyotp~=2.9.0
Pinyin2Hanzi~=0.1.1
Pinyin2Hanzi~=0.1.1
pywebpush~=2.0.0

9
update
View File

@@ -21,14 +21,14 @@ download_and_unzip() {
install_backend_and_download_resources() {
# 清理临时目录,上次安装失败可能有残留
rm -rf /tmp/*
if download_and_unzip "https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
if download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
echo "后端程序下载成功"
pip install ${PIP_OPTIONS} --upgrade pip
if pip install ${PIP_OPTIONS} -r /tmp/App/requirements.txt; then
echo "安装依赖成功"
frontend_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
if [[ "${frontend_version}" == *v* ]]; then
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
if download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
echo "前端程序下载成功"
# 提前备份插件目录
rm -rf /plugins
@@ -49,7 +49,7 @@ install_backend_and_download_resources() {
rm -rf /tmp/*
echo "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}"s
echo "开始更新插件..."
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"; then
if download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"; then
echo "插件下载成功"
# 恢复插件目录
cp -a /plugins/* /app/app/plugins/
@@ -61,7 +61,7 @@ install_backend_and_download_resources() {
rm -rf /tmp/*
echo "插件更新成功"
echo "开始更新资源包..."
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"; then
if download_and_unzip "${GITHUB_PROXY}https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"; then
echo "资源包下载成功"
# 资源包
cp -a /tmp/Resources/resources/* /app/app/helper/
@@ -92,6 +92,7 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
if [ -n "${PROXY_HOST}" ]; then
CURL_OPTIONS="-sL -x ${PROXY_HOST}"
PIP_OPTIONS="--proxy=${PROXY_HOST}"
GITHUB_PROXY=""
echo "使用代理更新程序"
else
CURL_OPTIONS="-sL"

View File

@@ -1 +1 @@
APP_VERSION = 'v1.9.2-beta'
APP_VERSION = 'v1.9.4-1'