mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-11 09:59:51 +08:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a67ea052 | ||
|
|
a4df2f5213 | ||
|
|
4f89780a0f | ||
|
|
26d6201b30 | ||
|
|
c9a9ff2692 | ||
|
|
0be49953b4 | ||
|
|
0de952f090 | ||
|
|
2b570bf48f | ||
|
|
9476017af5 | ||
|
|
54f808485e | ||
|
|
fa5c82899b | ||
|
|
4a57071809 | ||
|
|
4631db9a45 | ||
|
|
0f09da55b0 | ||
|
|
b14b41c2c1 | ||
|
|
cf05ae20c5 | ||
|
|
897758d829 | ||
|
|
85a77a66dd | ||
|
|
c450dfc0fa | ||
|
|
3d782a7475 | ||
|
|
4734851213 | ||
|
|
9c8635002d | ||
|
|
4cd3cb2b60 | ||
|
|
fa890ca29c | ||
|
|
bbf1ec4c50 | ||
|
|
523d458489 | ||
|
|
45ec668875 | ||
|
|
60122644b8 | ||
|
|
07a77e0001 | ||
|
|
d112f49a69 | ||
|
|
8cb061ff75 | ||
|
|
01e08c8e69 | ||
|
|
3549b38ee8 | ||
|
|
f5fb888c85 | ||
|
|
8bcb6a7cb6 | ||
|
|
ac81dd943c | ||
|
|
663d282b5e | ||
|
|
c7b389dd9b | ||
|
|
bad37a1846 | ||
|
|
9c09981583 | ||
|
|
2d8e66cbe2 | ||
|
|
db28986d22 | ||
|
|
727bed46b7 | ||
|
|
8e0df90177 | ||
|
|
34bbb86c16 | ||
|
|
0403f1f48c | ||
|
|
1db452e268 | ||
|
|
81ca11650d | ||
|
|
2e4671fdbc | ||
|
|
da80ad33d9 | ||
|
|
a6f28569ab | ||
|
|
5dd36e95e0 | ||
|
|
1eaeea62db | ||
|
|
4282c5dfc2 | ||
|
|
2e661f8759 | ||
|
|
31ca41828e | ||
|
|
c9ebe76eb1 | ||
|
|
71ac12ab7a | ||
|
|
81c0e15a1c | ||
|
|
2bde4923f9 | ||
|
|
22fb6305cf | ||
|
|
4bb5772e10 | ||
|
|
549658e871 | ||
|
|
80f47594f4 | ||
|
|
2614eeadb0 | ||
|
|
a0af827319 | ||
|
|
0233853794 | ||
|
|
6b24ccdc35 | ||
|
|
7d76ee2e65 | ||
|
|
1dd9228d01 | ||
|
|
a5b4221a00 | ||
|
|
37ba75b53c | ||
|
|
b8553e2b86 | ||
|
|
d28f3ed74b | ||
|
|
185c78b05c | ||
|
|
f23cab861a | ||
|
|
bbddec763a | ||
|
|
06c3985aa4 | ||
|
|
9503a603e6 | ||
|
|
6e9ab24d95 | ||
|
|
7524379af6 | ||
|
|
eebf3dec68 | ||
|
|
a89dd636a4 | ||
|
|
7fb025bff4 | ||
|
|
c44c0f6321 | ||
|
|
585bcb924f | ||
|
|
0ce3c3d90f | ||
|
|
9cb69f4879 | ||
|
|
c5b13f2fee | ||
|
|
235af9e558 | ||
|
|
cb274d1587 | ||
|
|
63643e6d26 | ||
|
|
0726600936 | ||
|
|
6151bd64dd | ||
|
|
32dc0f69f9 | ||
|
|
5b563cf173 | ||
|
|
3dbb534883 | ||
|
|
7304fad460 | ||
|
|
9f829c2129 | ||
|
|
32e71beca8 | ||
|
|
3c1c04f356 | ||
|
|
c473594663 | ||
|
|
a8ce9648e2 | ||
|
|
760285b085 | ||
|
|
ccdad3e8dc | ||
|
|
f33e9bee21 | ||
|
|
4183dca80f | ||
|
|
6f6fd6a42e | ||
|
|
13bb31fd93 | ||
|
|
5bac94cbc5 | ||
|
|
daa8d80ec9 | ||
|
|
b095f01b09 | ||
|
|
f43efab831 | ||
|
|
946b7905b3 | ||
|
|
544625a9a3 | ||
|
|
d7c6c27679 | ||
|
|
70adbfe6b5 | ||
|
|
d8f9ab93e5 | ||
|
|
e06d07937e | ||
|
|
f94d248383 | ||
|
|
c139aeebf5 | ||
|
|
89a8625817 | ||
|
|
59acda5dec | ||
|
|
57d9e4a370 | ||
|
|
8b6a2a3d99 | ||
|
|
3e10642bdd | ||
|
|
c8e63b6ae0 | ||
|
|
03c92ad41c | ||
|
|
690b454bb1 | ||
|
|
2e6c1bef63 | ||
|
|
0fd428f809 | ||
|
|
6083a8a859 | ||
|
|
bb7d262ea3 | ||
|
|
ca9a37d12a | ||
|
|
595ca631f4 | ||
|
|
cbffddc57f | ||
|
|
a5f5d41104 | ||
|
|
56f07b3dd6 | ||
|
|
fba10fe6a0 | ||
|
|
5639e0b7d0 | ||
|
|
a6ad58ca33 | ||
|
|
00447f2475 | ||
|
|
9d14fc47fe | ||
|
|
70c459f810 | ||
|
|
a0af2f4b68 | ||
|
|
603eefb22f | ||
|
|
34625ee384 | ||
|
|
ca78fb7c22 | ||
|
|
3c710dd266 | ||
|
|
514e7add4b | ||
|
|
bdbf1e9084 | ||
|
|
6149cef1d3 | ||
|
|
b8fac86c6e | ||
|
|
9f450dd8be | ||
|
|
24c2d3f8ca | ||
|
|
4248b8fa4e | ||
|
|
deaa2e5644 | ||
|
|
dc43aabe2a | ||
|
|
02981d38c0 | ||
|
|
85fd9b3c09 | ||
|
|
39ad54f3d9 | ||
|
|
aa9a2c46aa | ||
|
|
c43a1411c9 | ||
|
|
928aaf0c19 | ||
|
|
ea8a4a3ec4 | ||
|
|
c4dc468479 | ||
|
|
87ddfbca90 | ||
|
|
164ce8f7c4 | ||
|
|
c2fd6e3342 | ||
|
|
16b79754c3 | ||
|
|
9cfb1f789f | ||
|
|
e3faa388cf | ||
|
|
b75ec92368 | ||
|
|
f91763ef7c | ||
|
|
edf8b03d3b | ||
|
|
ea48eb5c56 | ||
|
|
282f723d34 | ||
|
|
dde3b76573 | ||
|
|
f571711386 | ||
|
|
e8e8d36a13 | ||
|
|
782a9a4759 | ||
|
|
d0184bd34c | ||
|
|
e4c0643c39 | ||
|
|
305c08c7dd | ||
|
|
9521a3ef09 | ||
|
|
b4c6a206af | ||
|
|
fa7eeec345 | ||
|
|
7350216fc4 | ||
|
|
36122dda31 | ||
|
|
5851673b43 | ||
|
|
0d81105a0b | ||
|
|
b934b0975b | ||
|
|
035b4b0608 |
164
README.md
164
README.md
@@ -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`后,需要根据下表配置对应站点的认证参数,认证资源`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.9,TR版本号要求>= 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`/`VoceChat`远程管理,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信回调地址、SynologyChat传入地址地址相对路径均为:`/api/v1/message/`;VoceChat的Webhook地址相对路径为:`/api/v1/message/?token=moviepilot`,其中moviepilot为设置的`API_TOKEN`。
|
||||
- 设置媒体服务器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`目录避免容器重置后重新触发浏览器内核下载。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
# 删除记录
|
||||
|
||||
@@ -34,21 +34,24 @@ 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)
|
||||
else:
|
||||
# 辅助验证用户若未启用,则禁止登录
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户未启用")
|
||||
# 普通用户权限
|
||||
user.is_superuser = False
|
||||
elif not user.is_active:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,13 +36,11 @@ async def user_message(background_tasks: BackgroundTasks, request: Request):
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/", summary="微信验证")
|
||||
def wechat_verify(echostr: str, msg_signature: str,
|
||||
timestamp: Union[str, int], nonce: str) -> Any:
|
||||
"""
|
||||
用户消息响应
|
||||
微信验证响应
|
||||
"""
|
||||
logger.info(f"收到微信验证请求: {echostr}")
|
||||
try:
|
||||
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
|
||||
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
|
||||
@@ -60,6 +58,28 @@ def wechat_verify(echostr: str, msg_signature: str,
|
||||
return PlainTextResponse(sEchoStr)
|
||||
|
||||
|
||||
def vocechat_verify(token: str) -> Any:
|
||||
"""
|
||||
VoceChat验证响应
|
||||
"""
|
||||
if token == settings.API_TOKEN:
|
||||
return {"status": "OK"}
|
||||
return {"status": "ERROR"}
|
||||
|
||||
|
||||
@router.get("/", summary="回调请求验证")
|
||||
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
|
||||
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
|
||||
"""
|
||||
微信/VoceChat等验证响应
|
||||
"""
|
||||
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
|
||||
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
|
||||
if echostr and msg_signature and timestamp and nonce:
|
||||
return wechat_verify(echostr, msg_signature, timestamp, nonce)
|
||||
return vocechat_verify(token)
|
||||
|
||||
|
||||
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
|
||||
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -72,7 +92,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
for noti in NotificationType:
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||
telegram=True, slack=True,
|
||||
synologychat=True))
|
||||
synologychat=True, vocechat=True))
|
||||
else:
|
||||
for switch in switchs:
|
||||
return_list.append(NotificationSwitch(**switch))
|
||||
|
||||
@@ -7,45 +7,60 @@ from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.plugin import PluginHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> Any:
|
||||
"""
|
||||
查询所有插件清单,包括本地插件和在线插件
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
"""
|
||||
plugins = []
|
||||
# 本地插件
|
||||
local_plugins = PluginManager().get_local_plugins()
|
||||
# 已安装插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.get("installed")]
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.get("installed")]
|
||||
if state == "installed":
|
||||
return installed_plugins
|
||||
|
||||
# 在线插件
|
||||
online_plugins = PluginManager().get_online_plugins()
|
||||
if not online_plugins:
|
||||
# 没有获取在线插件
|
||||
if state == "market":
|
||||
# 返回未安装的本地插件
|
||||
return not_installed_plugins
|
||||
return local_plugins
|
||||
|
||||
# 插件市场插件清单
|
||||
market_plugins = []
|
||||
# 已安装插件IDS
|
||||
installed_ids = [plugin["id"] for plugin in local_plugins if plugin.get("installed")]
|
||||
# 已经安装的本地
|
||||
plugins.extend([plugin for plugin in local_plugins if plugin.get("installed")])
|
||||
_installed_ids = [plugin["id"] for plugin in installed_plugins]
|
||||
# 未安装的线上插件或者有更新的插件
|
||||
for plugin in online_plugins:
|
||||
if plugin["id"] not in installed_ids:
|
||||
plugins.append(plugin)
|
||||
if plugin["id"] not in _installed_ids:
|
||||
market_plugins.append(plugin)
|
||||
elif plugin.get("has_update"):
|
||||
plugin["installed"] = False
|
||||
plugins.append(plugin)
|
||||
# 本地插件存在但未安装,且本地插件不在online插件中
|
||||
plugin_ids = [plugin["id"] for plugin in plugins]
|
||||
for plugin in local_plugins:
|
||||
if plugin["id"] not in installed_ids \
|
||||
and plugin["id"] not in plugin_ids:
|
||||
plugins.append(plugin)
|
||||
return plugins
|
||||
market_plugins.append(plugin)
|
||||
# 未安装的本地插件,且不在线上插件中
|
||||
_plugin_ids = [plugin["id"] for plugin in market_plugins]
|
||||
for plugin in not_installed_plugins:
|
||||
if plugin["id"] not in _plugin_ids:
|
||||
market_plugins.append(plugin)
|
||||
# 返回插件清单
|
||||
if state == "market":
|
||||
# 返回未安装的插件
|
||||
return market_plugins
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
|
||||
|
||||
@router.get("/installed", summary="已安装插件", response_model=List[str])
|
||||
def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def installed(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询用户已安装插件清单
|
||||
"""
|
||||
@@ -53,10 +68,10 @@ def installed_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install_plugin(plugin_id: str,
|
||||
repo_url: str = "",
|
||||
force: bool = False,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def install(plugin_id: str,
|
||||
repo_url: str = "",
|
||||
force: bool = False,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
@@ -76,6 +91,8 @@ def install_plugin(plugin_id: str,
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -101,7 +118,7 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token))
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据插件ID重置插件配置
|
||||
"""
|
||||
@@ -109,6 +126,8 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, {})
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -130,6 +149,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
PluginManager().save_plugin_config(plugin_id, conf)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -149,6 +170,8 @@ def uninstall_plugin(plugin_id: str,
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 重载插件管理器
|
||||
PluginManager().init_config()
|
||||
# 移除插件服务
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -83,10 +83,11 @@ def create_subscribe(
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
save_path=subscribe_in.save_path,
|
||||
search_imdbid=subscribe_in.search_imdbid,
|
||||
exist_ok=True)
|
||||
return schemas.Response(success=True if sid else False, message=message, data={
|
||||
"id": sid
|
||||
})
|
||||
return schemas.Response(
|
||||
success=bool(sid), message=message, data={"id": sid}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/", summary="更新订阅", response_model=schemas.Response)
|
||||
@@ -115,6 +116,9 @@ def update_subscribe(
|
||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode
|
||||
+ (subscribe_in.total_episode
|
||||
- (subscribe.total_episode or 0)))
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
subscribe.update(db, subscribe_dict)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -140,12 +144,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)
|
||||
|
||||
@@ -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, logfile: str = "moviepilot.log"):
|
||||
"""
|
||||
实时获取系统日志,返回格式为SSE
|
||||
实时获取系统日志
|
||||
length = -1 时, 返回text/plain
|
||||
否则 返回格式SSE
|
||||
"""
|
||||
if not token or not verify_token(token):
|
||||
raise HTTPException(
|
||||
@@ -133,45 +172,29 @@ def get_logging(token: str):
|
||||
detail="认证失败!",
|
||||
)
|
||||
|
||||
log_path = settings.LOG_PATH / logfile
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
# 计算相关秒数
|
||||
if result and result.status_code == 200:
|
||||
return schemas.Response(success=True, data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
elif result:
|
||||
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
# 根据length参数返回不同的响应
|
||||
if length == -1:
|
||||
# 返回全部日志作为文本响应
|
||||
if not log_path.exists():
|
||||
return Response(content="日志文件不存在!", media_type="text/plain")
|
||||
with open(log_path, 'r', encoding='utf-8') as file:
|
||||
text = file.read()
|
||||
return Response(content=text, media_type="text/plain")
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
# 返回SSE流响应
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
|
||||
@@ -219,6 +242,53 @@ def ruletest(title: str,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
# 计算相关秒数
|
||||
if result and result.status_code == 200:
|
||||
return schemas.Response(success=True, data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
elif result:
|
||||
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
|
||||
return schemas.Response(success=True, data={
|
||||
"ids": module_ids
|
||||
})
|
||||
|
||||
|
||||
@router.get("/moduletest/{moduleid}", summary="模块可用性测试", response_model=schemas.Response)
|
||||
def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
模块可用性测试接口
|
||||
"""
|
||||
state, errmsg = ModuleManager().test(moduleid)
|
||||
return schemas.Response(success=state, message=errmsg)
|
||||
|
||||
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
@@ -231,6 +301,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)):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, BackgroundTasks, Request, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.webhook import WebhookChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_uri_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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} 的数据 ...")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import pickle
|
||||
import re
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple
|
||||
from typing import Dict
|
||||
from typing import List, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -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,
|
||||
@@ -136,28 +135,8 @@ class SearchChain(ChainBase):
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
# 过滤种子
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始过滤资源,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=torrents,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
torrents = result
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
filter_rule=filter_rule)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 开始新进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
# 匹配的资源
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
@@ -165,28 +144,32 @@ class SearchChain(ChainBase):
|
||||
# 已处理数
|
||||
_count = 0
|
||||
if mediainfo:
|
||||
self.progress.start(ProgressKey.Search)
|
||||
logger.info(f'开始匹配,总 {_total} 个资源 ...')
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
for torrent in torrents:
|
||||
_count += 1
|
||||
self.progress.update(value=(_count / _total) * 100,
|
||||
self.progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||
key=ProgressKey.Search)
|
||||
# 比对IMDBID
|
||||
if torrent.imdbid \
|
||||
and mediainfo.imdb_id \
|
||||
and torrent.imdbid == mediainfo.imdb_id:
|
||||
logger.info(f'{mediainfo.title} 匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
# 识别
|
||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 比对类型
|
||||
if (torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV) \
|
||||
or (torrent_meta.type != MediaType.TV and mediainfo.type == MediaType.TV):
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 类型不匹配')
|
||||
# 比对种子识别类型
|
||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
||||
f'需要是 {mediainfo.type.value},不匹配')
|
||||
continue
|
||||
# 比对种子在站点中的类型
|
||||
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
||||
f'需要是 {mediainfo.type.value},不匹配')
|
||||
continue
|
||||
# 比对年份
|
||||
if mediainfo.year:
|
||||
@@ -231,21 +214,55 @@ class SearchChain(ChainBase):
|
||||
break
|
||||
else:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配')
|
||||
self.progress.update(value=100,
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
self.progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
self.progress.end(ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = torrents
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
# 开始过滤
|
||||
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
# 过滤种子
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始优先级规则过滤,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=_match_torrents,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
_match_torrents = result
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
if filter_rule:
|
||||
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
|
||||
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
|
||||
mediainfo=mediainfo,
|
||||
filter_rule=filter_rule)
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
# 组装上下文
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent) for torrent in _match_torrents]
|
||||
|
||||
logger.info(f"过滤完成,剩余 {_total} 个资源")
|
||||
self.progress.update(value=99, text=f'过滤完成,剩余 {_total} 个资源', key=ProgressKey.Search)
|
||||
# 排序
|
||||
self.progress.update(value=100,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
# 结束进度
|
||||
self.progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return contexts
|
||||
|
||||
@@ -352,102 +369,13 @@ class SearchChain(ChainBase):
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
||||
if not filter_rule:
|
||||
return torrents
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
# 电影大小
|
||||
movie_size = filter_rule.get("movie_size")
|
||||
# 剧集单集大小
|
||||
tv_size = filter_rule.get("tv_size")
|
||||
|
||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
获取大小范围
|
||||
"""
|
||||
if not size_str:
|
||||
return 0, 0
|
||||
try:
|
||||
size_range = size_str.split("-")
|
||||
if len(size_range) == 1:
|
||||
return 0, float(size_range[0])
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return 0, 0
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
过滤种子
|
||||
"""
|
||||
# 包含
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
|
||||
# 分辨率
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
|
||||
# 特效
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
# 大小
|
||||
if movie_size or tv_size:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
size = tv_size
|
||||
else:
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size is not None and end_size is not None:
|
||||
meta = MetaInfo(title=t.title, subtitle=t.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
season = meta.begin_season or 1
|
||||
if meta.total_episode:
|
||||
# 识别的总集数
|
||||
episodes_num = meta.total_episode
|
||||
else:
|
||||
# 整季集数
|
||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
||||
# 比较大小
|
||||
if not (begin_size * 1024 ** 3 <= (t.size / episodes_num) <= end_size * 1024 ** 3):
|
||||
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} "
|
||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
||||
return False
|
||||
else:
|
||||
# 电影比较大小
|
||||
if not (begin_size * 1024 ** 3 <= t.size <= end_size * 1024 ** 3):
|
||||
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} 不匹配大小规则 {size}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
return list(filter(lambda t: __filter_torrent(t), torrents))
|
||||
return list(filter(
|
||||
lambda t: self.torrenthelper.filter_torrent(
|
||||
torrent_info=t,
|
||||
filter_rule=filter_rule,
|
||||
mediainfo=mediainfo
|
||||
),
|
||||
torrents
|
||||
))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -18,9 +18,11 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo, Notification
|
||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class SubscribeChain(ChainBase):
|
||||
@@ -37,6 +39,7 @@ class SubscribeChain(ChainBase):
|
||||
self.mediachain = MediaChain()
|
||||
self.message = MessageHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -71,11 +74,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 +99,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 +201,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}')
|
||||
@@ -257,10 +262,7 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
|
||||
# 站点范围
|
||||
if subscribe.sites:
|
||||
sites = json.loads(subscribe.sites)
|
||||
else:
|
||||
sites = None
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
@@ -277,7 +279,8 @@ class SubscribeChain(ChainBase):
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
priority_rule=priority_rule,
|
||||
filter_rule=filter_rule)
|
||||
filter_rule=filter_rule,
|
||||
area="imdbid" if subscribe.search_imdbid else "title")
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
@@ -416,6 +419,15 @@ class SubscribeChain(ChainBase):
|
||||
self.torrentschain.refresh(sites=sites)
|
||||
)
|
||||
|
||||
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
|
||||
"""
|
||||
获取订阅中涉及的站点清单
|
||||
"""
|
||||
if subscribe.sites:
|
||||
return json.loads(subscribe.sites)
|
||||
# 默认站点
|
||||
return self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
|
||||
def get_subscribed_sites(self) -> Optional[List[int]]:
|
||||
"""
|
||||
获取订阅中涉及的所有站点清单(节约资源)
|
||||
@@ -428,13 +440,8 @@ class SubscribeChain(ChainBase):
|
||||
ret_sites = []
|
||||
# 刷新订阅选中的Rss站点
|
||||
for subscribe in subscribes:
|
||||
# 如果有一个订阅没有选择站点,则刷新所有订阅站点
|
||||
if not subscribe.sites:
|
||||
return []
|
||||
# 刷新选中的站点
|
||||
sub_sites = json.loads(subscribe.sites)
|
||||
if sub_sites:
|
||||
ret_sites.extend(sub_sites)
|
||||
ret_sites.extend(self.get_sub_sites(subscribe))
|
||||
# 去重
|
||||
if ret_sites:
|
||||
ret_sites = list(set(ret_sites))
|
||||
@@ -443,64 +450,20 @@ class SubscribeChain(ChainBase):
|
||||
|
||||
def get_filter_rule(self, subscribe: Subscribe):
|
||||
"""
|
||||
获取订阅过滤规则,没有则返回默认规则
|
||||
获取订阅过滤规则,同时组合默认规则
|
||||
"""
|
||||
# 默认过滤规则
|
||||
if (subscribe.include
|
||||
or subscribe.exclude
|
||||
or subscribe.quality
|
||||
or subscribe.resolution
|
||||
or subscribe.effect):
|
||||
return {
|
||||
"include": subscribe.include,
|
||||
"exclude": subscribe.exclude,
|
||||
"quality": subscribe.quality,
|
||||
"resolution": subscribe.resolution,
|
||||
"effect": subscribe.effect,
|
||||
}
|
||||
# 订阅默认过滤规则
|
||||
return self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
|
||||
@staticmethod
|
||||
def check_filter_rule(torrent_info: TorrentInfo, filter_rule: Dict[str, str]) -> bool:
|
||||
"""
|
||||
检查种子是否匹配订阅过滤规则
|
||||
"""
|
||||
if not filter_rule:
|
||||
return True
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
return True
|
||||
default_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
return {
|
||||
"include": subscribe.include or default_rule.get("include"),
|
||||
"exclude": subscribe.exclude or default_rule.get("exclude"),
|
||||
"quality": subscribe.quality or default_rule.get("quality"),
|
||||
"resolution": subscribe.resolution or default_rule.get("resolution"),
|
||||
"effect": subscribe.effect or default_rule.get("effect"),
|
||||
"tv_size": default_rule.get("tv_size"),
|
||||
"movie_size": default_rule.get("movie_size"),
|
||||
"min_seeders": default_rule.get("min_seeders"),
|
||||
}
|
||||
|
||||
def match(self, torrents: Dict[str, List[Context]]):
|
||||
"""
|
||||
@@ -523,7 +486,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}')
|
||||
@@ -587,15 +551,73 @@ class SubscribeChain(ChainBase):
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
for context in contexts:
|
||||
# 检查是否匹配
|
||||
torrent_meta = context.meta_info
|
||||
torrent_mediainfo = context.media_info
|
||||
torrent_info = context.torrent_info
|
||||
# 比对TMDBID和类型
|
||||
if torrent_mediainfo.tmdb_id != mediainfo.tmdb_id \
|
||||
or torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
|
||||
# 如果识别了媒体信息,则比对TMDBID和类型
|
||||
if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
|
||||
if torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
if torrent_mediainfo.tmdb_id \
|
||||
and torrent_mediainfo.tmdb_id != mediainfo.tmdb_id:
|
||||
continue
|
||||
if torrent_mediainfo.douban_id \
|
||||
and torrent_mediainfo.douban_id != mediainfo.douban_id:
|
||||
continue
|
||||
logger.info(f'{mediainfo.title_year} 通过媒体信ID匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
else:
|
||||
# 按标题匹配
|
||||
# 比对种子识别类型
|
||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||
continue
|
||||
# 比对种子在站点中的类型
|
||||
if torrent_info.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||
continue
|
||||
# 比对年份
|
||||
if mediainfo.year:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 剧集年份,每季的年份可能不同
|
||||
if torrent_meta.year and torrent_meta.year not in [year for year in
|
||||
mediainfo.season_years.values()]:
|
||||
continue
|
||||
else:
|
||||
# 电影年份,上下浮动1年
|
||||
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
||||
mediainfo.year,
|
||||
str(int(mediainfo.year) + 1)]:
|
||||
continue
|
||||
# 标题匹配标志
|
||||
title_match = False
|
||||
# 比对标题和原语种标题
|
||||
meta_name = StringUtils.clear_upper(torrent_meta.name)
|
||||
if meta_name in [
|
||||
StringUtils.clear_upper(mediainfo.title),
|
||||
StringUtils.clear_upper(mediainfo.original_title)
|
||||
]:
|
||||
title_match = True
|
||||
# 在副标题中判断是否存在标题与原语种标题
|
||||
if not title_match and torrent_info.description:
|
||||
subtitle = re.split(r'[\s/|]+', torrent_info.description)
|
||||
if (StringUtils.is_chinese(mediainfo.title)
|
||||
and str(mediainfo.title) in subtitle) \
|
||||
or (StringUtils.is_chinese(mediainfo.original_title)
|
||||
and str(mediainfo.original_title) in subtitle):
|
||||
title_match = True
|
||||
# 比对别名和译名
|
||||
if not title_match:
|
||||
for name in mediainfo.names:
|
||||
if StringUtils.clear_upper(name) == meta_name:
|
||||
title_match = True
|
||||
break
|
||||
if not title_match:
|
||||
continue
|
||||
# 标题匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 通过名称匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
@@ -609,12 +631,13 @@ class SubscribeChain(ChainBase):
|
||||
# 不符合过滤规则
|
||||
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||
continue
|
||||
|
||||
# 不在订阅站点范围的不处理
|
||||
if subscribe.sites:
|
||||
sub_sites = json.loads(subscribe.sites)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.info(f"{torrent_info.title} 不符合 {torrent_mediainfo.title_year} 订阅站点要求")
|
||||
continue
|
||||
sub_sites = self.get_sub_sites(subscribe)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||
continue
|
||||
|
||||
# 如果是电视剧
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
# 有多季的不要
|
||||
@@ -658,8 +681,9 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
|
||||
# 过滤规则
|
||||
if not self.check_filter_rule(torrent_info=torrent_info,
|
||||
filter_rule=filter_rule):
|
||||
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
filter_rule=filter_rule,
|
||||
mediainfo=torrent_mediainfo):
|
||||
continue
|
||||
|
||||
# 洗版时,优先级小于已下载优先级的不要
|
||||
@@ -701,7 +725,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 +733,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 not subscribe.manual_total_episode and len(episodes):
|
||||
total_episode = len(episodes)
|
||||
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
||||
logger.info(
|
||||
@@ -737,7 +763,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]):
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
import traceback
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
@@ -15,7 +16,7 @@ from app.helper.sites import SitesHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -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)
|
||||
@@ -183,6 +184,10 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
@@ -246,5 +251,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链接已过期"))
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
计算媒体库目录的根路径
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||
# 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔
|
||||
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,使用,分隔
|
||||
@@ -117,16 +117,22 @@ class Settings(BaseSettings):
|
||||
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||
# SynologyChat Token
|
||||
SYNOLOGYCHAT_TOKEN: str = ""
|
||||
# VoceChat地址
|
||||
VOCECHAT_HOST: str = ""
|
||||
# VoceChat ApiKey
|
||||
VOCECHAT_API_KEY: str = ""
|
||||
# VoceChat 频道ID
|
||||
VOCECHAT_CHANNEL_ID: str = ""
|
||||
# 下载器 qbittorrent/transmission
|
||||
DOWNLOADER: str = "qbittorrent"
|
||||
# 下载器监控开关
|
||||
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 +140,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 +164,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 +198,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 +225,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"
|
||||
|
||||
@@ -57,6 +57,8 @@ class TorrentInfo:
|
||||
labels: list = field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: int = 0
|
||||
# 种子分类 电影/电视剧
|
||||
category: str = None
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
self.__dict__[name] = value
|
||||
@@ -135,6 +137,8 @@ class MediaInfo:
|
||||
type: MediaType = None
|
||||
# 媒体标题
|
||||
title: str = None
|
||||
# 英文标题
|
||||
en_title: str = None
|
||||
# 年份
|
||||
year: str = None
|
||||
# 季
|
||||
@@ -160,7 +164,7 @@ class MediaInfo:
|
||||
# LOGO
|
||||
logo_path: str = None
|
||||
# 评分
|
||||
vote_average: int = 0
|
||||
vote_average: float = 0
|
||||
# 描述
|
||||
overview: str = None
|
||||
# 风格ID
|
||||
@@ -368,6 +372,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 +445,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")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -70,7 +74,7 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
if class_name in self._disabled_handlers:
|
||||
self._disabled_handlers.remove(class_name)
|
||||
logger.debug(f"Event Enabled:{class_name}")
|
||||
logger.debug(f"Event Enabled:{class_name}")
|
||||
|
||||
def send_event(self, etype: EventType, data: dict = None):
|
||||
"""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,7 +26,7 @@ class WordsMatcher(metaclass=Singleton):
|
||||
# 读取自定义识别词
|
||||
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
|
||||
for word in words:
|
||||
if not word:
|
||||
if not word or word.find('#') == 0:
|
||||
continue
|
||||
try:
|
||||
if word.count(" => ") and word.count(" && ") and word.count(" >> ") and word.count(" <> "):
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Generator, Optional
|
||||
from typing import Generator, Optional, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.module import ModuleHelper
|
||||
@@ -51,6 +51,18 @@ class ModuleManager(metaclass=Singleton):
|
||||
if hasattr(module, "stop"):
|
||||
module.stop()
|
||||
|
||||
def test(self, modleid: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块
|
||||
"""
|
||||
if modleid not in self._running_modules:
|
||||
return False, "模块未加载,请检查参数设置"
|
||||
module = self._running_modules[modleid]
|
||||
if hasattr(module, "test") \
|
||||
and ObjectUtils.check_method(getattr(module, "test")):
|
||||
return module.test()
|
||||
return True, "模块不支持测试"
|
||||
|
||||
@staticmethod
|
||||
def check_setting(setting: Optional[tuple]) -> bool:
|
||||
"""
|
||||
|
||||
@@ -72,9 +72,12 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin_obj.init_plugin(self.get_plugin_config(plugin_id))
|
||||
# 存储运行实例
|
||||
self._running_plugins[plugin_id] = plugin_obj
|
||||
logger.info(f"Plugin Loaded:{plugin_id}")
|
||||
# 设置事件注册状态可用
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
logger.info(f"加载插件:{plugin_id} 版本:{plugin_obj.plugin_version}")
|
||||
# 启用的插件才设置事件注册状态可用
|
||||
if plugin_obj.get_state():
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
else:
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
except Exception as err:
|
||||
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
||||
|
||||
@@ -85,6 +88,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 +146,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:
|
||||
"""
|
||||
@@ -233,6 +246,16 @@ class PluginManager(metaclass=Singleton):
|
||||
ret_services.extend(services)
|
||||
return ret_services
|
||||
|
||||
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||
"""
|
||||
获取插件属性
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
return None
|
||||
if not hasattr(self._running_plugins[pid], attr):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], attr)
|
||||
|
||||
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行插件方法
|
||||
@@ -249,6 +272,12 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
return list(self._plugins.keys())
|
||||
|
||||
def get_running_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有运行态插件ID
|
||||
"""
|
||||
return list(self._running_plugins.keys())
|
||||
|
||||
def get_online_plugins(self) -> List[dict]:
|
||||
"""
|
||||
获取所有在线插件信息
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from app.db import DbOper
|
||||
@@ -10,12 +9,12 @@ class DownloadHistoryOper(DbOper):
|
||||
下载历史管理
|
||||
"""
|
||||
|
||||
def get_by_path(self, path: Path) -> DownloadHistory:
|
||||
def get_by_path(self, path: str) -> DownloadHistory:
|
||||
"""
|
||||
按路径查询下载记录
|
||||
:param path: 数据key
|
||||
"""
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -67,6 +67,10 @@ class Subscribe(Base):
|
||||
current_priority = Column(Integer)
|
||||
# 保存路径
|
||||
save_path = Column(String)
|
||||
# 是否使用 imdbid 搜索
|
||||
search_imdbid = Column(Integer, default=0)
|
||||
# 是否手动修改过总集数 0否 1是
|
||||
manual_total_episode = Column(Integer, default=0)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,7 +8,7 @@ class CookieCloudHelper:
|
||||
|
||||
_ignore_cookies: list = ["CookieAutoDeleteBrowsingDataCleanup", "CookieAutoDeleteCleaningDiscarded"]
|
||||
|
||||
def __init__(self, server, key, password):
|
||||
def __init__(self, server: str, key: str, password: str):
|
||||
self._server = server
|
||||
self._key = key
|
||||
self._password = password
|
||||
@@ -21,8 +21,8 @@ class CookieCloudHelper:
|
||||
"""
|
||||
if not self._server or not self._key or not self._password:
|
||||
return None, "CookieCloud参数不正确"
|
||||
req_url = "%s/get/%s" % (self._server, self._key)
|
||||
ret = self._req.post_res(url=req_url, json={"password": self._password})
|
||||
req_url = "%s/get/%s" % (self._server, str(self._key).strip())
|
||||
ret = self._req.post_res(url=req_url, json={"password": str(self._password).strip()})
|
||||
if ret and ret.status_code == 200:
|
||||
result = ret.json()
|
||||
if not result:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 链接已过期, 您需要获得一个新的!",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List, Union
|
||||
from typing import Tuple, Optional, List, Union, Dict
|
||||
from urllib.parse import unquote
|
||||
|
||||
from requests import Response
|
||||
from torrentool.api import Torrent
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import Context, TorrentInfo, MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentHelper(metaclass=Singleton):
|
||||
@@ -295,3 +297,103 @@ class TorrentHelper(metaclass=Singleton):
|
||||
"""
|
||||
if url not in self._invalid_torrents:
|
||||
self._invalid_torrents.append(url)
|
||||
|
||||
@staticmethod
|
||||
def filter_torrent(torrent_info: TorrentInfo,
|
||||
filter_rule: Dict[str, str],
|
||||
mediainfo: MediaInfo) -> bool:
|
||||
"""
|
||||
检查种子是否匹配订阅过滤规则
|
||||
"""
|
||||
|
||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
获取大小范围
|
||||
"""
|
||||
if not size_str:
|
||||
return 0, 0
|
||||
try:
|
||||
size_range = size_str.split("-")
|
||||
if len(size_range) == 1:
|
||||
return 0, float(size_range[0])
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
||||
return 0, 0
|
||||
|
||||
if not filter_rule:
|
||||
return True
|
||||
|
||||
# 最少做种人数
|
||||
min_seeders = filter_rule.get("min_seeders")
|
||||
if min_seeders and torrent_info.seeders < int(min_seeders):
|
||||
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
|
||||
return False
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
# 大小
|
||||
tv_size = filter_rule.get("tv_size")
|
||||
movie_size = filter_rule.get("movie_size")
|
||||
if movie_size or tv_size:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
size = tv_size
|
||||
else:
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size or end_size:
|
||||
meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
season = meta.begin_season or 1
|
||||
if meta.total_episode:
|
||||
# 识别的总集数
|
||||
episodes_num = meta.total_episode
|
||||
else:
|
||||
# 整季集数
|
||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
||||
# 比较大小
|
||||
if not (begin_size * 1024 ** 3 <= (torrent_info.size / episodes_num) <= end_size * 1024 ** 3):
|
||||
logger.info(f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} "
|
||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
||||
return False
|
||||
else:
|
||||
# 电影比较大小
|
||||
if not (begin_size * 1024 ** 3 <= torrent_info.size <= end_size * 1024 ** 3):
|
||||
logger.info(
|
||||
f"{torrent_info.title} {StringUtils.str_filesize(torrent_info.size)} 不匹配大小规则 {size}")
|
||||
return False
|
||||
return True
|
||||
|
||||
43
app/helper/twofa.py
Normal file
43
app/helper/twofa.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class TwoFactorAuth:
|
||||
def __init__(self, code_or_secret: str):
|
||||
if code_or_secret and len(code_or_secret) > 16:
|
||||
self.code = None
|
||||
self.secret = code_or_secret
|
||||
else:
|
||||
self.code = code_or_secret
|
||||
self.secret = None
|
||||
|
||||
@staticmethod
|
||||
def __calc(secret_key: str) -> str:
|
||||
if not secret_key:
|
||||
return ""
|
||||
try:
|
||||
input_time = int(time.time()) // 30
|
||||
key = base64.b32decode(secret_key)
|
||||
msg = struct.pack(">Q", input_time)
|
||||
google_code = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
o = (
|
||||
google_code[19] & 15
|
||||
if sys.version_info > (2, 7)
|
||||
else ord(str(google_code[19])) & 15
|
||||
)
|
||||
google_code = str(
|
||||
(struct.unpack(">I", google_code[o: o + 4])[0] & 0x7FFFFFFF) % 1000000
|
||||
)
|
||||
return f"0{google_code}" if len(google_code) == 5 else google_code
|
||||
except Exception as e:
|
||||
logger.error(f"计算动态验证码失败:{str(e)}")
|
||||
return ""
|
||||
|
||||
def get_code(self) -> str:
|
||||
return self.code or self.__calc(self.secret)
|
||||
174
app/log.py
174
app/log.py
@@ -1,6 +1,8 @@
|
||||
import inspect
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
import click
|
||||
|
||||
@@ -26,32 +28,156 @@ class CustomFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
seperator = " " * (8 - len(record.levelname))
|
||||
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
|
||||
if record.filename == "__init__.py":
|
||||
record.filename = Path(record.pathname).parent.name
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# DEBUG
|
||||
logger = logging.getLogger()
|
||||
if settings.DEBUG:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
class LoggerManager:
|
||||
"""
|
||||
日志管理
|
||||
"""
|
||||
# 管理所有的Logger
|
||||
_loggers: Dict[str, Any] = {}
|
||||
# 默认日志文件
|
||||
_default_log_file = "moviepilot.log"
|
||||
|
||||
# 终端日志
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_formatter = CustomFormatter("%(leveltext)s%(filename)s - %(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
"""
|
||||
获取调用者的文件名称与插件名称(如果是插件调用内置的模块, 也能写入到插件日志文件中)
|
||||
"""
|
||||
# 调用者文件名称
|
||||
caller_name = None
|
||||
# 调用者插件名称
|
||||
plugin_name = None
|
||||
for i in inspect.stack()[3:]:
|
||||
filepath = Path(i.filename)
|
||||
parts = filepath.parts
|
||||
if not caller_name:
|
||||
# 设定调用者文件名称
|
||||
if parts[-1] == "__init__.py":
|
||||
caller_name = parts[-2]
|
||||
else:
|
||||
caller_name = parts[-1]
|
||||
if "app" in parts:
|
||||
if not plugin_name and "plugins" in parts:
|
||||
# 设定调用者插件名称
|
||||
plugin_name = parts[parts.index("plugins") + 1]
|
||||
if plugin_name == "__init__.py":
|
||||
plugin_name = "plugin"
|
||||
break
|
||||
if "main.py" in parts:
|
||||
# 已经到达程序的入口
|
||||
break
|
||||
elif len(parts) != 1:
|
||||
# 已经超出程序范围
|
||||
break
|
||||
return caller_name or "log.py", plugin_name
|
||||
|
||||
# 文件日志
|
||||
file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log',
|
||||
mode='w',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_formater = CustomFormatter("【%(levelname)s】%(asctime)s - %(filename)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
logger.addHandler(file_handler)
|
||||
@staticmethod
|
||||
def __setup_logger(log_file: str):
|
||||
"""
|
||||
设置日志
|
||||
log_file:日志文件相对路径
|
||||
"""
|
||||
log_file_path = settings.LOG_PATH / log_file
|
||||
if not log_file_path.parent.exists():
|
||||
log_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建新实例
|
||||
_logger = logging.getLogger(log_file_path.stem)
|
||||
|
||||
# DEBUG
|
||||
if settings.DEBUG:
|
||||
_logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
_logger.setLevel(logging.INFO)
|
||||
|
||||
# 移除已有的 handler,避免重复添加
|
||||
for handler in _logger.handlers:
|
||||
_logger.removeHandler(handler)
|
||||
|
||||
# 终端日志
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_formatter = CustomFormatter(f"%(leveltext)s%(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
_logger.addHandler(console_handler)
|
||||
|
||||
# 文件日志
|
||||
file_handler = RotatingFileHandler(filename=log_file_path,
|
||||
mode='w',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_formater = CustomFormatter(f"【%(levelname)s】%(asctime)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
_logger.addHandler(file_handler)
|
||||
|
||||
return _logger
|
||||
|
||||
def logger(self, method: str, msg: str, *args, **kwargs):
|
||||
"""
|
||||
获取模块的logger
|
||||
:param method: 日志方法
|
||||
:param msg: 日志信息
|
||||
"""
|
||||
|
||||
# 获取调用者文件名和插件名
|
||||
caller_name, plugin_name = self.__get_caller()
|
||||
# 区分插件日志
|
||||
if plugin_name:
|
||||
# 使用插件日志文件
|
||||
logfile = Path("plugins") / f"{plugin_name}.log"
|
||||
else:
|
||||
# 使用默认日志文件
|
||||
logfile = self._default_log_file
|
||||
|
||||
# 获取调用者的模块的logger
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
_logger = self.__setup_logger(logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
if hasattr(_logger, method):
|
||||
method = getattr(_logger, method)
|
||||
method(f"{caller_name} - {msg}", *args, **kwargs)
|
||||
|
||||
def info(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载info方法
|
||||
"""
|
||||
self.logger("info", msg, *args, **kwargs)
|
||||
|
||||
def debug(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载debug方法
|
||||
"""
|
||||
self.logger("debug", msg, *args, **kwargs)
|
||||
|
||||
def warning(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载warning方法
|
||||
"""
|
||||
self.logger("warning", msg, *args, **kwargs)
|
||||
|
||||
def warn(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载warn方法
|
||||
"""
|
||||
self.logger("warning", msg, *args, **kwargs)
|
||||
|
||||
def error(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载error方法
|
||||
"""
|
||||
self.logger("error", msg, *args, **kwargs)
|
||||
|
||||
def critical(self, msg: str, *args, **kwargs):
|
||||
"""
|
||||
重载critical方法
|
||||
"""
|
||||
self.logger("critical", msg, *args, **kwargs)
|
||||
|
||||
|
||||
# 初始化公共日志
|
||||
logger = LoggerManager()
|
||||
|
||||
@@ -35,6 +35,13 @@ class _ModuleBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
模块测试, 返回测试结果和错误信息
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def checkMessage(channel_type: MessageChannel):
|
||||
"""
|
||||
@@ -60,6 +67,8 @@ def checkMessage(channel_type: MessageChannel):
|
||||
return None
|
||||
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||
return None
|
||||
if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"):
|
||||
return None
|
||||
return func(self, message, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
@@ -29,18 +30,31 @@ class DoubanModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
return False, "豆瓣网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
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 +62,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 +100,7 @@ class DoubanModule(_ModuleBase):
|
||||
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
|
||||
return None
|
||||
# 保存到缓存
|
||||
if meta:
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,6 +17,16 @@ class EmbyModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.emby.is_inactive():
|
||||
self.emby.reconnect()
|
||||
if not self.emby.get_user():
|
||||
return False, "无法连接Emby,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MEDIASERVER", "emby"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -317,6 +317,17 @@ class FanartModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://webservice.fanart.tv")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接fanart,错误码:{ret.status_code}"
|
||||
return False, "fanart网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "FANART_API_KEY", True
|
||||
|
||||
|
||||
@@ -27,6 +27,30 @@ class FileTransferModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if not settings.DOWNLOAD_PATH:
|
||||
return False, "下载目录未设置"
|
||||
# 检查下载目录
|
||||
download_path = Path(settings.DOWNLOAD_PATH)
|
||||
if not download_path.exists():
|
||||
return False, "下载目录不存在"
|
||||
if not settings.LIBRARY_PATH:
|
||||
return False, "媒体库目录未设置"
|
||||
# 下载目录的设备ID
|
||||
download_devid = download_path.stat().st_dev
|
||||
# 比较媒体库目录的设备ID
|
||||
for path in settings.LIBRARY_PATHS:
|
||||
library_path = Path(path)
|
||||
if not library_path.exists():
|
||||
return False, f"目录不存在:{library_path}"
|
||||
if settings.TRANSFER_TYPE == "link":
|
||||
if library_path.stat().st_dev != download_devid:
|
||||
return False, f"下载目录 {download_path} 与媒体库目录 {library_path} 不在同一设备,将无法硬链接"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -319,7 +343,7 @@ class FileTransferModule(_ModuleBase):
|
||||
:param transfer_type: RmtMode转移方式
|
||||
:param over_flag: 是否覆盖,为True时会先删除再转移
|
||||
"""
|
||||
if new_file.exists():
|
||||
if new_file.exists() or new_file.is_symlink():
|
||||
if not over_flag:
|
||||
logger.warn(f"文件已存在:{new_file}")
|
||||
return 0
|
||||
@@ -486,37 +510,46 @@ class FileTransferModule(_ModuleBase):
|
||||
|
||||
# 判断是否要覆盖
|
||||
overflag = False
|
||||
if new_file.exists():
|
||||
# 目标文件已存在
|
||||
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
|
||||
match settings.OVERWRITE_MODE:
|
||||
case 'always':
|
||||
# 总是覆盖同名文件
|
||||
target_file = new_file
|
||||
if new_file.exists() or new_file.is_symlink():
|
||||
if new_file.is_symlink():
|
||||
target_file = new_file.readlink()
|
||||
if not target_file.exists():
|
||||
overflag = True
|
||||
case 'size':
|
||||
# 存在时大覆盖小
|
||||
if new_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件文件大小更小,将被覆盖:{new_file}")
|
||||
if not overflag:
|
||||
# 目标文件已存在
|
||||
logger.info(f"目标文件已存在,转移覆盖模式:{settings.OVERWRITE_MODE}")
|
||||
match settings.OVERWRITE_MODE:
|
||||
case 'always':
|
||||
# 总是覆盖同名文件
|
||||
overflag = True
|
||||
else:
|
||||
case 'size':
|
||||
# 存在时大覆盖小
|
||||
if target_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
else:
|
||||
return TransferInfo(success=False,
|
||||
message=f"媒体库中已存在,且质量更好",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
case 'never':
|
||||
# 存在不覆盖
|
||||
return TransferInfo(success=False,
|
||||
message=f"媒体库中已存在,且质量更好",
|
||||
message=f"媒体库中已存在,当前设置为不覆盖",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
case 'never':
|
||||
# 存在不覆盖
|
||||
return TransferInfo(success=False,
|
||||
message=f"媒体库中已存在,当前设置为不覆盖",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
case 'latest':
|
||||
# 仅保留最新版本
|
||||
self.delete_all_version_files(new_file)
|
||||
overflag = True
|
||||
case _:
|
||||
pass
|
||||
case 'latest':
|
||||
# 仅保留最新版本
|
||||
logger.info(f"仅保留最新版本,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
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 +585,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 +609,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}",
|
||||
# 识别名称(优先使用中文)
|
||||
|
||||
@@ -39,7 +39,7 @@ class FilterModule(_ModuleBase):
|
||||
# 中字
|
||||
"CNSUB": {
|
||||
"include": [
|
||||
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字'],
|
||||
r'[中国國繁简](/|\s|\\|\|)?[繁简英粤]|[英简繁](/|\s|\\|\|)?[中繁简]|繁體|简体|[中国國][字配]|国语|國語|中文|中字|简日|繁日|简繁|繁体|([\s,.-\[])(CHT|CHS|cht|chs)(|[\s,.-\]])'],
|
||||
"exclude": [],
|
||||
"tmdb": {
|
||||
"original_language": "zh,cn"
|
||||
@@ -132,6 +132,9 @@ class FilterModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List, Optional, Tuple, Union
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.context import TorrentInfo
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.indexer.mtorrent import MTorrentSpider
|
||||
@@ -25,6 +26,15 @@ class IndexerModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
sites = SitesHelper().get_indexers()
|
||||
if not sites:
|
||||
return False, "未配置站点或未通过用户认证"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "INDEXER", "builtin"
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import copy
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from typing import List
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.parse import quote, urlencode, urlparse, parse_qs
|
||||
|
||||
import chardet
|
||||
from jinja2 import Template
|
||||
@@ -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)
|
||||
@@ -270,7 +276,7 @@ class TorrentSpider:
|
||||
return self.parse(page_source)
|
||||
|
||||
def __get_title(self, torrent):
|
||||
# title default
|
||||
# title default text
|
||||
if 'title' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('title', {})
|
||||
@@ -300,7 +306,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_description(self, torrent):
|
||||
# title optional
|
||||
# title optional text
|
||||
if 'description' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('description', {})
|
||||
@@ -346,7 +352,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_detail(self, torrent):
|
||||
# details
|
||||
# details page text
|
||||
if 'details' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('details', {})
|
||||
@@ -367,7 +373,7 @@ class TorrentSpider:
|
||||
self.torrents_info['page_url'] = detail_link
|
||||
|
||||
def __get_download(self, torrent):
|
||||
# download link
|
||||
# download link text
|
||||
if 'download' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('download', {})
|
||||
@@ -397,7 +403,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_size(self, torrent):
|
||||
# torrent size
|
||||
# torrent size int
|
||||
if 'size' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('size', {})
|
||||
@@ -414,7 +420,7 @@ class TorrentSpider:
|
||||
self.torrents_info['size'] = 0
|
||||
|
||||
def __get_leechers(self, torrent):
|
||||
# torrent leechers
|
||||
# torrent leechers int
|
||||
if 'leechers' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('leechers', {})
|
||||
@@ -424,6 +430,7 @@ class TorrentSpider:
|
||||
item = self.__index(items, selector)
|
||||
if item:
|
||||
peers_val = item.split("/")[0]
|
||||
peers_val = peers_val.replace(",", "")
|
||||
peers_val = self.__filter_text(peers_val,
|
||||
selector.get('filters'))
|
||||
self.torrents_info['peers'] = int(peers_val) if peers_val and peers_val.isdigit() else 0
|
||||
@@ -431,7 +438,7 @@ class TorrentSpider:
|
||||
self.torrents_info['peers'] = 0
|
||||
|
||||
def __get_seeders(self, torrent):
|
||||
# torrent leechers
|
||||
# torrent leechers int
|
||||
if 'seeders' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('seeders', {})
|
||||
@@ -441,6 +448,7 @@ class TorrentSpider:
|
||||
item = self.__index(items, selector)
|
||||
if item:
|
||||
seeders_val = item.split("/")[0]
|
||||
seeders_val = seeders_val.replace(",", "")
|
||||
seeders_val = self.__filter_text(seeders_val,
|
||||
selector.get('filters'))
|
||||
self.torrents_info['seeders'] = int(seeders_val) if seeders_val and seeders_val.isdigit() else 0
|
||||
@@ -448,7 +456,7 @@ class TorrentSpider:
|
||||
self.torrents_info['seeders'] = 0
|
||||
|
||||
def __get_grabs(self, torrent):
|
||||
# torrent grabs
|
||||
# torrent grabs int
|
||||
if 'grabs' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('grabs', {})
|
||||
@@ -458,6 +466,7 @@ class TorrentSpider:
|
||||
item = self.__index(items, selector)
|
||||
if item:
|
||||
grabs_val = item.split("/")[0]
|
||||
grabs_val = grabs_val.replace(",", "")
|
||||
grabs_val = self.__filter_text(grabs_val,
|
||||
selector.get('filters'))
|
||||
self.torrents_info['grabs'] = int(grabs_val) if grabs_val and grabs_val.isdigit() else 0
|
||||
@@ -465,7 +474,7 @@ class TorrentSpider:
|
||||
self.torrents_info['grabs'] = 0
|
||||
|
||||
def __get_pubdate(self, torrent):
|
||||
# torrent pubdate
|
||||
# torrent pubdate yyyy-mm-dd hh:mm:ss
|
||||
if 'date_added' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('date_added', {})
|
||||
@@ -477,7 +486,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_date_elapsed(self, torrent):
|
||||
# torrent pubdate
|
||||
# torrent data elaspsed text
|
||||
if 'date_elapsed' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('date_elapsed', {})
|
||||
@@ -489,7 +498,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_downloadvolumefactor(self, torrent):
|
||||
# downloadvolumefactor
|
||||
# downloadvolumefactor int
|
||||
selector = self.fields.get('downloadvolumefactor', {})
|
||||
if not selector:
|
||||
return
|
||||
@@ -512,7 +521,7 @@ class TorrentSpider:
|
||||
self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1))
|
||||
|
||||
def __get_uploadvolumefactor(self, torrent):
|
||||
# uploadvolumefactor
|
||||
# uploadvolumefactor int
|
||||
selector = self.fields.get('uploadvolumefactor', {})
|
||||
if not selector:
|
||||
return
|
||||
@@ -535,7 +544,7 @@ class TorrentSpider:
|
||||
self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1))
|
||||
|
||||
def __get_labels(self, torrent):
|
||||
# labels
|
||||
# labels ['label1', 'label2']
|
||||
if 'labels' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('labels', {})
|
||||
@@ -548,7 +557,7 @@ class TorrentSpider:
|
||||
self.torrents_info['labels'] = []
|
||||
|
||||
def __get_free_date(self, torrent):
|
||||
# free date
|
||||
# free date yyyy-mm-dd hh:mm:ss
|
||||
if 'freedate' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('freedate', {})
|
||||
@@ -560,7 +569,7 @@ class TorrentSpider:
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_hit_and_run(self, torrent):
|
||||
# hitandrun
|
||||
# hitandrun True/False
|
||||
if 'hr' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('hr', {})
|
||||
@@ -570,28 +579,71 @@ class TorrentSpider:
|
||||
else:
|
||||
self.torrents_info['hit_and_run'] = False
|
||||
|
||||
def __get_category(self, torrent):
|
||||
# category 电影/电视剧
|
||||
if 'category' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('category', {})
|
||||
category = torrent(selector.get('selector', '')).clone()
|
||||
self.__remove(category, selector)
|
||||
items = self.__attribute_or_text(category, selector)
|
||||
category_value = self.__index(items, selector)
|
||||
category_value = self.__filter_text(category_value,
|
||||
selector.get('filters'))
|
||||
if category_value and self.category:
|
||||
tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []]
|
||||
movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []]
|
||||
if category_value in tv_cats \
|
||||
and category_value not in movie_cats:
|
||||
self.torrents_info['category'] = MediaType.TV.value
|
||||
elif category_value in movie_cats:
|
||||
self.torrents_info['category'] = MediaType.MOVIE.value
|
||||
else:
|
||||
self.torrents_info['category'] = MediaType.UNKNOWN.value
|
||||
else:
|
||||
self.torrents_info['category'] = MediaType.UNKNOWN.value
|
||||
|
||||
def get_info(self, torrent) -> dict:
|
||||
"""
|
||||
解析单条种子数据
|
||||
"""
|
||||
self.torrents_info = {}
|
||||
try:
|
||||
# 标题
|
||||
self.__get_title(torrent)
|
||||
# 描述
|
||||
self.__get_description(torrent)
|
||||
# 详情页面
|
||||
self.__get_detail(torrent)
|
||||
# 下载链接
|
||||
self.__get_download(torrent)
|
||||
# 完成数
|
||||
self.__get_grabs(torrent)
|
||||
# 下载数
|
||||
self.__get_leechers(torrent)
|
||||
# 做种数
|
||||
self.__get_seeders(torrent)
|
||||
# 大小
|
||||
self.__get_size(torrent)
|
||||
# IMDBID
|
||||
self.__get_imdbid(torrent)
|
||||
# 下载系数
|
||||
self.__get_downloadvolumefactor(torrent)
|
||||
# 上传系数
|
||||
self.__get_uploadvolumefactor(torrent)
|
||||
# 发布时间
|
||||
self.__get_pubdate(torrent)
|
||||
# 已发布时间
|
||||
self.__get_date_elapsed(torrent)
|
||||
# 免费载止时间
|
||||
self.__get_free_date(torrent)
|
||||
# 标签
|
||||
self.__get_labels(torrent)
|
||||
# HR
|
||||
self.__get_hit_and_run(torrent)
|
||||
# 分类
|
||||
self.__get_category(torrent)
|
||||
|
||||
except Exception as err:
|
||||
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
|
||||
return self.torrents_info
|
||||
@@ -608,8 +660,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])
|
||||
@@ -623,8 +675,13 @@ class TorrentSpider:
|
||||
text = text.strip()
|
||||
elif method_name == "appendleft":
|
||||
text = f"{args}{text}"
|
||||
elif method_name == "querystring":
|
||||
parsed_url = urlparse(text)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
param_value = query_params.get(args)
|
||||
text = param_value[0] if param_value else ''
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}')
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -28,6 +28,16 @@ class JellyfinModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.jellyfin.is_inactive():
|
||||
self.jellyfin.reconnect()
|
||||
if not self.jellyfin.get_user():
|
||||
return False, "无法连接Jellyfin,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def user_authenticate(self, name: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
使用Emby用户辅助完成用户认证
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,16 @@ class PlexModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.plex.is_inactive():
|
||||
self.plex.reconnect()
|
||||
if not self.plex.get_librarys():
|
||||
return False, "无法连接Plex,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MEDIASERVER", "plex"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ class QbittorrentModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.qbittorrent.is_inactive():
|
||||
self.qbittorrent.reconnect()
|
||||
if self.qbittorrent.is_inactive():
|
||||
return False, "无法连接Qbittorrent,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "DOWNLOADER", "qbittorrent"
|
||||
|
||||
@@ -113,7 +123,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
# 获取种子Hash
|
||||
torrent_hash = self.qbittorrent.get_torrent_id_by_tag(tags=tag)
|
||||
if not torrent_hash:
|
||||
return None, f"获取种子Hash失败:{content}"
|
||||
return None, f"下载任务添加成功,但获取Qbittorrent任务信息失败:{content}"
|
||||
else:
|
||||
if is_paused:
|
||||
# 种子文件
|
||||
@@ -206,7 +216,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 +246,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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -180,7 +179,7 @@ class Qbittorrent(metaclass=Singleton):
|
||||
通过标签多次尝试获取刚添加的种子ID,并移除标签
|
||||
"""
|
||||
torrent_id = None
|
||||
# QB添加下载后需要时间,重试5次每次等待5秒
|
||||
# QB添加下载后需要时间,重试10次每次等待3秒
|
||||
for i in range(1, 10):
|
||||
time.sleep(3)
|
||||
torrent_id = self.__get_last_add_torrentid_by_tag(tags=tags,
|
||||
|
||||
@@ -19,6 +19,15 @@ class SlackModule(_ModuleBase):
|
||||
def stop(self):
|
||||
self.slack.stop()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.slack.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "Slack未就续,请检查参数设置和网络连接"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "slack"
|
||||
|
||||
|
||||
@@ -87,6 +87,12 @@ class Slack:
|
||||
except Exception as err:
|
||||
logger.error("Slack消息接收服务停止失败: %s" % str(err))
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return True if self._client else False
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""):
|
||||
"""
|
||||
发送Telegram消息
|
||||
|
||||
@@ -34,6 +34,9 @@ class SubtitleModule(_ModuleBase):
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
def test(self):
|
||||
pass
|
||||
|
||||
def download_added(self, context: Context, download_dir: Path, torrent_path: Path = None) -> None:
|
||||
"""
|
||||
添加下载任务成功后,从站点下载字幕,保存到下载目录
|
||||
|
||||
@@ -16,6 +16,15 @@ class SynologyChatModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.synologychat.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "SynologyChat未就续,请检查参数设置、网络连接以及机器人是否可见"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "synologychat"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -26,6 +25,17 @@ class SynologyChat(metaclass=Singleton):
|
||||
def check_token(self, token: str) -> bool:
|
||||
return True if token == self._token else False
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
if not self._webhook_url or not self._token:
|
||||
return False
|
||||
ret = self.__get_bot_users()
|
||||
if ret:
|
||||
return True
|
||||
return False
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
|
||||
@@ -18,6 +18,15 @@ class TelegramModule(_ModuleBase):
|
||||
def stop(self):
|
||||
self.telegram.stop()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.telegram.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "Telegram未就续,请检查参数设置和网络连接"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "telegram"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -62,6 +61,12 @@ class Telegram(metaclass=Singleton):
|
||||
self._polling_thread.start()
|
||||
logger.info("Telegram消息接收服务启动")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return self._bot is not None
|
||||
|
||||
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送Telegram消息
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbapi import TmdbHelper
|
||||
from app.schemas.types import MediaType, MediaImageType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
@@ -38,18 +39,32 @@ class TheMovieDbModule(_ModuleBase):
|
||||
def stop(self):
|
||||
self.cache.save()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils(proxies=settings.PROXY).get_res(
|
||||
f"https://{settings.TMDB_API_DOMAIN}/3/movie/550?api_key={settings.TMDB_API_KEY}")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接 {settings.TMDB_API_DOMAIN},错误码:{ret.status_code}"
|
||||
return False, f"{settings.TMDB_API_DOMAIN} 网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
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 +72,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 +132,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
logger.error("识别媒体信息时未提供元数据或tmdbid")
|
||||
return None
|
||||
# 保存到缓存
|
||||
if meta:
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from xml.dom import minidom
|
||||
@@ -135,14 +136,15 @@ class TmdbScraper:
|
||||
file_path=file_path)
|
||||
# 集的图片
|
||||
episode_image = episodeinfo.get("still_path")
|
||||
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
|
||||
Path(episode_image).suffix)
|
||||
if episode_image and (self._force_img or not image_path.exists()):
|
||||
self.__save_image(
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
|
||||
image_path)
|
||||
if episode_image:
|
||||
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
|
||||
Path(episode_image).suffix)
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
|
||||
image_path)
|
||||
except Exception as e:
|
||||
logger.error(f"{file_path} 刮削失败:{str(e)}")
|
||||
logger.error(f"{file_path} 刮削失败:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
@staticmethod
|
||||
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
||||
@@ -358,7 +360,7 @@ class TmdbScraper:
|
||||
"""
|
||||
try:
|
||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||
r = RequestUtils().get_res(url=url, raise_exception=True)
|
||||
r = RequestUtils(proxies=settings.PROXY).get_res(url=url, raise_exception=True)
|
||||
if r:
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, r.content)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
from typing import Optional, Tuple, Union
|
||||
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.thetvdb import tvdbapi
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class TheTvDbModule(_ModuleBase):
|
||||
|
||||
tvdb: tvdbapi.Tvdb = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
@@ -20,6 +19,17 @@ class TheTvDbModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils(proxies=settings.PROXY).get_res("https://api.thetvdb.com/series/81189")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接 api.thetvdb.com,错误码:{ret.status_code}"
|
||||
return False, "api.thetvdb.com 网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ class TransmissionModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if self.transmission.is_inactive():
|
||||
self.transmission.reconnect()
|
||||
if self.transmission.is_inactive():
|
||||
return False, "无法连接Transmission,请检查参数配置"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "DOWNLOADER", "transmission"
|
||||
|
||||
@@ -121,20 +131,25 @@ class TransmissionModule(_ModuleBase):
|
||||
return torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
|
||||
# 需要的文件信息
|
||||
file_ids = []
|
||||
unwanted_file_ids = []
|
||||
for torrent_file in torrent_files:
|
||||
file_id = torrent_file.id
|
||||
file_name = torrent_file.name
|
||||
meta_info = MetaInfo(file_name)
|
||||
if not meta_info.episode_list:
|
||||
unwanted_file_ids.append(file_id)
|
||||
continue
|
||||
selected = set(meta_info.episode_list).issubset(set(episodes))
|
||||
if not selected:
|
||||
unwanted_file_ids.append(file_id)
|
||||
continue
|
||||
file_ids.append(file_id)
|
||||
# 选择文件
|
||||
self.transmission.set_files(torrent_hash, file_ids)
|
||||
self.transmission.set_unwanted_files(torrent_hash, unwanted_file_ids)
|
||||
# 开始任务
|
||||
self.transmission.start_torrents(torrent_hash)
|
||||
return torrent_hash, "添加下载任务成功"
|
||||
else:
|
||||
return torrent_hash, "添加下载任务成功"
|
||||
|
||||
@@ -220,13 +235,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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -243,6 +242,19 @@ class Transmission(metaclass=Singleton):
|
||||
logger.error(f"设置下载文件状态出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def set_unwanted_files(self, tid: str, file_ids: list) -> bool:
|
||||
"""
|
||||
设置下载文件的状态
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
try:
|
||||
self.trc.change_torrent(ids=tid, files_unwanted=file_ids)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"设置下载文件状态出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def transfer_info(self) -> Optional[SessionStats]:
|
||||
"""
|
||||
获取传输信息
|
||||
@@ -360,3 +372,4 @@ class Transmission(metaclass=Singleton):
|
||||
except Exception as err:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
|
||||
128
app/modules/vocechat/__init__.py
Normal file
128
app/modules/vocechat/__init__.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import json
|
||||
from typing import Optional, Union, List, Tuple, Any, Dict
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase, checkMessage
|
||||
from app.modules.vocechat.vocechat import VoceChat
|
||||
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||
|
||||
|
||||
class VoceChatModule(_ModuleBase):
|
||||
vocechat: VoceChat = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
self.vocechat = VoceChat()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.vocechat.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "获取VoceChat频道失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "vocechat"
|
||||
|
||||
@staticmethod
|
||||
def message_parser(body: Any, form: Any,
|
||||
args: Any) -> Optional[CommingMessage]:
|
||||
"""
|
||||
解析消息内容,返回字典,注意以下约定值:
|
||||
userid: 用户ID
|
||||
username: 用户名
|
||||
text: 内容
|
||||
:param body: 请求体
|
||||
:param form: 表单
|
||||
:param args: 参数
|
||||
:return: 渠道、消息体
|
||||
"""
|
||||
try:
|
||||
"""
|
||||
{
|
||||
"created_at": 1672048481664, //消息创建的时间戳
|
||||
"detail": {
|
||||
"content": "hello this is my message to you", //消息内容
|
||||
"content_type": "text/plain", //消息类型,text/plain:纯文本消息,text/markdown:markdown消息,vocechat/file:文件类消息
|
||||
"expires_in": null, //消息过期时长,如果有大于0数字,说明该消息是个限时消息
|
||||
"properties": null, //一些有关消息的元数据,比如at信息,文件消息的具体类型信息,如果是个图片消息,还会有一些宽高,图片名称等元信息
|
||||
"type": "normal" //消息类型,normal代表是新消息
|
||||
},
|
||||
"from_uid": 7910, //来自于谁
|
||||
"mid": 2978, //消息ID
|
||||
"target": { "gid": 2 } //发送给谁,gid代表是发送给频道,uid代表是发送给个人,此时的数据结构举例:{"uid":1}
|
||||
}
|
||||
"""
|
||||
# 报文体
|
||||
msg_body = json.loads(body)
|
||||
# 类型
|
||||
msg_type = msg_body.get("detail", {}).get("type")
|
||||
if msg_type != "normal":
|
||||
# 非新消息
|
||||
return None
|
||||
logger.debug(f"收到VoceChat请求:{msg_body}")
|
||||
# token校验
|
||||
token = args.get("token")
|
||||
if not token or token != settings.API_TOKEN:
|
||||
logger.warn(f"VoceChat请求token校验失败:{token}")
|
||||
return None
|
||||
# 文本内容
|
||||
content = msg_body.get("detail", {}).get("content")
|
||||
# 用户ID
|
||||
gid = msg_body.get("target", {}).get("gid")
|
||||
if gid and str(gid) == str(settings.VOCECHAT_CHANNEL_ID):
|
||||
# 来自监听频道的消息
|
||||
userid = f"GID#{gid}"
|
||||
else:
|
||||
# 来自个人的消息
|
||||
userid = f"UID#{msg_body.get('from_uid')}"
|
||||
|
||||
# 处理消息内容
|
||||
if content and userid:
|
||||
logger.info(f"收到VoceChat消息:userid={userid}, text={content}")
|
||||
return CommingMessage(channel=MessageChannel.VoceChat,
|
||||
userid=userid, username=userid, text=content)
|
||||
except Exception as err:
|
||||
logger.error(f"VoceChat消息处理发生错误:{str(err)}")
|
||||
return None
|
||||
|
||||
@checkMessage(MessageChannel.VoceChat)
|
||||
def post_message(self, message: Notification) -> None:
|
||||
"""
|
||||
发送消息
|
||||
:param message: 消息内容
|
||||
:return: 成功或失败
|
||||
"""
|
||||
self.vocechat.send_msg(title=message.title, text=message.text, userid=message.userid)
|
||||
|
||||
@checkMessage(MessageChannel.VoceChat)
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
||||
"""
|
||||
发送媒体信息选择列表
|
||||
:param message: 消息内容
|
||||
:param medias: 媒体列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
# 先发送标题
|
||||
self.vocechat.send_msg(title=message.title, userid=message.userid)
|
||||
# 再发送内容
|
||||
return self.vocechat.send_medias_msg(title=message.title, medias=medias, userid=message.userid)
|
||||
|
||||
@checkMessage(MessageChannel.VoceChat)
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
||||
"""
|
||||
发送种子信息选择列表
|
||||
:param message: 消息内容
|
||||
:param torrents: 种子列表
|
||||
:return: 成功或失败
|
||||
"""
|
||||
return self.vocechat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]):
|
||||
pass
|
||||
191
app/modules/vocechat/vocechat.py
Normal file
191
app/modules/vocechat/vocechat.py
Normal file
@@ -0,0 +1,191 @@
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional, List
|
||||
|
||||
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.string import StringUtils
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class VoceChat:
|
||||
# host
|
||||
_host = None
|
||||
# apikey
|
||||
_apikey = None
|
||||
# 频道ID
|
||||
_channel_id = None
|
||||
# 请求对象
|
||||
_client = None
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
self._host = settings.VOCECHAT_HOST
|
||||
if self._host:
|
||||
if not self._host.endswith("/"):
|
||||
self._host += "/"
|
||||
if not self._host.startswith("http"):
|
||||
self._playhost = "http://" + self._host
|
||||
self._apikey = settings.VOCECHAT_API_KEY
|
||||
self._channel_id = settings.VOCECHAT_CHANNEL_ID
|
||||
if self._apikey and self._host and self._channel_id:
|
||||
self._client = RequestUtils(headers={
|
||||
"content-type": "text/markdown",
|
||||
"x-api-key": self._apikey,
|
||||
"accept": "application/json; charset=utf-8"
|
||||
})
|
||||
|
||||
def get_state(self):
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return True if self.get_groups() else False
|
||||
|
||||
def get_groups(self):
|
||||
"""
|
||||
获取频道列表
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
result = self._client.get_res(f"{self._host}api/bot")
|
||||
if result and result.status_code == 200:
|
||||
return result.json()
|
||||
|
||||
def send_msg(self, title: str, text: str = "", userid: str = None) -> Optional[bool]:
|
||||
"""
|
||||
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
|
||||
:param title: 消息标题
|
||||
:param text: 消息内容
|
||||
:param userid: 消息发送对象的ID,为空则发给所有人
|
||||
:return: 发送状态,错误信息
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
if not title and not text:
|
||||
logger.warn("标题和内容不能同时为空")
|
||||
return False
|
||||
|
||||
try:
|
||||
if text:
|
||||
caption = f"**{title}**\n{text}"
|
||||
else:
|
||||
caption = f"**{title}**"
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = f"GID#{self._channel_id}"
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def send_medias_msg(self, title: str, medias: List[MediaInfo], userid: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送列表类消息
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
try:
|
||||
index, caption = 1, "**%s**" % title
|
||||
for media in medias:
|
||||
if media.vote_average:
|
||||
caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (caption,
|
||||
index,
|
||||
media.title_year,
|
||||
media.detail_link,
|
||||
f"类型:{media.type.value}",
|
||||
f"评分:{media.vote_average}")
|
||||
else:
|
||||
caption = "%s\n%s. [%s](%s)\n_%s_" % (caption,
|
||||
index,
|
||||
media.title_year,
|
||||
media.detail_link,
|
||||
f"类型:{media.type.value}")
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = f"GID#{self._channel_id}"
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
def send_torrents_msg(self, torrents: List[Context],
|
||||
userid: str = "", title: str = "") -> Optional[bool]:
|
||||
"""
|
||||
发送列表消息
|
||||
"""
|
||||
if not self._client:
|
||||
return None
|
||||
|
||||
if not torrents:
|
||||
return False
|
||||
|
||||
try:
|
||||
index, caption = 1, "**%s**" % title
|
||||
for context in torrents:
|
||||
torrent = context.torrent_info
|
||||
site_name = torrent.site_name
|
||||
meta = MetaInfo(torrent.title, torrent.description)
|
||||
link = torrent.page_url
|
||||
title = f"{meta.season_episode} " \
|
||||
f"{meta.resource_term} " \
|
||||
f"{meta.video_term} " \
|
||||
f"{meta.release_group}"
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
free = torrent.volume_factor
|
||||
seeder = f"{torrent.seeders}↑"
|
||||
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
chat_id = userid
|
||||
else:
|
||||
chat_id = f"GID#{self._channel_id}"
|
||||
|
||||
return self.__send_request(userid=chat_id, caption=caption)
|
||||
|
||||
except Exception as msg_e:
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __send_request(self, userid: str, caption: str) -> bool:
|
||||
"""
|
||||
向VoceChat发送报文
|
||||
userid格式:UID#xxx / GID#xxx
|
||||
"""
|
||||
if not self._client:
|
||||
return False
|
||||
if userid.startswith("GID#"):
|
||||
action = "send_to_group"
|
||||
else:
|
||||
action = "send_to_user"
|
||||
idstr = userid[4:]
|
||||
|
||||
with lock:
|
||||
result = self._client.post_res(f"{self._host}api/bot/{action}/{idstr}", data=caption.encode("utf-8"))
|
||||
if result and result.status_code == 200:
|
||||
return True
|
||||
elif result is not None:
|
||||
logger.error(f"VoceChat发送消息失败,错误码:{result.status_code}")
|
||||
return False
|
||||
else:
|
||||
raise Exception("VoceChat发送消息失败,连接失败")
|
||||
@@ -20,6 +20,15 @@ class WechatModule(_ModuleBase):
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
state = self.wechat.get_state()
|
||||
if state:
|
||||
return True, ""
|
||||
return False, "获取微信token失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MESSAGER", "wechat"
|
||||
|
||||
|
||||
@@ -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,13 @@ class WeChat(metaclass=Singleton):
|
||||
if self._corpid and self._appsecret and self._appid:
|
||||
self.__get_access_token()
|
||||
|
||||
def get_state(self):
|
||||
"""
|
||||
获取状态
|
||||
"""
|
||||
return True if self.__get_access_token else False
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __get_access_token(self, force=False):
|
||||
"""
|
||||
获取微信Token
|
||||
@@ -75,6 +82,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
|
||||
|
||||
@@ -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
|
||||
|
||||
201
app/scheduler.py
201
app/scheduler.py
@@ -1,16 +1,18 @@
|
||||
import logging
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List
|
||||
|
||||
import pytz
|
||||
from apscheduler.executors.pool import ThreadPoolExecutor
|
||||
from apscheduler.jobstores.base import JobLookupError
|
||||
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
|
||||
@@ -39,10 +41,12 @@ class Scheduler(metaclass=Singleton):
|
||||
# 定时服务
|
||||
_scheduler = BackgroundScheduler(timezone=settings.TZ,
|
||||
executors={
|
||||
'default': ThreadPoolExecutor(20)
|
||||
'default': ThreadPoolExecutor(100)
|
||||
})
|
||||
# 退出事件
|
||||
_event = threading.Event()
|
||||
# 锁
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -56,7 +60,7 @@ class Scheduler(metaclass=Singleton):
|
||||
# 各服务的运行状态
|
||||
self._jobs = {
|
||||
"cookiecloud": {
|
||||
"func": CookieCloudChain().process,
|
||||
"func": SiteChain().sync_cookies,
|
||||
"running": False,
|
||||
},
|
||||
"mediaserver_sync": {
|
||||
@@ -93,13 +97,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 +112,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 +179,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'
|
||||
}
|
||||
@@ -229,25 +236,8 @@ class Scheduler(metaclass=Singleton):
|
||||
)
|
||||
|
||||
# 注册插件公共服务
|
||||
plugin_services = PluginManager().get_plugin_services()
|
||||
for service in plugin_services:
|
||||
try:
|
||||
self._jobs[service["id"]] = {
|
||||
"func": service["func"],
|
||||
"running": False,
|
||||
}
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=service["id"],
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': service["id"]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件服务失败:{str(e)} - {service}")
|
||||
for pid in PluginManager().get_running_plugin_ids():
|
||||
self.update_plugin_job(pid)
|
||||
|
||||
# 打印服务
|
||||
logger.debug(self._scheduler.print_jobs())
|
||||
@@ -260,51 +250,136 @@ class Scheduler(metaclass=Singleton):
|
||||
启动定时服务
|
||||
"""
|
||||
# 处理job_id格式
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.get("running"):
|
||||
logger.warning(f"定时任务 {job_id} 正在运行 ...")
|
||||
return
|
||||
self._jobs[job_id]["running"] = True
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
if job.get("running"):
|
||||
logger.warning(f"定时任务 {job_id} 正在运行 ...")
|
||||
return
|
||||
self._jobs[job_id]["running"] = True
|
||||
# 开始运行
|
||||
try:
|
||||
if not kwargs:
|
||||
kwargs = job.get("kwargs") or {}
|
||||
job["func"](*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务 {job_id} 执行失败:{str(e)}")
|
||||
self._jobs[job_id]["running"] = False
|
||||
# 运行结束
|
||||
with self._lock:
|
||||
try:
|
||||
self._jobs[job_id]["running"] = False
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def update_plugin_job(self, pid: str):
|
||||
"""
|
||||
更新插件定时服务
|
||||
"""
|
||||
# 移除该插件的全部服务
|
||||
self.remove_plugin_job(pid)
|
||||
# 获取插件服务列表
|
||||
with self._lock:
|
||||
try:
|
||||
plugin_services = PluginManager().run_plugin_method(pid, "get_service") or []
|
||||
except Exception as e:
|
||||
logger.error(f"运行插件 {pid} 服务失败:{str(e)} - {traceback.format_exc()}")
|
||||
return
|
||||
# 获取插件名称
|
||||
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
|
||||
# 开始注册插件服务
|
||||
for service in plugin_services:
|
||||
try:
|
||||
sid = f"{service['id']}"
|
||||
job_id = sid.split("|")[0]
|
||||
if job_id not in self._jobs:
|
||||
self._jobs[job_id] = {
|
||||
"func": service["func"],
|
||||
"name": service["name"],
|
||||
"pid": pid,
|
||||
"plugin_name": plugin_name,
|
||||
"running": False,
|
||||
}
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=sid,
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': job_id
|
||||
}
|
||||
)
|
||||
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
|
||||
|
||||
def remove_plugin_job(self, pid: str):
|
||||
"""
|
||||
移除插件定时服务
|
||||
"""
|
||||
with self._lock:
|
||||
# 获取插件名称
|
||||
plugin_name = PluginManager().get_plugin_attr(pid, "plugin_name")
|
||||
for job_id, service in self._jobs.copy().items():
|
||||
try:
|
||||
if service.get("pid") == pid:
|
||||
self._jobs.pop(job_id, None)
|
||||
try:
|
||||
self._scheduler.remove_job(job_id)
|
||||
except JobLookupError:
|
||||
pass
|
||||
logger.info(f"移除插件服务({plugin_name}):{service.get('name')}")
|
||||
except Exception as e:
|
||||
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
|
||||
|
||||
def list(self) -> List[schemas.ScheduleInfo]:
|
||||
"""
|
||||
当前所有任务
|
||||
"""
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
job_id = job.id.split("|")[0]
|
||||
if not self._jobs.get(job_id):
|
||||
continue
|
||||
# 任务状态
|
||||
status = "正在运行" if self._jobs[job_id].get("running") else "等待"
|
||||
# 下次运行时间
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=job.name,
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
return schedulers
|
||||
with self._lock:
|
||||
# 返回计时任务
|
||||
schedulers = []
|
||||
# 去重
|
||||
added = []
|
||||
jobs = self._scheduler.get_jobs()
|
||||
# 按照下次运行时间排序
|
||||
jobs.sort(key=lambda x: x.next_run_time)
|
||||
# 将正在运行的任务提取出来 (保障一次性任务正常显示)
|
||||
for job_id, service in self._jobs.items():
|
||||
name = service.get("name")
|
||||
plugin_name = service.get("plugin_name")
|
||||
if service.get("running") and name and plugin_name:
|
||||
if name not in added:
|
||||
added.append(name)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=name,
|
||||
provider=plugin_name,
|
||||
status="正在运行",
|
||||
))
|
||||
# 获取其他待执行任务
|
||||
for job in jobs:
|
||||
if job.name not in added:
|
||||
added.append(job.name)
|
||||
else:
|
||||
continue
|
||||
job_id = job.id.split("|")[0]
|
||||
service = self._jobs.get(job_id)
|
||||
if not service:
|
||||
continue
|
||||
# 任务状态
|
||||
status = "正在运行" if service.get("running") else "等待"
|
||||
# 下次运行时间
|
||||
next_run = TimerUtils.time_difference(job.next_run_time)
|
||||
schedulers.append(schemas.ScheduleInfo(
|
||||
id=job_id,
|
||||
name=job.name,
|
||||
provider=service.get("plugin_name", "[系统]"),
|
||||
status=status,
|
||||
next_run=next_run
|
||||
))
|
||||
return schedulers
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
|
||||
@@ -67,6 +67,8 @@ class MediaInfo(BaseModel):
|
||||
type: Optional[str] = None
|
||||
# 媒体标题
|
||||
title: Optional[str] = None
|
||||
# 英文标题
|
||||
en_title: Optional[str] = None
|
||||
# 年份
|
||||
year: Optional[str] = None
|
||||
# 标题(年份)
|
||||
@@ -92,7 +94,7 @@ class MediaInfo(BaseModel):
|
||||
# 海报图片
|
||||
poster_path: Optional[str] = None
|
||||
# 评分
|
||||
vote_average: Optional[int] = 0
|
||||
vote_average: Optional[float] = 0
|
||||
# 描述
|
||||
overview: Optional[str] = None
|
||||
# 二级分类
|
||||
|
||||
@@ -56,6 +56,8 @@ class ScheduleInfo(BaseModel):
|
||||
id: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 提供者
|
||||
provider: Optional[str] = None
|
||||
# 状态
|
||||
status: Optional[str] = None
|
||||
# 下次执行时间
|
||||
|
||||
@@ -53,3 +53,5 @@ class NotificationSwitch(BaseModel):
|
||||
slack: Optional[bool] = False
|
||||
# SynologyChat开关
|
||||
synologychat: Optional[bool] = False
|
||||
# VoceChat开关
|
||||
vocechat: Optional[bool] = False
|
||||
|
||||
@@ -59,6 +59,8 @@ class Subscribe(BaseModel):
|
||||
current_priority: Optional[int] = None
|
||||
# 保存路径
|
||||
save_path: Optional[str] = None
|
||||
# 是否使用 imdbid 搜索
|
||||
search_imdbid: Optional[int] = 0
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -40,6 +40,8 @@ class EventType(Enum):
|
||||
NameRecognize = "name.recognize"
|
||||
# 名称识别结果
|
||||
NameRecognizeResult = "name.recognize.result"
|
||||
# 缓存站点图标
|
||||
CacheSiteIcon = "cache.siteicon"
|
||||
|
||||
|
||||
# 系统配置Key字典
|
||||
@@ -114,3 +116,4 @@ class MessageChannel(Enum):
|
||||
Telegram = "Telegram"
|
||||
Slack = "Slack"
|
||||
SynologyChat = "SynologyChat"
|
||||
VoceChat = "VoceChat"
|
||||
|
||||
@@ -24,7 +24,8 @@ class SiteUtils:
|
||||
' or contains(@onclick, "logout")'
|
||||
' or contains(@href, "usercp")]',
|
||||
'//form[contains(@action, "logout")]',
|
||||
'//div[@class="user-info-side"]'
|
||||
'//div[@class="user-info-side"]',
|
||||
'//a[@id="myitem"]'
|
||||
]
|
||||
for xpath in xpaths:
|
||||
if html.xpath(xpath):
|
||||
|
||||
@@ -56,7 +56,7 @@ class TimerUtils:
|
||||
|
||||
days = time_difference.days
|
||||
hours, remainder = divmod(time_difference.seconds, 3600)
|
||||
minutes, _ = divmod(remainder, 60)
|
||||
minutes, second = divmod(remainder, 60)
|
||||
|
||||
time_difference_string = ""
|
||||
if days > 0:
|
||||
@@ -65,6 +65,8 @@ class TimerUtils:
|
||||
time_difference_string += f"{hours}小时"
|
||||
if minutes > 0:
|
||||
time_difference_string += f"{minutes}分钟"
|
||||
if not time_difference_string and second:
|
||||
time_difference_string = f"{second}秒"
|
||||
|
||||
return time_difference_string
|
||||
|
||||
|
||||
180
config/app.env
180
config/app.env
@@ -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/bing,tmdb要求能正常连接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
|
||||
# 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
# 配置电影的分类策略, 配置为空或者不配置该项则不启用电影分类
|
||||
####### 配置说明 #######
|
||||
# 1. 该配置文件用于配置电影和电视剧的分类策略,配置后程序会按照配置的分类策略名称进行分类,配置文件采用yaml格式,需要严格附合语法规则
|
||||
# 2. 配置文件中的一级分类名称:`movie`、`tv` 为固定名称不可修改,二级名称同时也是目录名称,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
|
||||
# 3. 支持的分类条件:
|
||||
# `original_language` 语种,具体含义参考下方字典
|
||||
# `production_countries` 国家或地区(电影)、`origin_country` 国家或地区(电视剧),具体含义参考下方字典
|
||||
# `genre_ids` 内容类型,具体含义参考下方字典
|
||||
# themoviedb 详情API返回的其它一级字段
|
||||
# 4. 配置多项条件时需要同时满足,一个条件需要匹配多个值是使用`,`分隔
|
||||
|
||||
# 配置电影的分类策略
|
||||
movie:
|
||||
# 分类名同时也是目录名,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
|
||||
# 分类名同时也是目录名
|
||||
动画电影:
|
||||
# 分类依据,可以是:original_language 语种、production_countries 国家或地区、genre_ids 内容类型等,只要TMDB API返回的字段中有就行
|
||||
# 配置多项条件时,需要同时满足;不需要的匹配项可以删掉或者配置为空
|
||||
# 匹配 genre_ids 内容类型,16是动漫
|
||||
genre_ids: '16'
|
||||
华语电影:
|
||||
# 匹配语种
|
||||
original_language: 'zh,cn,bo,za'
|
||||
# 未配置任何过滤条件时,则按先后顺序不符合上面分类的都会在这个分类下,建议配置在最末尾
|
||||
# 未匹配以上条件时,分类为外语电影
|
||||
外语电影:
|
||||
|
||||
# 配置电视剧的分类策略, 配置为空或者不配置该项则不启用电视剧分类
|
||||
# 配置电视剧的分类策略
|
||||
tv:
|
||||
# 分类名同时也是目录名,会按先后顺序匹配,匹配后程序会按这个名称建立二级目录
|
||||
# 分类名同时也是目录名
|
||||
动漫:
|
||||
# 分类依据,可以是:original_language 语种、origin_country 国家或地区、genre_ids 内容类型等,只要TMDB API返回的字段中有就行
|
||||
# 配置多项条件时,需要同时满足;不需要的匹配项可以删掉或者配置为空
|
||||
# 匹配 genre_ids 内容类型,16是动漫
|
||||
genre_ids: '16'
|
||||
纪录片:
|
||||
@@ -163,7 +169,7 @@ tv:
|
||||
# cn 中文
|
||||
# zu 祖鲁语
|
||||
|
||||
## origin_country 国家地区 字典
|
||||
## origin_country/production_countries 国家地区 字典
|
||||
# AR 阿根廷
|
||||
# AU 澳大利亚
|
||||
# BE 比利时
|
||||
|
||||
30
database/versions/127a25fdf0e8_1_0_13.py
Normal file
30
database/versions/127a25fdf0e8_1_0_13.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""1.0.13
|
||||
|
||||
Revision ID: 127a25fdf0e8
|
||||
Revises: d71e624f0208
|
||||
Create Date: 2024-02-24 03:11:32.005540
|
||||
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '127a25fdf0e8'
|
||||
down_revision = 'd71e624f0208'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with contextlib.suppress(Exception):
|
||||
with op.batch_alter_table("subscribe") as batch_op:
|
||||
batch_op.add_column(sa.Column('search_imdbid', sa.Integer, nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
31
database/versions/f94cd1217fd7_1_0_14.py
Normal file
31
database/versions/f94cd1217fd7_1_0_14.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""1_0_14
|
||||
|
||||
Revision ID: f94cd1217fd7
|
||||
Revises: 127a25fdf0e8
|
||||
Create Date: 2024-03-06 19:19:33.053186
|
||||
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f94cd1217fd7'
|
||||
down_revision = '127a25fdf0e8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with contextlib.suppress(Exception):
|
||||
with op.batch_alter_table("subscribe") as batch_op:
|
||||
batch_op.add_column(sa.Column('manual_total_episode', sa.Integer, nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
12
entrypoint
12
entrypoint
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
92
update
92
update
@@ -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,64 @@ 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
|
||||
# 清理临时目录,上次安装失败可能有残留
|
||||
rm -rf /tmp/*
|
||||
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 /plugins
|
||||
mkdir -p /plugins
|
||||
cp -a /app/app/plugins/* /plugins/
|
||||
# 不备份__init__.py
|
||||
rm -f /plugins/__init__.py
|
||||
# 清空目录
|
||||
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 "插件下载成功"
|
||||
# 恢复插件目录
|
||||
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 +107,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
|
||||
@@ -118,4 +130,4 @@ elif [[ "${MOVIEPILOT_AUTO_UPDATE}" = "false" ]]; then
|
||||
echo "程序自动升级已关闭,如需自动升级请在创建容器时设置环境变量:MOVIEPILOT_AUTO_UPDATE=release"
|
||||
else
|
||||
echo "MOVIEPILOT_AUTO_UPDATE 变量设置错误"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.6.1'
|
||||
APP_VERSION = 'v1.7.0-1'
|
||||
|
||||
Reference in New Issue
Block a user