mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-09 21:32:39 +08:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
166
README.md
166
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:** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`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远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址,地址相对路径均为:`/api/v1/message/`。
|
||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`,其中`moviepilot`为设置的`API_TOKEN`。
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr,可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`。
|
||||
- 将WEB页面添加到手机桌面图标可获得与App一样的使用体验。
|
||||
|
||||
### **注意**
|
||||
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
|
||||
@@ -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,17 +34,17 @@ async def login_access_token(
|
||||
)
|
||||
if not user:
|
||||
# 请求协助认证
|
||||
logger.warn("登录用户本地不匹配,尝试辅助认证 ...")
|
||||
logger.warn(f"登录用户 {form_data.username} 本地用户名或密码不匹配,尝试辅助认证 ...")
|
||||
token = UserChain().user_authenticate(form_data.username, form_data.password)
|
||||
if not token:
|
||||
logger.warn(f"用户 {form_data.username} 登录失败!")
|
||||
raise HTTPException(status_code=401, detail="用户名或密码不正确")
|
||||
else:
|
||||
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token}")
|
||||
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
|
||||
# 加入用户信息表
|
||||
user = User.get_by_name(db=db, name=form_data.username)
|
||||
if not user:
|
||||
logger.info(f"用户不存在,创建普通用户: {form_data.username}")
|
||||
logger.info(f"用户不存在,创建用户: {form_data.username}")
|
||||
user = User(name=form_data.username, is_active=True,
|
||||
is_superuser=False, hashed_password=get_password_hash(token))
|
||||
user.create(db)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -140,12 +140,13 @@ def subscribe_mediaid(
|
||||
if not doubanid:
|
||||
return Subscribe()
|
||||
result = Subscribe.get_by_doubanid(db, doubanid)
|
||||
|
||||
if not result and title:
|
||||
meta = MetaInfo(title)
|
||||
if season:
|
||||
meta.begin_season = season
|
||||
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
# 豆瓣已订阅如果 id 搜索无结果使用标题搜索
|
||||
# 会造成同名结果也会被返回
|
||||
if not result and title:
|
||||
meta = MetaInfo(title)
|
||||
if season:
|
||||
meta.begin_season = season
|
||||
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
|
||||
if result and result.sites:
|
||||
result.sites = json.loads(result.sites)
|
||||
|
||||
@@ -4,12 +4,14 @@ from datetime import datetime
|
||||
from typing import Union, Any
|
||||
|
||||
import tailer
|
||||
from dotenv import set_key
|
||||
from fastapi import APIRouter, HTTPException, Depends, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_token
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
@@ -46,7 +48,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
查询系统环境变量,包括当前版本号
|
||||
"""
|
||||
info = settings.dict(
|
||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD", "API_TOKEN"}
|
||||
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
|
||||
)
|
||||
info.update({
|
||||
"VERSION": APP_VERSION,
|
||||
@@ -57,6 +59,27 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
data=info)
|
||||
|
||||
|
||||
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
|
||||
def set_env_setting(env: dict,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
更新系统环境变量
|
||||
"""
|
||||
for k, v in env.items():
|
||||
if k == "undefined":
|
||||
continue
|
||||
if hasattr(settings, k):
|
||||
if v == "None":
|
||||
v = None
|
||||
setattr(settings, k, v)
|
||||
if v is None:
|
||||
v = ''
|
||||
else:
|
||||
v = str(v)
|
||||
set_key(settings.CONFIG_PATH / "app.env", k, v)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/progress/{process_type}", summary="实时进度")
|
||||
def get_progress(process_type: str, token: str):
|
||||
"""
|
||||
@@ -85,18 +108,32 @@ def get_setting(key: str,
|
||||
"""
|
||||
查询系统设置
|
||||
"""
|
||||
if hasattr(settings, key):
|
||||
value = getattr(settings, key)
|
||||
else:
|
||||
value = SystemConfigOper().get(key)
|
||||
return schemas.Response(success=True, data={
|
||||
"value": SystemConfigOper().get(key)
|
||||
"value": value
|
||||
})
|
||||
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(key: str, value: Union[list, dict, str, int] = None,
|
||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
更新系统设置
|
||||
"""
|
||||
SystemConfigOper().set(key, value)
|
||||
if hasattr(settings, key):
|
||||
if value == "None":
|
||||
value = None
|
||||
setattr(settings, key, value)
|
||||
if value is None:
|
||||
value = ''
|
||||
else:
|
||||
value = str(value)
|
||||
set_key(settings.CONFIG_PATH / "app.env", key, value)
|
||||
else:
|
||||
SystemConfigOper().set(key, value)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -123,9 +160,11 @@ def get_message(token: str):
|
||||
|
||||
|
||||
@router.get("/logging", summary="实时日志")
|
||||
def get_logging(token: str):
|
||||
def get_logging(token: str, length: int = 50):
|
||||
"""
|
||||
实时获取系统日志,返回格式为SSE
|
||||
实时获取系统日志
|
||||
length = -1 时, 返回text/plain
|
||||
否则 返回格式SSE
|
||||
"""
|
||||
if not token or not verify_token(token):
|
||||
raise HTTPException(
|
||||
@@ -133,18 +172,27 @@ def get_logging(token: str):
|
||||
detail="认证失败!",
|
||||
)
|
||||
|
||||
log_path = settings.LOG_PATH / 'moviepilot.log'
|
||||
|
||||
def log_generator():
|
||||
log_path = settings.LOG_PATH / 'moviepilot.log'
|
||||
# 读取文件末尾50行,不使用tailer模块
|
||||
with open(log_path, 'r', encoding='utf-8') as f:
|
||||
for line in f.readlines()[-50:]:
|
||||
for line in f.readlines()[-max(length, 50):]:
|
||||
yield 'data: %s\n\n' % line
|
||||
while True:
|
||||
for text in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||
yield 'data: %s\n\n' % (text or '')
|
||||
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||
yield 'data: %s\n\n' % (t or '')
|
||||
time.sleep(1)
|
||||
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
# 根据length参数返回不同的响应
|
||||
if length == -1:
|
||||
# 返回全部日志作为文本响应
|
||||
with open(log_path, 'r', encoding='utf-8') as file:
|
||||
text = file.read()
|
||||
return Response(content=text, media_type="text/plain")
|
||||
else:
|
||||
# 返回SSE流响应
|
||||
return StreamingResponse(log_generator(), media_type="text/event-stream")
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
@@ -231,6 +279,16 @@ def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
重新加载模块
|
||||
"""
|
||||
ModuleManager().stop()
|
||||
ModuleManager().load_modules()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def execute_command(jobid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
import pickle
|
||||
import re
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict, Tuple
|
||||
@@ -74,7 +75,7 @@ class SearchChain(ChainBase):
|
||||
try:
|
||||
return pickle.loads(results)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
|
||||
return []
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
@@ -122,10 +123,8 @@ class SearchChain(ChainBase):
|
||||
# 搜索关键词
|
||||
if keyword:
|
||||
keywords = [keyword]
|
||||
elif mediainfo.original_title and mediainfo.title != mediainfo.original_title:
|
||||
keywords = [mediainfo.title, mediainfo.original_title]
|
||||
else:
|
||||
keywords = [mediainfo.title]
|
||||
keywords = list({mediainfo.title, mediainfo.original_title, mediainfo.en_title} - {None})
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
@@ -167,6 +166,7 @@ class SearchChain(ChainBase):
|
||||
if mediainfo:
|
||||
self.progress.start(ProgressKey.Search)
|
||||
logger.info(f'开始匹配,总 {_total} 个资源 ...')
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
for torrent in torrents:
|
||||
@@ -380,7 +380,7 @@ class SearchChain(ChainBase):
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
||||
return 0, 0
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
@@ -425,7 +425,7 @@ class SearchChain(ChainBase):
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size is not None and end_size is not None:
|
||||
if begin_size or end_size:
|
||||
meta = MetaInfo(title=t.title, subtitle=t.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -71,11 +71,11 @@ class SubscribeChain(ChainBase):
|
||||
if tmdbinfo:
|
||||
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
|
||||
else:
|
||||
# 识别TMDB信息
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
|
||||
# 识别TMDB信息,不使用缓存
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid, cache=False)
|
||||
else:
|
||||
# 豆瓣识别模式
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid)
|
||||
# 豆瓣识别模式,不使用缓存
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, doubanid=doubanid, cache=False)
|
||||
if mediainfo:
|
||||
# 豆瓣标题处理
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
@@ -96,7 +96,8 @@ class SubscribeChain(ChainBase):
|
||||
# 补充媒体信息
|
||||
mediainfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id)
|
||||
doubanid=mediainfo.douban_id,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.error(f"媒体信息识别失败!")
|
||||
return None, "媒体信息识别失败"
|
||||
@@ -197,7 +198,8 @@ class SubscribeChain(ChainBase):
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid)
|
||||
doubanid=subscribe.doubanid,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
@@ -523,7 +525,8 @@ class SubscribeChain(ChainBase):
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid)
|
||||
doubanid=subscribe.doubanid,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
@@ -701,7 +704,7 @@ class SubscribeChain(ChainBase):
|
||||
return
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
logger.info(f'开始检查订阅:{subscribe.name} ...')
|
||||
logger.info(f'开始更新订阅元数据:{subscribe.name} ...')
|
||||
# 生成元数据
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
@@ -709,14 +712,16 @@ class SubscribeChain(ChainBase):
|
||||
meta.type = MediaType(subscribe.type)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid,
|
||||
cache=False)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
if len(episodes) > (subscribe.total_episode or 0):
|
||||
if len(episodes):
|
||||
total_episode = len(episodes)
|
||||
lack_episode = subscribe.lack_episode + (total_episode - subscribe.total_episode)
|
||||
logger.info(
|
||||
@@ -737,7 +742,7 @@ class SubscribeChain(ChainBase):
|
||||
"total_episode": total_episode,
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
logger.info(f'订阅 {subscribe.name} 更新完成')
|
||||
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
||||
|
||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: List[Context]):
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import re
|
||||
import traceback
|
||||
from typing import Dict, List, Union
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
@@ -98,7 +99,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
if not site.get("rss"):
|
||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||
return []
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False)
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False, timeout=int(site.get("timeout") or 30))
|
||||
if rss_items is None:
|
||||
# rss过期,尝试保留原配置生成新的rss
|
||||
self.__renew_rss_url(domain=domain, site=site)
|
||||
@@ -246,5 +247,5 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
self.post_message(
|
||||
Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
logger.error(f"站点 {domain} RSS链接自动获取失败:{str(e)} - {traceback.format_exc()}")
|
||||
self.post_message(Notification(mtype=NotificationType.SiteMessage, title=f"站点 {domain} RSS链接已过期"))
|
||||
|
||||
@@ -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
|
||||
AUTO_DOWNLOAD_USER: Optional[str] = None
|
||||
# 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
MESSAGER: str = "telegram"
|
||||
# WeChat企业ID
|
||||
WECHAT_CORPID: str = None
|
||||
WECHAT_CORPID: Optional[str] = None
|
||||
# WeChat应用Secret
|
||||
WECHAT_APP_SECRET: str = None
|
||||
WECHAT_APP_SECRET: Optional[str] = None
|
||||
# WeChat应用ID
|
||||
WECHAT_APP_ID: str = None
|
||||
WECHAT_APP_ID: Optional[str] = None
|
||||
# WeChat代理服务器
|
||||
WECHAT_PROXY: str = "https://qyapi.weixin.qq.com"
|
||||
# WeChat Token
|
||||
WECHAT_TOKEN: str = None
|
||||
WECHAT_TOKEN: Optional[str] = None
|
||||
# WeChat EncodingAESKey
|
||||
WECHAT_ENCODING_AESKEY: str = None
|
||||
WECHAT_ENCODING_AESKEY: Optional[str] = None
|
||||
# WeChat 管理员
|
||||
WECHAT_ADMINS: str = None
|
||||
WECHAT_ADMINS: Optional[str] = None
|
||||
# Telegram Bot Token
|
||||
TELEGRAM_TOKEN: str = None
|
||||
TELEGRAM_TOKEN: Optional[str] = None
|
||||
# Telegram Chat ID
|
||||
TELEGRAM_CHAT_ID: str = None
|
||||
TELEGRAM_CHAT_ID: Optional[str] = None
|
||||
# Telegram 用户ID,使用,分隔
|
||||
TELEGRAM_USERS: str = ""
|
||||
# Telegram 管理员ID,使用,分隔
|
||||
@@ -122,11 +122,11 @@ class Settings(BaseSettings):
|
||||
# 下载器监控开关
|
||||
DOWNLOADER_MONITOR: bool = True
|
||||
# Qbittorrent地址,IP:PORT
|
||||
QB_HOST: str = None
|
||||
QB_HOST: Optional[str] = None
|
||||
# Qbittorrent用户名
|
||||
QB_USER: str = None
|
||||
QB_USER: Optional[str] = None
|
||||
# Qbittorrent密码
|
||||
QB_PASSWORD: str = None
|
||||
QB_PASSWORD: Optional[str] = None
|
||||
# Qbittorrent分类自动管理
|
||||
QB_CATEGORY: bool = False
|
||||
# Qbittorrent按顺序下载
|
||||
@@ -134,21 +134,21 @@ class Settings(BaseSettings):
|
||||
# Qbittorrent忽略队列限制,强制继续
|
||||
QB_FORCE_RESUME: bool = False
|
||||
# Transmission地址,IP:PORT
|
||||
TR_HOST: str = None
|
||||
TR_HOST: Optional[str] = None
|
||||
# Transmission用户名
|
||||
TR_USER: str = None
|
||||
TR_USER: Optional[str] = None
|
||||
# Transmission密码
|
||||
TR_PASSWORD: str = None
|
||||
TR_PASSWORD: Optional[str] = None
|
||||
# 种子标签
|
||||
TORRENT_TAG: str = "MOVIEPILOT"
|
||||
# 下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_PATH: str = None
|
||||
DOWNLOAD_PATH: Optional[str] = None
|
||||
# 电影下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_MOVIE_PATH: str = None
|
||||
DOWNLOAD_MOVIE_PATH: Optional[str] = None
|
||||
# 电视剧下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_TV_PATH: str = None
|
||||
DOWNLOAD_TV_PATH: Optional[str] = None
|
||||
# 动漫下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_ANIME_PATH: str = None
|
||||
DOWNLOAD_ANIME_PATH: Optional[str] = None
|
||||
# 下载目录二级分类
|
||||
DOWNLOAD_CATEGORY: bool = False
|
||||
# 下载站点字幕
|
||||
@@ -158,33 +158,33 @@ class Settings(BaseSettings):
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST: str = None
|
||||
MEDIASERVER_SYNC_BLACKLIST: Optional[str] = None
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST: str = None
|
||||
EMBY_HOST: Optional[str] = None
|
||||
# EMBY外网地址,http(s)://DOMAIN:PORT,未设置时使用EMBY_HOST
|
||||
EMBY_PLAY_HOST: str = None
|
||||
EMBY_PLAY_HOST: Optional[str] = None
|
||||
# EMBY Api Key
|
||||
EMBY_API_KEY: str = None
|
||||
EMBY_API_KEY: Optional[str] = None
|
||||
# Jellyfin服务器地址,IP:PORT
|
||||
JELLYFIN_HOST: str = None
|
||||
JELLYFIN_HOST: Optional[str] = None
|
||||
# Jellyfin外网地址,http(s)://DOMAIN:PORT,未设置时使用JELLYFIN_HOST
|
||||
JELLYFIN_PLAY_HOST: str = None
|
||||
JELLYFIN_PLAY_HOST: Optional[str] = None
|
||||
# Jellyfin Api Key
|
||||
JELLYFIN_API_KEY: str = None
|
||||
JELLYFIN_API_KEY: Optional[str] = None
|
||||
# Plex服务器地址,IP:PORT
|
||||
PLEX_HOST: str = None
|
||||
PLEX_HOST: Optional[str] = None
|
||||
# Plex外网地址,http(s)://DOMAIN:PORT,未设置时使用PLEX_HOST
|
||||
PLEX_PLAY_HOST: str = None
|
||||
PLEX_PLAY_HOST: Optional[str] = None
|
||||
# Plex Token
|
||||
PLEX_TOKEN: str = None
|
||||
PLEX_TOKEN: Optional[str] = None
|
||||
# 转移方式 link/copy/move/softlink
|
||||
TRANSFER_TYPE: str = "copy"
|
||||
# CookieCloud服务器地址
|
||||
COOKIECLOUD_HOST: str = "https://movie-pilot.org/cookiecloud"
|
||||
# CookieCloud用户KEY
|
||||
COOKIECLOUD_KEY: str = None
|
||||
COOKIECLOUD_KEY: Optional[str] = None
|
||||
# CookieCloud端对端加密密码
|
||||
COOKIECLOUD_PASSWORD: str = None
|
||||
COOKIECLOUD_PASSWORD: Optional[str] = None
|
||||
# CookieCloud同步间隔(分钟)
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# OCR服务器地址
|
||||
@@ -192,13 +192,13 @@ class Settings(BaseSettings):
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH: str = None
|
||||
LIBRARY_PATH: Optional[str] = None
|
||||
# 电影媒体库目录名
|
||||
LIBRARY_MOVIE_NAME: str = "电影"
|
||||
# 电视剧媒体库目录名
|
||||
LIBRARY_TV_NAME: str = "电视剧"
|
||||
# 动漫媒体库目录名,不设置时使用电视剧目录
|
||||
LIBRARY_ANIME_NAME: str = None
|
||||
LIBRARY_ANIME_NAME: Optional[str] = None
|
||||
# 二级分类
|
||||
LIBRARY_CATEGORY: bool = True
|
||||
# 电视剧动漫的分类genre_ids
|
||||
@@ -219,10 +219,22 @@ class Settings(BaseSettings):
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: str = None
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
|
||||
@validator("SUBSCRIBE_RSS_INTERVAL",
|
||||
"COOKIECLOUD_INTERVAL",
|
||||
"MEDIASERVER_SYNC_INTERVAL",
|
||||
pre=True, always=True)
|
||||
def convert_int(cls, value):
|
||||
if not value:
|
||||
return 0
|
||||
try:
|
||||
return int(value)
|
||||
except (ValueError, TypeError):
|
||||
raise ValueError(f"{value} 格式错误,不是有效数字!")
|
||||
|
||||
@property
|
||||
def INNER_CONFIG_PATH(self):
|
||||
return self.ROOT_PATH / "config"
|
||||
|
||||
@@ -135,6 +135,8 @@ class MediaInfo:
|
||||
type: MediaType = None
|
||||
# 媒体标题
|
||||
title: str = None
|
||||
# 英文标题
|
||||
en_title: str = None
|
||||
# 年份
|
||||
year: str = None
|
||||
# 季
|
||||
@@ -368,6 +370,8 @@ class MediaInfo:
|
||||
self.genre_ids = info.get('genre_ids') or []
|
||||
# 原语种
|
||||
self.original_language = info.get('original_language')
|
||||
# 英文标题
|
||||
self.en_title = info.get('en_title')
|
||||
if self.type == MediaType.MOVIE:
|
||||
# 标题
|
||||
self.title = info.get('title')
|
||||
@@ -439,6 +443,9 @@ class MediaInfo:
|
||||
# 标题
|
||||
if not self.title:
|
||||
self.title = info.get("title")
|
||||
# 英文标题,暂时不支持
|
||||
if not self.en_title:
|
||||
self.en_title = info.get('original_title')
|
||||
# 原语种标题
|
||||
if not self.original_title:
|
||||
self.original_title = info.get("original_title")
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,7 +73,9 @@ class PluginManager(metaclass=Singleton):
|
||||
# 存储运行实例
|
||||
self._running_plugins[plugin_id] = plugin_obj
|
||||
logger.info(f"Plugin Loaded:{plugin_id}")
|
||||
# 设置事件注册状态可用
|
||||
# 启用的插件才设置事件注册状态可用
|
||||
if not plugin_obj.get_state():
|
||||
continue
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
except Exception as err:
|
||||
logger.error(f"加载插件 {plugin_id} 出错:{str(err)} - {traceback.format_exc()}")
|
||||
@@ -85,6 +87,12 @@ class PluginManager(metaclass=Singleton):
|
||||
if not self._running_plugins.get(plugin_id):
|
||||
return
|
||||
self._running_plugins[plugin_id].init_plugin(conf)
|
||||
if self._running_plugins[plugin_id].get_state():
|
||||
# 设置启用的插件事件注册状态可用
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
else:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
@@ -137,7 +145,11 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return {}
|
||||
return self.systemconfig.get(self._config_key % pid) or {}
|
||||
conf = self.systemconfig.get(self._config_key % pid)
|
||||
if conf:
|
||||
# 去掉空Key
|
||||
return {k: v for k, v in conf.items() if k}
|
||||
return {}
|
||||
|
||||
def save_plugin_config(self, pid: str, conf: dict) -> bool:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ class DownloadHistoryOper(DbOper):
|
||||
下载历史管理
|
||||
"""
|
||||
|
||||
def get_by_path(self, path: Path) -> DownloadHistory:
|
||||
def get_by_path(self, path: str) -> DownloadHistory:
|
||||
"""
|
||||
按路径查询下载记录
|
||||
:param path: 数据key
|
||||
"""
|
||||
return DownloadHistory.get_by_path(self._db, str(path))
|
||||
return DownloadHistory.get_by_path(self._db, path)
|
||||
|
||||
def get_by_hash(self, download_hash: str) -> DownloadHistory:
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 链接已过期, 您需要获得一个新的!",
|
||||
|
||||
41
app/helper/twofa.py
Normal file
41
app/helper/twofa.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
class TwoFactorAuth:
|
||||
def __init__(self, code_or_secret: str):
|
||||
if code_or_secret and len(code_or_secret) > 16:
|
||||
self.code = None
|
||||
self.secret = code_or_secret
|
||||
else:
|
||||
self.code = code_or_secret
|
||||
self.secret = None
|
||||
|
||||
@staticmethod
|
||||
def __calc(secret_key: str) -> str:
|
||||
try:
|
||||
input_time = int(time.time()) // 30
|
||||
key = base64.b32decode(secret_key)
|
||||
msg = struct.pack(">Q", input_time)
|
||||
google_code = hmac.new(key, msg, hashlib.sha1).digest()
|
||||
o = (
|
||||
google_code[19] & 15
|
||||
if sys.version_info > (2, 7)
|
||||
else ord(str(google_code[19])) & 15
|
||||
)
|
||||
google_code = str(
|
||||
(struct.unpack(">I", google_code[o: o + 4])[0] & 0x7FFFFFFF) % 1000000
|
||||
)
|
||||
return f"0{google_code}" if len(google_code) == 5 else google_code
|
||||
except Exception as e:
|
||||
logger.error(f"计算动态验证码失败:{str(e)}")
|
||||
return ""
|
||||
|
||||
def get_code(self) -> str:
|
||||
return self.code or self.__calc(self.secret)
|
||||
@@ -35,12 +35,14 @@ class DoubanModule(_ModuleBase):
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: str = None,
|
||||
cache: bool = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与doubanid配套
|
||||
:param doubanid: 豆瓣ID
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
@@ -48,11 +50,17 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
if not meta:
|
||||
cache_info = {}
|
||||
elif not meta.name:
|
||||
logger.error("识别媒体信息时未提供元数据名称")
|
||||
return None
|
||||
else:
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if doubanid:
|
||||
meta.doubanid = doubanid
|
||||
# 读取缓存
|
||||
cache_info = self.cache.get(meta)
|
||||
if not cache_info:
|
||||
if not cache_info or not cache:
|
||||
# 缓存没有或者强制不使用缓存
|
||||
if doubanid:
|
||||
# 直接查询详情
|
||||
@@ -80,7 +88,7 @@ class DoubanModule(_ModuleBase):
|
||||
logger.error("识别媒体信息时未提供元数据或豆瓣ID")
|
||||
return None
|
||||
# 保存到缓存
|
||||
if meta:
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -496,7 +496,7 @@ class FileTransferModule(_ModuleBase):
|
||||
case 'size':
|
||||
# 存在时大覆盖小
|
||||
if new_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件文件大小更小,将被覆盖:{new_file}")
|
||||
logger.info(f"目标文件文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
else:
|
||||
return TransferInfo(success=False,
|
||||
@@ -513,10 +513,13 @@ class FileTransferModule(_ModuleBase):
|
||||
fail_list=[str(in_path)])
|
||||
case 'latest':
|
||||
# 仅保留最新版本
|
||||
self.delete_all_version_files(new_file)
|
||||
logger.info(f"仅保留最新版本,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
case _:
|
||||
pass
|
||||
else:
|
||||
if settings.OVERWRITE_MODE == 'latest':
|
||||
# 文件不存在,但仅保留最新版本
|
||||
logger.info(f"转移覆盖模式:{settings.OVERWRITE_MODE},仅保留最新版本")
|
||||
self.delete_all_version_files(new_file)
|
||||
# 原文件大小
|
||||
file_size = in_path.stat().st_size
|
||||
# 转移文件
|
||||
@@ -552,6 +555,20 @@ class FileTransferModule(_ModuleBase):
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
|
||||
def __convert_invalid_characters(filename: str):
|
||||
if not filename:
|
||||
return filename
|
||||
invalid_characters = r'\/:*?"<>|'
|
||||
# 创建半角到全角字符的转换表
|
||||
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
|
||||
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
|
||||
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
|
||||
# 将不支持的字符替换为对应的全角字符
|
||||
for char in invalid_characters:
|
||||
filename = filename.replace(char, char.translate(translation_table))
|
||||
return filename
|
||||
|
||||
# 获取集标题
|
||||
episode_title = None
|
||||
if meta.begin_episode and episodes_info:
|
||||
@@ -562,9 +579,11 @@ class FileTransferModule(_ModuleBase):
|
||||
|
||||
return {
|
||||
# 标题
|
||||
"title": mediainfo.title,
|
||||
"title": __convert_invalid_characters(mediainfo.title),
|
||||
# 英文标题
|
||||
"en_title": __convert_invalid_characters(mediainfo.en_title),
|
||||
# 原语种标题
|
||||
"original_title": mediainfo.original_title,
|
||||
"original_title": __convert_invalid_characters(mediainfo.original_title),
|
||||
# 原文件名
|
||||
"original_name": f"{meta.org_string}{file_ext}",
|
||||
# 识别名称(优先使用中文)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import copy
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
from typing import List
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
@@ -56,12 +57,14 @@ class TorrentSpider:
|
||||
fields: dict = {}
|
||||
# 页码
|
||||
page: int = 0
|
||||
# 搜索条数
|
||||
# 搜索条数, 默认: 100条
|
||||
result_num: int = 100
|
||||
# 单个种子信息
|
||||
torrents_info: dict = {}
|
||||
# 种子列表
|
||||
torrents_info_array: list = []
|
||||
# 搜索超时, 默认: 30秒
|
||||
_timeout = 30
|
||||
|
||||
def __init__(self,
|
||||
indexer: CommentedMap,
|
||||
@@ -91,6 +94,8 @@ class TorrentSpider:
|
||||
self.fields = indexer.get('torrents').get('fields')
|
||||
self.render = indexer.get('render')
|
||||
self.domain = indexer.get('domain')
|
||||
self.result_num = int(indexer.get('result_num') or 100)
|
||||
self._timeout = int(indexer.get('timeout') or 30)
|
||||
self.page = page
|
||||
if self.domain and not str(self.domain).endswith("/"):
|
||||
self.domain = self.domain + "/"
|
||||
@@ -152,7 +157,7 @@ class TorrentSpider:
|
||||
search_mode = "0"
|
||||
|
||||
# 搜索URL
|
||||
indexer_params = self.search.get("params") or {}
|
||||
indexer_params = self.search.get("params", {}).copy()
|
||||
if indexer_params:
|
||||
search_area = indexer_params.get('search_area')
|
||||
# search_area非0表示支持imdbid搜索
|
||||
@@ -233,14 +238,15 @@ class TorrentSpider:
|
||||
url=searchurl,
|
||||
cookies=self.cookie,
|
||||
ua=self.ua,
|
||||
proxies=self.proxy_server
|
||||
proxies=self.proxy_server,
|
||||
timeout=self._timeout
|
||||
)
|
||||
else:
|
||||
# requests请求
|
||||
ret = RequestUtils(
|
||||
ua=self.ua,
|
||||
cookies=self.cookie,
|
||||
timeout=30,
|
||||
timeout=self._timeout,
|
||||
referer=self.referer,
|
||||
proxies=self.proxies
|
||||
).get_res(searchurl, allow_redirects=True)
|
||||
@@ -608,8 +614,8 @@ class TorrentSpider:
|
||||
for filter_item in filters:
|
||||
if not text:
|
||||
break
|
||||
method_name = filter_item.get("name")
|
||||
try:
|
||||
method_name = filter_item.get("name")
|
||||
args = filter_item.get("args")
|
||||
if method_name == "re_search" and isinstance(args, list):
|
||||
text = re.search(r"%s" % args[0], text).group(args[-1])
|
||||
@@ -624,7 +630,7 @@ class TorrentSpider:
|
||||
elif method_name == "appendleft":
|
||||
text = f"{args}{text}"
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
logger.debug(f'过滤器 {method_name} 处理失败:{str(err)} - {traceback.format_exc()}')
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
season_episode=meta.season_episode,
|
||||
progress=torrent.get('progress') * 100,
|
||||
size=torrent.get('total_size'),
|
||||
state="paused" if torrent.get('state') == "paused" else "downloading",
|
||||
state="paused" if torrent.get('state') in ("paused", "pausedDL") else "downloading",
|
||||
dlspeed=StringUtils.str_filesize(torrent.get('dlspeed')),
|
||||
upspeed=StringUtils.str_filesize(torrent.get('upspeed')),
|
||||
left_time=StringUtils.str_secends(
|
||||
@@ -236,13 +236,14 @@ class QbittorrentModule(_ModuleBase):
|
||||
logger.warn(f"删除残留文件夹:{path}")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list]) -> bool:
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
|
||||
"""
|
||||
删除下载器种子
|
||||
:param hashs: 种子Hash
|
||||
:param delete_file: 是否删除文件
|
||||
:return: bool
|
||||
"""
|
||||
return self.qbittorrent.delete_torrents(delete_file=True, ids=hashs)
|
||||
return self.qbittorrent.delete_torrents(delete_file=delete_file, ids=hashs)
|
||||
|
||||
def start_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,12 +44,14 @@ class TheMovieDbModule(_ModuleBase):
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
tmdbid: int = None,
|
||||
cache: bool = True,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
识别媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:param mtype: 识别的媒体类型,与tmdbid配套
|
||||
:param tmdbid: tmdbid
|
||||
:param cache: 是否使用缓存
|
||||
:return: 识别的媒体信息,包括剧集信息
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
@@ -57,11 +59,17 @@ class TheMovieDbModule(_ModuleBase):
|
||||
|
||||
if not meta:
|
||||
cache_info = {}
|
||||
elif not meta.name:
|
||||
logger.warn("识别媒体信息时未提供元数据名称")
|
||||
return None
|
||||
else:
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if tmdbid:
|
||||
meta.tmdbid = tmdbid
|
||||
# 读取缓存
|
||||
cache_info = self.cache.get(meta)
|
||||
if not cache_info:
|
||||
if not cache_info or not cache:
|
||||
# 缓存没有或者强制不使用缓存
|
||||
if tmdbid:
|
||||
# 直接查询详情
|
||||
@@ -111,7 +119,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
logger.error("识别媒体信息时未提供元数据或tmdbid")
|
||||
return None
|
||||
# 保存到缓存
|
||||
if meta:
|
||||
if meta and cache:
|
||||
self.cache.update(meta, info)
|
||||
else:
|
||||
# 使用缓存信息
|
||||
|
||||
@@ -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,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,"
|
||||
|
||||
@@ -220,13 +220,14 @@ class TransmissionModule(_ModuleBase):
|
||||
logger.warn(f"删除残留文件夹:{path}")
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def remove_torrents(self, hashs: Union[str, list]) -> bool:
|
||||
def remove_torrents(self, hashs: Union[str, list], delete_file: bool = True) -> bool:
|
||||
"""
|
||||
删除下载器种子
|
||||
:param hashs: 种子Hash
|
||||
:param delete_file: 是否删除文件
|
||||
:return: bool
|
||||
"""
|
||||
return self.transmission.delete_torrents(delete_file=True, ids=hashs)
|
||||
return self.transmission.delete_torrents(delete_file=delete_file, ids=hashs)
|
||||
|
||||
def start_torrents(self, hashs: Union[list, str]) -> bool:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,14 +8,14 @@ from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
class WeChat(metaclass=Singleton):
|
||||
class WeChat:
|
||||
# 企业微信Token
|
||||
_access_token = None
|
||||
# 企业微信Token过期时间
|
||||
@@ -47,6 +47,7 @@ class WeChat(metaclass=Singleton):
|
||||
if self._corpid and self._appsecret and self._appid:
|
||||
self.__get_access_token()
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __get_access_token(self, force=False):
|
||||
"""
|
||||
获取微信Token
|
||||
@@ -75,6 +76,7 @@ class WeChat(metaclass=Singleton):
|
||||
logger.error(f"获取微信access_token失败,错误码:{res.status_code},错误原因:{res.reason}")
|
||||
else:
|
||||
logger.error(f"获取微信access_token失败,未获取到返回信息")
|
||||
raise Exception("获取微信access_token失败,网络连接失败")
|
||||
except Exception as e:
|
||||
logger.error(f"获取微信access_token失败,错误信息:{str(e)}")
|
||||
return None
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,8 +9,8 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
@@ -56,7 +56,7 @@ class Scheduler(metaclass=Singleton):
|
||||
# 各服务的运行状态
|
||||
self._jobs = {
|
||||
"cookiecloud": {
|
||||
"func": CookieCloudChain().process,
|
||||
"func": SiteChain().sync_cookies,
|
||||
"running": False,
|
||||
},
|
||||
"mediaserver_sync": {
|
||||
@@ -93,13 +93,14 @@ class Scheduler(metaclass=Singleton):
|
||||
return
|
||||
|
||||
# CookieCloud定时同步
|
||||
if settings.COOKIECLOUD_INTERVAL:
|
||||
if settings.COOKIECLOUD_INTERVAL \
|
||||
and str(settings.COOKIECLOUD_INTERVAL).isdigit():
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="cookiecloud",
|
||||
name="同步CookieCloud站点",
|
||||
minutes=settings.COOKIECLOUD_INTERVAL,
|
||||
minutes=int(settings.COOKIECLOUD_INTERVAL),
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=1),
|
||||
kwargs={
|
||||
'job_id': 'cookiecloud'
|
||||
@@ -107,13 +108,14 @@ class Scheduler(metaclass=Singleton):
|
||||
)
|
||||
|
||||
# 媒体服务器同步
|
||||
if settings.MEDIASERVER_SYNC_INTERVAL:
|
||||
if settings.MEDIASERVER_SYNC_INTERVAL \
|
||||
and str(settings.MEDIASERVER_SYNC_INTERVAL).isdigit():
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="mediaserver_sync",
|
||||
name="同步媒体服务器",
|
||||
hours=settings.MEDIASERVER_SYNC_INTERVAL,
|
||||
hours=int(settings.MEDIASERVER_SYNC_INTERVAL),
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(minutes=5),
|
||||
kwargs={
|
||||
'job_id': 'mediaserver_sync'
|
||||
@@ -173,16 +175,17 @@ class Scheduler(metaclass=Singleton):
|
||||
})
|
||||
else:
|
||||
# RSS订阅模式
|
||||
if not settings.SUBSCRIBE_RSS_INTERVAL:
|
||||
if not settings.SUBSCRIBE_RSS_INTERVAL \
|
||||
or not str(settings.SUBSCRIBE_RSS_INTERVAL).isdigit():
|
||||
settings.SUBSCRIBE_RSS_INTERVAL = 30
|
||||
elif settings.SUBSCRIBE_RSS_INTERVAL < 5:
|
||||
elif int(settings.SUBSCRIBE_RSS_INTERVAL) < 5:
|
||||
settings.SUBSCRIBE_RSS_INTERVAL = 5
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="subscribe_refresh",
|
||||
name="RSS订阅刷新",
|
||||
minutes=settings.SUBSCRIBE_RSS_INTERVAL,
|
||||
minutes=int(settings.SUBSCRIBE_RSS_INTERVAL),
|
||||
kwargs={
|
||||
'job_id': 'subscribe_refresh'
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ class MediaInfo(BaseModel):
|
||||
type: Optional[str] = None
|
||||
# 媒体标题
|
||||
title: Optional[str] = None
|
||||
# 英文标题
|
||||
en_title: Optional[str] = None
|
||||
# 年份
|
||||
year: Optional[str] = None
|
||||
# 标题(年份)
|
||||
|
||||
@@ -40,6 +40,8 @@ class EventType(Enum):
|
||||
NameRecognize = "name.recognize"
|
||||
# 名称识别结果
|
||||
NameRecognizeResult = "name.recognize.result"
|
||||
# 缓存站点图标
|
||||
CacheSiteIcon = "cache.siteicon"
|
||||
|
||||
|
||||
# 系统配置Key字典
|
||||
|
||||
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
|
||||
# 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/
|
||||
|
||||
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])
|
||||
|
||||
88
update
88
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,62 @@ download_and_unzip() {
|
||||
|
||||
# 下载程序资源,$1: 后端版本路径
|
||||
install_backend_and_download_resources() {
|
||||
download_and_unzip "https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"
|
||||
if [ $? -eq 0 ]; then
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot/archive/refs/${1}" "App"; then
|
||||
echo "后端程序下载成功"
|
||||
pip install ${PIP_OPTIONS} --upgrade pip
|
||||
pip install ${PIP_OPTIONS} -r /tmp/App/requirements.txt
|
||||
if [ $? -eq 0 ]; then
|
||||
if pip install ${PIP_OPTIONS} -r /tmp/App/requirements.txt; then
|
||||
echo "安装依赖成功"
|
||||
download_and_unzip "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "插件下载成功"
|
||||
download_and_unzip "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "资源包下载成功"
|
||||
frontend_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
|
||||
if [[ "${frontend_version}" == *v* ]]; then
|
||||
download_and_unzip "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "前端程序下载成功"
|
||||
# 备份插件目录
|
||||
rm -rf /plugins
|
||||
mv /app/app/plugins /plugins
|
||||
# 清空目录
|
||||
rm -rf /app
|
||||
# 后端程序
|
||||
mv /tmp/App /app
|
||||
# 恢复插件目录
|
||||
mv -f /plugins/* /app/app/plugins/
|
||||
# 插件仓库
|
||||
rsync -av --remove-source-files /tmp/Plugins/plugins/* /app/app/plugins/
|
||||
frontend_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
|
||||
if [[ "${frontend_version}" == *v* ]]; then
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${frontend_version}/dist.zip" "dist"; then
|
||||
echo "前端程序下载成功"
|
||||
# 清空目录
|
||||
rm -rf /app
|
||||
mkdir -p /app
|
||||
# 后端程序
|
||||
cp -a /tmp/App/* /app/
|
||||
# 前端程序
|
||||
rm -rf /public
|
||||
mkdir -p /public
|
||||
cp -a /tmp/dist/* /public/
|
||||
# 清理临时目录
|
||||
rm -rf /tmp/*
|
||||
echo "程序部分更新成功,前端版本:${frontend_version},后端版本:${1}"s
|
||||
echo "开始更新插件..."
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" "Plugins"; then
|
||||
echo "插件下载成功"
|
||||
# 备份插件目录
|
||||
rm -rf /plugins
|
||||
mkdir -p /plugins
|
||||
cp -a /app/app/plugins/* /plugins/
|
||||
# 不备份__init__.py
|
||||
rm -f /plugins/__init__.py
|
||||
# 恢复插件目录
|
||||
cp -a /plugins/* /app/app/plugins/
|
||||
# 插件仓库
|
||||
rsync -av --remove-source-files /tmp/Plugins/plugins/* /app/app/plugins/
|
||||
# 清理临时目录
|
||||
rm -rf /tmp/*
|
||||
echo "插件更新成功"
|
||||
echo "开始更新资源包..."
|
||||
if download_and_unzip "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "Resources"; then
|
||||
echo "资源包下载成功"
|
||||
# 资源包
|
||||
mv -f /tmp/Resources/resources/* /app/app/helper/
|
||||
# 前端程序
|
||||
rm -rf /public
|
||||
mv /tmp/dist /public
|
||||
cp -a /tmp/Resources/resources/* /app/app/helper/
|
||||
# 清理临时目录
|
||||
rm -rf /tmp/*
|
||||
echo "程序更新成功,前端版本:${frontend_version},后端版本:${1}"
|
||||
echo "资源包更新成功"
|
||||
else
|
||||
echo "前端程序下载失败,继续使用旧的程序来启动..."
|
||||
echo "资源包下载失败,继续使用旧的资源包来启动..."
|
||||
fi
|
||||
else
|
||||
echo "前端最新版本号获取失败,继续启动..."
|
||||
echo "插件下载失败,继续使用旧的插件来启动..."
|
||||
fi
|
||||
else
|
||||
echo "资源包下载失败,继续使用旧的程序来启动..."
|
||||
echo "前端程序下载失败,继续使用旧的程序来启动..."
|
||||
fi
|
||||
else
|
||||
echo "插件下载失败,继续使用旧的程序来启动..."
|
||||
echo "前端最新版本号获取失败,继续启动..."
|
||||
fi
|
||||
else
|
||||
echo "安装依赖失败,请重新拉取镜像"
|
||||
@@ -95,7 +105,7 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
|
||||
echo "Release 更新模式"
|
||||
old_version=$(cat /app/version.py)
|
||||
if [[ "${old_version}" == *APP_VERSION* ]]; then
|
||||
current_version=v$(echo ${old_version} | sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
current_version=v$(echo "${old_version}" | sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "当前版本号:${current_version}"
|
||||
new_version=$(curl ${CURL_OPTIONS} "https://api.github.com/repos/jxxghp/MoviePilot/releases/latest" ${CURL_HEADERS} | jq -r .tag_name)
|
||||
if [[ "${new_version}" == *v* ]]; then
|
||||
|
||||
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.6.1'
|
||||
APP_VERSION = 'v1.6.6'
|
||||
|
||||
Reference in New Issue
Block a user