Compare commits

...

92 Commits

Author SHA1 Message Date
jxxghp
c473594663 v1.6.6
- IYUU Api域名地址更改为`api.bolahg.cn`
- 消息交互支持洗版订阅和强制重新下载
- 历史记录删除源文件时自动删除下载器中的下载任务
- 文件管理支持英文标题占位符`en_title`
- 更新了多个官方插件,其中目录监控插件修复了蓝光原盘目录整理重复通知的问题

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

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

166
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -140,12 +140,13 @@ def subscribe_mediaid(
if not doubanid:
return Subscribe()
result = Subscribe.get_by_doubanid(db, doubanid)
if not result and title:
meta = MetaInfo(title)
if season:
meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
# 豆瓣已订阅如果 id 搜索无结果使用标题搜索
# 会造成同名结果也会被返回
if not result and title:
meta = MetaInfo(title)
if season:
meta.begin_season = season
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
if result and result.sites:
result.sites = json.loads(result.sites)

View File

@@ -4,12 +4,14 @@ from datetime import datetime
from typing import Union, Any
import tailer
from dotenv import set_key
from fastapi import APIRouter, HTTPException, Depends, Response
from fastapi.responses import StreamingResponse
from app import schemas
from app.chain.search import SearchChain
from app.core.config import settings
from app.core.module import ModuleManager
from app.core.security import verify_token
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.message import MessageHelper
@@ -46,7 +48,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
查询系统环境变量,包括当前版本号
"""
info = settings.dict(
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
)
info.update({
"VERSION": APP_VERSION,
@@ -57,6 +59,27 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
data=info)
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
def set_env_setting(env: dict,
_: schemas.TokenPayload = Depends(verify_token)):
"""
更新系统环境变量
"""
for k, v in env.items():
if k == "undefined":
continue
if hasattr(settings, k):
if v == "None":
v = None
setattr(settings, k, v)
if v is None:
v = ''
else:
v = str(v)
set_key(settings.CONFIG_PATH / "app.env", k, v)
return schemas.Response(success=True)
@router.get("/progress/{process_type}", summary="实时进度")
def get_progress(process_type: str, token: str):
"""
@@ -85,18 +108,32 @@ def get_setting(key: str,
"""
查询系统设置
"""
if hasattr(settings, key):
value = getattr(settings, key)
else:
value = SystemConfigOper().get(key)
return schemas.Response(success=True, data={
"value": SystemConfigOper().get(key)
"value": value
})
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
def set_setting(key: str, value: Union[list, dict, str, int] = None,
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
_: schemas.TokenPayload = Depends(verify_token)):
"""
更新系统设置
"""
SystemConfigOper().set(key, value)
if hasattr(settings, key):
if value == "None":
value = None
setattr(settings, key, value)
if value is None:
value = ''
else:
value = str(value)
set_key(settings.CONFIG_PATH / "app.env", key, value)
else:
SystemConfigOper().set(key, value)
return schemas.Response(success=True)
@@ -123,9 +160,11 @@ def get_message(token: str):
@router.get("/logging", summary="实时日志")
def get_logging(token: str):
def get_logging(token: str, length: int = 50):
"""
实时获取系统日志返回格式为SSE
实时获取系统日志
length = -1 时, 返回text/plain
否则 返回格式SSE
"""
if not token or not verify_token(token):
raise HTTPException(
@@ -133,18 +172,27 @@ def get_logging(token: str):
detail="认证失败!",
)
log_path = settings.LOG_PATH / 'moviepilot.log'
def log_generator():
log_path = settings.LOG_PATH / 'moviepilot.log'
# 读取文件末尾50行不使用tailer模块
with open(log_path, 'r', encoding='utf-8') as f:
for line in f.readlines()[-50:]:
for line in f.readlines()[-max(length, 50):]:
yield 'data: %s\n\n' % line
while True:
for text in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (text or '')
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (t or '')
time.sleep(1)
return StreamingResponse(log_generator(), media_type="text/event-stream")
# 根据length参数返回不同的响应
if length == -1:
# 返回全部日志作为文本响应
with open(log_path, 'r', encoding='utf-8') as file:
text = file.read()
return Response(content=text, media_type="text/plain")
else:
# 返回SSE流响应
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/nettest", summary="测试网络连通性")
@@ -231,6 +279,16 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
return schemas.Response(success=ret, message=msg)
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
"""
重新加载模块
"""
ModuleManager().stop()
ModuleManager().load_modules()
return schemas.Response(success=True)
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
def execute_command(jobid: str,
_: schemas.TokenPayload = Depends(verify_token)):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import pickle
import re
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Dict, Tuple
@@ -74,7 +75,7 @@ class SearchChain(ChainBase):
try:
return pickle.loads(results)
except Exception as e:
print(str(e))
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
return []
def process(self, mediainfo: MediaInfo,
@@ -122,10 +123,8 @@ class SearchChain(ChainBase):
# 搜索关键词
if keyword:
keywords = [keyword]
elif mediainfo.original_title and mediainfo.title != mediainfo.original_title:
keywords = [mediainfo.title, mediainfo.original_title]
else:
keywords = [mediainfo.title]
keywords = list({mediainfo.title, mediainfo.original_title, mediainfo.en_title} - {None})
# 执行搜索
torrents: List[TorrentInfo] = self.__search_all_sites(
mediainfo=mediainfo,
@@ -167,6 +166,7 @@ class SearchChain(ChainBase):
if mediainfo:
self.progress.start(ProgressKey.Search)
logger.info(f'开始匹配,总 {_total} 个资源 ...')
# 英文标题应该在别名/原标题中,不需要再匹配
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
for torrent in torrents:
@@ -380,7 +380,7 @@ class SearchChain(ChainBase):
elif len(size_range) == 2:
return float(size_range[0]), float(size_range[1])
except Exception as e:
print(str(e))
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
return 0, 0
def __filter_torrent(t: TorrentInfo) -> bool:
@@ -425,7 +425,7 @@ class SearchChain(ChainBase):
size = movie_size
# 大小范围
begin_size, end_size = __get_size_range(size)
if begin_size is not None and end_size is not None:
if begin_size or end_size:
meta = MetaInfo(title=t.title, subtitle=t.description)
# 集数
if mediainfo.type == MediaType.TV:

View File

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

View File

@@ -71,11 +71,11 @@ class SubscribeChain(ChainBase):
if tmdbinfo:
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
else:
# 识别TMDB信息
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
# 识别TMDB信息,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, cache=False)
else:
# 豆瓣识别模式
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid)
# 豆瓣识别模式,不使用缓存
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
if mediainfo:
# 豆瓣标题处理
meta = MetaInfo(mediainfo.title)
@@ -96,7 +96,8 @@ class SubscribeChain(ChainBase):
# 补充媒体信息
mediainfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
doubanid=mediainfo.douban_id,
cache=False)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
return None, "媒体信息识别失败"
@@ -197,7 +198,8 @@ class SubscribeChain(ChainBase):
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid)
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -523,7 +525,8 @@ class SubscribeChain(ChainBase):
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid)
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
@@ -701,7 +704,7 @@ class SubscribeChain(ChainBase):
return
# 遍历订阅
for subscribe in subscribes:
logger.info(f'开始检查订阅{subscribe.name} ...')
logger.info(f'开始更新订阅元数据{subscribe.name} ...')
# 生成元数据
meta = MetaInfo(subscribe.name)
meta.year = subscribe.year
@@ -709,14 +712,16 @@ class SubscribeChain(ChainBase):
meta.type = MediaType(subscribe.type)
# 识别媒体信息
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
tmdbid=subscribe.tmdbid,
doubanid=subscribe.doubanid,
cache=False)
if not mediainfo:
logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue
# 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or []
if len(episodes) > (subscribe.total_episode or 0):
if len(episodes):
total_episode = len(episodes)
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
logger.info(
@@ -737,7 +742,7 @@ class SubscribeChain(ChainBase):
"total_episode": total_episode,
"lack_episode": lack_episode
})
logger.info(f'订阅 {subscribe.name} 更新完成')
logger.info(f'{subscribe.name} 订阅元数据更新完成')
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
"""

View File

@@ -1,4 +1,5 @@
import re
import traceback
from typing import Dict, List, Union
from cachetools import cached, TTLCache
@@ -98,7 +99,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址')
return []
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False)
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
if rss_items is None:
# rss过期尝试保留原配置生成新的rss
self.__renew_rss_url(domain=domain, site=site)
@@ -246,5 +247,5 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
self.post_message(
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
except Exception as e:
print(str(e))
logger.error(f"站点 {domain} RSS链接自动获取失败{str(e)} - {traceback.format_exc()}")
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))

View File

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

View File

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

View File

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

View File

@@ -135,6 +135,8 @@ class MediaInfo:
type: MediaType = None
# 媒体标题
title: str = None
# 英文标题
en_title: str = None
# 年份
year: str = None
# 季
@@ -368,6 +370,8 @@ class MediaInfo:
self.genre_ids = info.get('genre_ids') or []
# 原语种
self.original_language = info.get('original_language')
# 英文标题
self.en_title = info.get('en_title')
if self.type == MediaType.MOVIE:
# 标题
self.title = info.get('title')
@@ -439,6 +443,9 @@ class MediaInfo:
# 标题
if not self.title:
self.title = info.get("title")
# 英文标题,暂时不支持
if not self.en_title:
self.en_title = info.get('original_title')
# 原语种标题
if not self.original_title:
self.original_title = info.get("original_title")

View File

@@ -37,9 +37,13 @@ class EventManager(metaclass=Singleton):
def check(self, etype: EventType):
"""
检查事件是否存在响应
检查事件是否存在响应,去除掉被禁用的事件响应
"""
return etype.value in self._handlers
if etype.value not in self._handlers:
return False
handlers = self._handlers.get(etype.value)
return any([handler for handler in handlers.values()
if handler.__qualname__.split(".")[0] not in self._disabled_handlers])
def add_event_listener(self, etype: EventType, handler: type):
"""

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,9 @@ class PluginManager(metaclass=Singleton):
# 存储运行实例
self._running_plugins[plugin_id] = plugin_obj
logger.info(f"Plugin Loaded{plugin_id}")
# 设置事件注册状态可用
# 启用的插件才设置事件注册状态可用
if not plugin_obj.get_state():
continue
eventmanager.enable_events_hander(plugin_id)
except Exception as err:
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
@@ -85,6 +87,12 @@ class PluginManager(metaclass=Singleton):
if not self._running_plugins.get(plugin_id):
return
self._running_plugins[plugin_id].init_plugin(conf)
if self._running_plugins[plugin_id].get_state():
# 设置启用的插件事件注册状态可用
eventmanager.enable_events_hander(plugin_id)
else:
# 设置事件状态为不可用
eventmanager.disable_events_hander(plugin_id)
def stop(self):
"""
@@ -137,7 +145,11 @@ class PluginManager(metaclass=Singleton):
"""
if not self._plugins.get(pid):
return {}
return self.systemconfig.get(self._config_key % pid) or {}
conf = self.systemconfig.get(self._config_key % pid)
if conf:
# 去掉空Key
return {k: v for k, v in conf.items() if k}
return {}
def save_plugin_config(self, pid: str, conf: dict) -> bool:
"""

View File

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

View File

@@ -10,12 +10,12 @@ class DownloadHistoryOper(DbOper):
下载历史管理
"""
def get_by_path(self, path: Path) -> DownloadHistory:
def get_by_path(self, path: str) -> DownloadHistory:
"""
按路径查询下载记录
:param path: 数据key
"""
return DownloadHistory.get_by_path(self._db, str(path))
return DownloadHistory.get_by_path(self._db, path)
def get_by_hash(self, download_hash: str) -> DownloadHistory:
"""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -35,12 +35,14 @@ class DoubanModule(_ModuleBase):
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
doubanid: str = None,
cache: bool = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与doubanid配套
:param doubanid: 豆瓣ID
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
if settings.RECOGNIZE_SOURCE != "douban":
@@ -48,11 +50,17 @@ class DoubanModule(_ModuleBase):
if not meta:
cache_info = {}
elif not meta.name:
logger.error("识别媒体信息时未提供元数据名称")
return None
else:
if mtype:
meta.type = mtype
if doubanid:
meta.doubanid = doubanid
# 读取缓存
cache_info = self.cache.get(meta)
if not cache_info:
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存
if doubanid:
# 直接查询详情
@@ -80,7 +88,7 @@ class DoubanModule(_ModuleBase):
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
return None
# 保存到缓存
if meta:
if meta and cache:
self.cache.update(meta, info)
else:
# 使用缓存信息

View File

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

View File

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

View File

@@ -496,7 +496,7 @@ class FileTransferModule(_ModuleBase):
case 'size':
# 存在时大覆盖小
if new_file.stat().st_size < in_path.stat().st_size:
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
overflag = True
else:
return TransferInfo(success=False,
@@ -513,10 +513,13 @@ class FileTransferModule(_ModuleBase):
fail_list=[str(in_path)])
case 'latest':
# 仅保留最新版本
self.delete_all_version_files(new_file)
logger.info(f"仅保留最新版本,将覆盖:{new_file}")
overflag = True
case _:
pass
else:
if settings.OVERWRITE_MODE == 'latest':
# 文件不存在,但仅保留最新版本
logger.info(f"转移覆盖模式:{settings.OVERWRITE_MODE},仅保留最新版本")
self.delete_all_version_files(new_file)
# 原文件大小
file_size = in_path.stat().st_size
# 转移文件
@@ -552,6 +555,20 @@ class FileTransferModule(_ModuleBase):
:param file_ext: 文件扩展名
:param episodes_info: 当前季的全部集信息
"""
def __convert_invalid_characters(filename: str):
if not filename:
return filename
invalid_characters = r'\/:*?"<>|'
# 创建半角到全角字符的转换表
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
# 将不支持的字符替换为对应的全角字符
for char in invalid_characters:
filename = filename.replace(char, char.translate(translation_table))
return filename
# 获取集标题
episode_title = None
if meta.begin_episode and episodes_info:
@@ -562,9 +579,11 @@ class FileTransferModule(_ModuleBase):
return {
# 标题
"title": mediainfo.title,
"title": __convert_invalid_characters(mediainfo.title),
# 英文标题
"en_title": __convert_invalid_characters(mediainfo.en_title),
# 原语种标题
"original_title": mediainfo.original_title,
"original_title": __convert_invalid_characters(mediainfo.original_title),
# 原文件名
"original_name": f"{meta.org_string}{file_ext}",
# 识别名称(优先使用中文)

View File

@@ -1,6 +1,7 @@
import copy
import datetime
import re
import traceback
from typing import List
from urllib.parse import quote, urlencode
@@ -56,12 +57,14 @@ class TorrentSpider:
fields: dict = {}
# 页码
page: int = 0
# 搜索条数
# 搜索条数, 默认: 100条
result_num: int = 100
# 单个种子信息
torrents_info: dict = {}
# 种子列表
torrents_info_array: list = []
# 搜索超时, 默认: 30秒
_timeout = 30
def __init__(self,
indexer: CommentedMap,
@@ -91,6 +94,8 @@ class TorrentSpider:
self.fields = indexer.get('torrents').get('fields')
self.render = indexer.get('render')
self.domain = indexer.get('domain')
self.result_num = int(indexer.get('result_num') or 100)
self._timeout = int(indexer.get('timeout') or 30)
self.page = page
if self.domain and not str(self.domain).endswith("/"):
self.domain = self.domain + "/"
@@ -152,7 +157,7 @@ class TorrentSpider:
search_mode = "0"
# 搜索URL
indexer_params = self.search.get("params") or {}
indexer_params = self.search.get("params", {}).copy()
if indexer_params:
search_area = indexer_params.get('search_area')
# search_area非0表示支持imdbid搜索
@@ -233,14 +238,15 @@ class TorrentSpider:
url=searchurl,
cookies=self.cookie,
ua=self.ua,
proxies=self.proxy_server
proxies=self.proxy_server,
timeout=self._timeout
)
else:
# requests请求
ret = RequestUtils(
ua=self.ua,
cookies=self.cookie,
timeout=30,
timeout=self._timeout,
referer=self.referer,
proxies=self.proxies
).get_res(searchurl, allow_redirects=True)
@@ -608,8 +614,8 @@ class TorrentSpider:
for filter_item in filters:
if not text:
break
method_name = filter_item.get("name")
try:
method_name = filter_item.get("name")
args = filter_item.get("args")
if method_name == "re_search" and isinstance(args, list):
text = re.search(r"%s" % args[0], text).group(args[-1])
@@ -624,7 +630,7 @@ class TorrentSpider:
elif method_name == "appendleft":
text = f"{args}{text}"
except Exception as err:
print(str(err))
logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}')
return text.strip()
@staticmethod

View File

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

View File

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

View File

@@ -206,7 +206,7 @@ class QbittorrentModule(_ModuleBase):
season_episode=meta.season_episode,
progress=torrent.get('progress') * 100,
size=torrent.get('total_size'),
state="paused" if torrent.get('state') == "paused" else "downloading",
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
left_time=StringUtils.str_secends(
@@ -236,13 +236,14 @@ class QbittorrentModule(_ModuleBase):
logger.warn(f"删除残留文件夹:{path}")
shutil.rmtree(path, ignore_errors=True)
def remove_torrents(self, hashs: Union[str, list]) -> bool:
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
"""
删除下载器种子
:param hashs: 种子Hash
:param delete_file: 是否删除文件
:return: bool
"""
return self.qbittorrent.delete_torrents(delete_file=True, ids=hashs)
return self.qbittorrent.delete_torrents(delete_file=delete_file, ids=hashs)
def start_torrents(self, hashs: Union[list, str]) -> bool:
"""

View File

@@ -8,11 +8,10 @@ from qbittorrentapi.transfer import TransferInfoDictionary
from app.core.config import settings
from app.log import logger
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class Qbittorrent(metaclass=Singleton):
class Qbittorrent:
_host: str = None
_port: int = None
_username: str = None

View File

@@ -9,13 +9,12 @@ from app.core.context import MediaInfo, Context
from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
lock = Lock()
class SynologyChat(metaclass=Singleton):
class SynologyChat:
def __init__(self):
self._req = RequestUtils(content_type="application/x-www-form-urlencoded")
self._webhook_url = settings.SYNOLOGYCHAT_WEBHOOK

View File

@@ -14,13 +14,12 @@ from app.core.metainfo import MetaInfo
from app.log import logger
from app.utils.common import retry
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
apihelper.proxy = settings.PROXY
class Telegram(metaclass=Singleton):
class Telegram:
_ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}"
_event = Event()
_bot: telebot.TeleBot = None

View File

@@ -44,12 +44,14 @@ class TheMovieDbModule(_ModuleBase):
def recognize_media(self, meta: MetaBase = None,
mtype: MediaType = None,
tmdbid: int = None,
cache: bool = True,
**kwargs) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param mtype: 识别的媒体类型与tmdbid配套
:param tmdbid: tmdbid
:param cache: 是否使用缓存
:return: 识别的媒体信息,包括剧集信息
"""
if settings.RECOGNIZE_SOURCE != "themoviedb":
@@ -57,11 +59,17 @@ class TheMovieDbModule(_ModuleBase):
if not meta:
cache_info = {}
elif not meta.name:
logger.warn("识别媒体信息时未提供元数据名称")
return None
else:
if mtype:
meta.type = mtype
if tmdbid:
meta.tmdbid = tmdbid
# 读取缓存
cache_info = self.cache.get(meta)
if not cache_info:
if not cache_info or not cache:
# 缓存没有或者强制不使用缓存
if tmdbid:
# 直接查询详情
@@ -111,7 +119,7 @@ class TheMovieDbModule(_ModuleBase):
logger.error("识别媒体信息时未提供元数据或tmdbid")
return None
# 保存到缓存
if meta:
if meta and cache:
self.cache.update(meta, info)
else:
# 使用缓存信息

View File

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

View File

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

View File

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

View File

@@ -220,13 +220,14 @@ class TransmissionModule(_ModuleBase):
logger.warn(f"删除残留文件夹:{path}")
shutil.rmtree(path, ignore_errors=True)
def remove_torrents(self, hashs: Union[str, list]) -> bool:
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
"""
删除下载器种子
:param hashs: 种子Hash
:param delete_file: 是否删除文件
:return: bool
"""
return self.transmission.delete_torrents(delete_file=True, ids=hashs)
return self.transmission.delete_torrents(delete_file=delete_file, ids=hashs)
def start_torrents(self, hashs: Union[list, str]) -> bool:
"""

View File

@@ -6,11 +6,10 @@ from transmission_rpc.session import SessionStats
from app.core.config import settings
from app.log import logger
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class Transmission(metaclass=Singleton):
class Transmission:
_host: str = None
_port: int = None
_username: str = None

View File

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

View File

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

View File

@@ -9,8 +9,8 @@ from apscheduler.schedulers.background import BackgroundScheduler
from app import schemas
from app.chain import ChainBase
from app.chain.cookiecloud import CookieCloudChain
from app.chain.mediaserver import MediaServerChain
from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain
@@ -56,7 +56,7 @@ class Scheduler(metaclass=Singleton):
# 各服务的运行状态
self._jobs = {
"cookiecloud": {
"func": CookieCloudChain().process,
"func": SiteChain().sync_cookies,
"running": False,
},
"mediaserver_sync": {
@@ -93,13 +93,14 @@ class Scheduler(metaclass=Singleton):
return
# CookieCloud定时同步
if settings.COOKIECLOUD_INTERVAL:
if settings.COOKIECLOUD_INTERVAL \
and str(settings.COOKIECLOUD_INTERVAL).isdigit():
self._scheduler.add_job(
self.start,
"interval",
id="cookiecloud",
name="同步CookieCloud站点",
minutes=settings.COOKIECLOUD_INTERVAL,
minutes=int(settings.COOKIECLOUD_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
kwargs={
'job_id': 'cookiecloud'
@@ -107,13 +108,14 @@ class Scheduler(metaclass=Singleton):
)
# 媒体服务器同步
if settings.MEDIASERVER_SYNC_INTERVAL:
if settings.MEDIASERVER_SYNC_INTERVAL \
and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit():
self._scheduler.add_job(
self.start,
"interval",
id="mediaserver_sync",
name="同步媒体服务器",
hours=settings.MEDIASERVER_SYNC_INTERVAL,
hours=int(settings.MEDIASERVER_SYNC_INTERVAL),
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
kwargs={
'job_id': 'mediaserver_sync'
@@ -173,16 +175,17 @@ class Scheduler(metaclass=Singleton):
})
else:
# RSS订阅模式
if not settings.SUBSCRIBE_RSS_INTERVAL:
if not settings.SUBSCRIBE_RSS_INTERVAL \
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit():
settings.SUBSCRIBE_RSS_INTERVAL = 30
elif settings.SUBSCRIBE_RSS_INTERVAL < 5:
elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:
settings.SUBSCRIBE_RSS_INTERVAL = 5
self._scheduler.add_job(
self.start,
"interval",
id="subscribe_refresh",
name="RSS订阅刷新",
minutes=settings.SUBSCRIBE_RSS_INTERVAL,
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
kwargs={
'job_id': 'subscribe_refresh'
}

View File

@@ -67,6 +67,8 @@ class MediaInfo(BaseModel):
type: Optional[str] = None
# 媒体标题
title: Optional[str] = None
# 英文标题
en_title: Optional[str] = None
# 年份
year: Optional[str] = None
# 标题(年份)

View File

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

View File

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

View File

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

View File

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

88
update
View File

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

View File

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