mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 21:02:44 +08:00
Compare commits
200 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b98a033cd2 | ||
|
|
c69853ce4b | ||
|
|
e00a440336 | ||
|
|
c0eb6b0600 | ||
|
|
4d1c8c3764 | ||
|
|
62628e526c | ||
|
|
ad7761a785 | ||
|
|
e545b8d900 | ||
|
|
f2f1ecfdf1 | ||
|
|
fdec997ed0 | ||
|
|
9b653ceec9 | ||
|
|
fbaaed1c61 | ||
|
|
639abf67c2 | ||
|
|
1f56ceaea9 | ||
|
|
16a4f61fec | ||
|
|
ea0aba96fd | ||
|
|
4393dad77c | ||
|
|
d099c0e702 | ||
|
|
a299d786fe | ||
|
|
3500f5b9a6 | ||
|
|
64233c89d7 | ||
|
|
8c727da58a | ||
|
|
152a87d109 | ||
|
|
6a2cde0664 | ||
|
|
c86cc2cb51 | ||
|
|
6d7a63ff61 | ||
|
|
c044e59481 | ||
|
|
3c31bf24e5 | ||
|
|
d89c80ac89 | ||
|
|
8236d6c8d7 | ||
|
|
3646540a7f | ||
|
|
c1ecdfc61d | ||
|
|
7587946d51 | ||
|
|
3ad64baaeb | ||
|
|
24c43b53a2 | ||
|
|
53a6a1c691 | ||
|
|
c3ba83c7ca | ||
|
|
d9b349873e | ||
|
|
4dcefb141a | ||
|
|
c674e32046 | ||
|
|
8aa1027aae | ||
|
|
b4cb9c3fb3 | ||
|
|
d82ab5d60d | ||
|
|
979b636eec | ||
|
|
bf8a75b201 | ||
|
|
87111c8736 | ||
|
|
9b97e478aa | ||
|
|
2af7abee3c | ||
|
|
2c8a41ebad | ||
|
|
c632cfd6b9 | ||
|
|
7f05df2fb3 | ||
|
|
ff33432809 | ||
|
|
0a57e69bcf | ||
|
|
7af8b15dbb | ||
|
|
bc4931d971 | ||
|
|
cfb029b6b4 | ||
|
|
6fa50101a6 | ||
|
|
843fbc83f4 | ||
|
|
55f8fb3b66 | ||
|
|
a47774472d | ||
|
|
713f4ca356 | ||
|
|
b06795510a | ||
|
|
0f57ec099a | ||
|
|
8325caabdc | ||
|
|
44d276d7e7 | ||
|
|
935340561b | ||
|
|
a60fde3b91 | ||
|
|
163a855d5c | ||
|
|
c9b1e75361 | ||
|
|
a9932d0866 | ||
|
|
11d29919bf | ||
|
|
4fe755332d | ||
|
|
0095e0f4dd | ||
|
|
322c72ab54 | ||
|
|
4d51459a47 | ||
|
|
d51de30898 | ||
|
|
90f9edbf24 | ||
|
|
8aa10457a7 | ||
|
|
ab584720c6 | ||
|
|
56ad281cb6 | ||
|
|
61281cca02 | ||
|
|
b53dbbc38e | ||
|
|
3f88cfba28 | ||
|
|
e855d8b9af | ||
|
|
171720e629 | ||
|
|
8aa6b33fba | ||
|
|
505fc803db | ||
|
|
b5146620a6 | ||
|
|
7d44f24347 | ||
|
|
4dccc6e860 | ||
|
|
ee6585c737 | ||
|
|
62e5e8a69f | ||
|
|
e942a99ff0 | ||
|
|
b3fe49684b | ||
|
|
dcf1985361 | ||
|
|
8f4f4cc004 | ||
|
|
f49baadb76 | ||
|
|
5233484fc5 | ||
|
|
84c4cc8b5d | ||
|
|
77036eccd8 | ||
|
|
dcdb08ec80 | ||
|
|
cd7f688e78 | ||
|
|
cb12a052ac | ||
|
|
995c359f20 | ||
|
|
690066ad32 | ||
|
|
73942e315a | ||
|
|
48badb3243 | ||
|
|
d5eb12cc4e | ||
|
|
7d7539df4c | ||
|
|
14a8f44f8c | ||
|
|
a7be470f33 | ||
|
|
a677169f60 | ||
|
|
b72ef4f2aa | ||
|
|
403054751b | ||
|
|
b3e5c734d4 | ||
|
|
5732125ff6 | ||
|
|
eb66cf7aad | ||
|
|
a317c35eab | ||
|
|
ab138560c1 | ||
|
|
f0fbad889d | ||
|
|
1323cd5dc6 | ||
|
|
2c43d8e145 | ||
|
|
0214beb679 | ||
|
|
7d73cdef33 | ||
|
|
fcfab2c750 | ||
|
|
e048be17a5 | ||
|
|
024f1de4f1 | ||
|
|
d2c9f7a778 | ||
|
|
1784d2ec61 | ||
|
|
8f4a213f55 | ||
|
|
6a8c684af0 | ||
|
|
aefba83319 | ||
|
|
e411f4062a | ||
|
|
3c6802860d | ||
|
|
3daad5ea90 | ||
|
|
6835c38c24 | ||
|
|
32be9b71d5 | ||
|
|
84bc0e0fb4 | ||
|
|
8105dc9c82 | ||
|
|
840c968454 | ||
|
|
8c5f19a0f4 | ||
|
|
bf813aa906 | ||
|
|
e5ac7f10d4 | ||
|
|
d049e04fa2 | ||
|
|
ef45b08ee5 | ||
|
|
d77644ab16 | ||
|
|
1a90b4a3cb | ||
|
|
9e5d401a85 | ||
|
|
6da6dc2b8c | ||
|
|
2ca7021df0 | ||
|
|
1682cdad37 | ||
|
|
ec5e898feb | ||
|
|
ccf6fb1b36 | ||
|
|
83c8d619d0 | ||
|
|
dd5b8219a1 | ||
|
|
34fd927972 | ||
|
|
6db5ad2697 | ||
|
|
0be1f27970 | ||
|
|
54603798fc | ||
|
|
2f1e947323 | ||
|
|
ef2cfe1c1d | ||
|
|
f8edf79c59 | ||
|
|
dec80b6567 | ||
|
|
4dac9237ef | ||
|
|
12f5f373b3 | ||
|
|
76472770bf | ||
|
|
f5baf77c3c | ||
|
|
126276c727 | ||
|
|
5d6ba83fc5 | ||
|
|
047c3b596d | ||
|
|
2240ff08a1 | ||
|
|
4d6bf56fa0 | ||
|
|
10ad9a5601 | ||
|
|
fb39500428 | ||
|
|
615a52cef9 | ||
|
|
791be0583a | ||
|
|
a324731061 | ||
|
|
539d9cf537 | ||
|
|
699312ff28 | ||
|
|
b92b0ec149 | ||
|
|
51536062f1 | ||
|
|
4c230b4c1e | ||
|
|
a7752ceb17 | ||
|
|
ed59a90d78 | ||
|
|
11d65e7527 | ||
|
|
b6ac5f0f84 | ||
|
|
c336e62885 | ||
|
|
b868cdb25e | ||
|
|
04339539d1 | ||
|
|
2d146880ec | ||
|
|
6eec4ef7f4 | ||
|
|
9841f3dd18 | ||
|
|
03e0118fb7 | ||
|
|
c7c222b357 | ||
|
|
2d8e45cd1b | ||
|
|
e2bd5cc245 | ||
|
|
47ddfec30e | ||
|
|
9344b2a324 | ||
|
|
8496fcccc5 | ||
|
|
c6fa3b9d25 |
51
.github/workflows/build.yml
vendored
51
.github/workflows/build.yml
vendored
@@ -110,7 +110,7 @@ jobs:
|
||||
|
||||
- name: Pyinstaller
|
||||
run: |
|
||||
pyinstaller windows.spec
|
||||
pyinstaller frozen.spec
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Windows File
|
||||
@@ -119,10 +119,54 @@ jobs:
|
||||
name: windows
|
||||
path: dist/MoviePilot.exe
|
||||
|
||||
Linux-build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Linux Amd64
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Init Python 3.11.4
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11.4'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install Dependent Packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install wheel pyinstaller
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Prepare Frontend
|
||||
run: |
|
||||
wget https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip
|
||||
unzip main.zip
|
||||
mv MoviePilot-Plugins-main/plugins/* app/plugins/
|
||||
rm main.zip
|
||||
rm -rf MoviePilot-Plugins-main
|
||||
|
||||
wget https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip
|
||||
unzip main.zip
|
||||
mv MoviePilot-Resources-main/resources/* app/helper/
|
||||
rm main.zip
|
||||
rm -rf MoviePilot-Resources-main
|
||||
|
||||
- name: Pyinstaller
|
||||
run: |
|
||||
pyinstaller frozen.spec
|
||||
mv dist/MoviePilot dist/MoviePilot_Amd64
|
||||
|
||||
- name: Upload Linux File
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: linux-amd64
|
||||
path: dist/MoviePilot_Amd64
|
||||
|
||||
Create-release:
|
||||
permissions: write-all
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Windows-build, Docker-build ]
|
||||
needs: [ Windows-build, Docker-build, Linux-build-amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
@@ -139,7 +183,8 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir releases
|
||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_v${{ env.app_version }}.exe
|
||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_Win_v${{ env.app_version }}.exe
|
||||
mv ./linux-amd64/MoviePilot_Amd64 ./releases/MoviePilot_Amd64_v${{ env.app_version }}
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ app/helper/*.so
|
||||
app/helper/*.pyd
|
||||
app/helper/*.bin
|
||||
app/plugins/**
|
||||
!app/plugins/__init__.py
|
||||
config/user.db
|
||||
config/sites/**
|
||||
*.pyc
|
||||
|
||||
@@ -32,6 +32,7 @@ RUN apt-get update -y \
|
||||
haproxy \
|
||||
fuse3 \
|
||||
rsync \
|
||||
ffmpeg \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
|
||||
213
README.md
213
README.md
@@ -7,12 +7,14 @@
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
## 主要特性
|
||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
|
||||
- 前后端分离,基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend),API:http://localhost:3001/docs
|
||||
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
|
||||
- 重新设计了用户界面,更加美观易用。
|
||||
|
||||
## 安装
|
||||
|
||||
### 注意:管理员用户不要使用弱密码!如非必要不要暴露到公网。如被盗取管理账号权限,将会导致站点Cookie等敏感数据泄露!
|
||||
|
||||
### 1. **安装CookieCloud插件**
|
||||
|
||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
||||
@@ -39,58 +41,87 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
|
||||
- Windows
|
||||
|
||||
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录。
|
||||
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录,访问:http://localhost:3000
|
||||
|
||||
- 群晖套件
|
||||
|
||||
添加套件源:https://spk7.imnks.com/
|
||||
|
||||
- 本地运行
|
||||
|
||||
1) 将工程 [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins) plugins目录下的所有文件复制到`app/plugins`目录
|
||||
2) 将工程 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) resources目录下的所有文件复制到`app/helper`目录
|
||||
3) 执行命令:`pip install -r requirements.txt` 安装依赖
|
||||
4) 执行命令:`python app/main.py` 启动服务
|
||||
4) 执行命令:`PYTHONPATH=. python app/main.py` 启动服务
|
||||
5) 根据前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend) 说明,启动前端服务
|
||||
|
||||
## 配置
|
||||
|
||||
项目的所有配置均通过环境变量进行设置,支持两种配置方式:
|
||||
- 在Docker环境变量部分或Windows系统环境变量中进行参数配置,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
- 下载 [app.env](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env) 配置文件,修改好配置后放置到配置文件映射路径根目录,配置项可根据说明自主增减。
|
||||
|
||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**。
|
||||
|
||||
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||
|
||||
### 1. **基础设置**
|
||||
### 1. **环境变量**
|
||||
|
||||
- **❗NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突(仅支持环境变量配置)
|
||||
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突(仅支持环境变量配置)
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`(仅支持环境变量配置)
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`(仅支持环境变量配置)
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`(仅支持环境变量配置)
|
||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`release`/`dev`/`false`,默认`release` **注意:如果出现网络问题可以配置`PROXY_HOST`**(仅支持环境变量配置)
|
||||
---
|
||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **❗SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **❗NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`
|
||||
- **MOVIEPILOT_AUTO_UPDATE:** 重启时自动更新,`true`/`release`/`dev`/`false`,默认`release`,需要能正常连接Github **注意:如果出现网络问题可以配置`PROXY_HOST`**
|
||||
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`,需要能正常连接Github,仅支持Docker
|
||||
- **❗AUTH_SITE:** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
||||
|
||||
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数,认证资源`v1.1.1`支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
| 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`:密钥 |
|
||||
|
||||
|
||||
### 2. **app.env配置文件**
|
||||
|
||||
下载 [app.env 模板](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env),修改后放配置文件目录下,app.env 的所有配置项也可以通过环境变量进行配置。
|
||||
|
||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面。**注意:1、初始密码为自动生成,需要在首次运行时的后台日志中查看,成功登录后可以设定中修改;2、启动一次后再次修改该值不会生效,除非删除数据库文件!**
|
||||
- **❗API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`或其它中转代理服务地址,能连通即可
|
||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
|
||||
- **GITHUB_TOKEN:** Github token,提高自动更新、插件安装等请求Github Api的限流阈值,格式:ghp_****
|
||||
---
|
||||
- **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`时不支持二级分类
|
||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
- **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信息变化了也会仍然按历史记录中已入库的信息进行刮削
|
||||
---
|
||||
- **❗TRANSFER_TYPE:** 整理转移方式,支持`link`/`copy`/`move`/`softlink`/`rclone_copy`/`rclone_move` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响;rclone需要自行映射rclone配置目录到容器中或在容器内完成rclone配置,节点名称必须为:`MP`**
|
||||
- **❗OVERWRITE_MODE:** 转移覆盖模式,默认为`size`,支持`nerver`/`size`/`always`/`latest`,分别表示`不覆盖同名文件`/`同名文件根据文件大小覆盖(大覆盖小)`/`总是覆盖同名文件`/`仅保留最新版本,删除旧版本文件(包括非同名文件)`
|
||||
- **❗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
|
||||
@@ -101,11 +132,9 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- **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),多个用户使用,分割,未设置需要选择资源或者回复`0`
|
||||
- **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) 自行搭建。
|
||||
- **PLUGIN_MARKET:** 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/,默认为官方插件仓库:`https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/`。
|
||||
- **GITHUB_TOKEN:** Github token,提高请求api限流阈值 ghp_****(仅支持环境变量配置)
|
||||
---
|
||||
- **❗MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
|
||||
@@ -136,7 +165,6 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
|
||||
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
||||
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
||||
|
||||
---
|
||||
- **❗DOWNLOAD_PATH:** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
|
||||
- **DOWNLOAD_MOVIE_PATH:** 电影下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
@@ -144,8 +172,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- **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_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
- **TORRENT_TAG:** 下载器种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
||||
---
|
||||
- **❗DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
|
||||
- `qbittorrent`设置项:
|
||||
@@ -162,105 +189,77 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- **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`支持的配置项:
|
||||
|
||||
### 2. **用户认证**
|
||||
> `title`: TMDB/豆瓣中的标题
|
||||
> `original_title`: TMDB/豆瓣中的原语种标题
|
||||
> `name`: 从文件名中识别的名称(同时存在中英文时,优先使用中文)
|
||||
> `en_name`:从文件名中识别的英文名称(可能为空)
|
||||
> `original_name`: 原文件名(包括文件外缀)
|
||||
> `year`: 年份
|
||||
> `resourceType`:资源类型
|
||||
> `effect`:特效
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDB ID(非TMDB识别源时为空)
|
||||
> `imdbid`: IMDB ID(可能为空)
|
||||
> `doubanid`:豆瓣ID(非豆瓣识别源时为空)
|
||||
> `part`:段/节
|
||||
> `fileExt`:文件扩展名
|
||||
> `customization`:自定义占位符
|
||||
|
||||
`MOVIE_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过环境变量配置**)
|
||||
|
||||
`AUTH_SITE`支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
||||
|
||||
- **❗AUTH_SITE:** 认证站点,认证资源`v1.0.1`支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
| 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`:密钥 |
|
||||
| 1ptba | `1PTBA_UID`:用户ID<br/>`1PTBA_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`:密钥 |
|
||||
|
||||
|
||||
### 2. **进阶配置**
|
||||
|
||||
- **BIG_MEMORY_MODE:** 大内存模式,默认为`false`,开启后会增加缓存数量,占用更多的内存,但响应速度会更快
|
||||
|
||||
- **MOVIE_RENAME_FORMAT:** 电影重命名格式
|
||||
|
||||
`MOVIE_RENAME_FORMAT`支持的配置项:
|
||||
|
||||
> `title`: 标题
|
||||
> `original_name`: 原文件名
|
||||
> `original_title`: 原语种标题
|
||||
> `name`: 识别名称
|
||||
> `year`: 年份
|
||||
> `resourceType`:资源类型
|
||||
> `effect`:特效
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
> `imdbid`: IMDBID
|
||||
> `part`:段/节
|
||||
> `fileExt`:文件扩展名
|
||||
> `tmdbid`:TMDB ID
|
||||
> `imdbid`:IMDB ID
|
||||
> `customization`:自定义占位符
|
||||
|
||||
`MOVIE_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
- **TV_RENAME_FORMAT:** 电视剧重命名格式
|
||||
|
||||
`TV_RENAME_FORMAT`额外支持的配置项:
|
||||
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
> `episode_title`: 集标题
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
||||
```
|
||||
- **TV_RENAME_FORMAT:** 电视剧重命名格式,基于jinjia2语法
|
||||
|
||||
`TV_RENAME_FORMAT`额外支持的配置项:
|
||||
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
> `episode_title`: 集标题
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
```
|
||||
{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
||||
```
|
||||
|
||||
|
||||
### 3. **优先级规则**
|
||||
|
||||
@@ -268,6 +267,10 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
||||
- 不符合过滤规则所有层级规则的资源将不会被选中
|
||||
|
||||
### 4. **插件扩展**
|
||||
|
||||
- **PLUGIN_MARKET:** 插件市场仓库地址,仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` ,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork,或者查看频道置顶了解更多第三方插件仓库。
|
||||
|
||||
|
||||
## 使用
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, filebrowser, transfer, mediaserver
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -21,3 +21,4 @@ api_router.include_router(download.router, prefix="/download", tags=["download"]
|
||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||
api_router.include_router(filebrowser.router, prefix="/filebrowser", tags=["filebrowser"])
|
||||
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
|
||||
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
|
||||
|
||||
@@ -149,6 +149,47 @@ def tv_hot(page: int = 1,
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.DoubanPerson])
|
||||
def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
doubaninfos = DoubanChain().movie_credits(doubanid=doubanid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
doubaninfos = DoubanChain().tv_credits(doubanid=doubanid, page=page)
|
||||
else:
|
||||
return []
|
||||
if not doubaninfos:
|
||||
return []
|
||||
else:
|
||||
return [schemas.DoubanPerson(**doubaninfo) for doubaninfo in doubaninfos]
|
||||
|
||||
|
||||
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def douban_recommend(doubanid: str,
|
||||
type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
doubaninfos = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
doubaninfos = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||
else:
|
||||
return []
|
||||
if not doubaninfos:
|
||||
return []
|
||||
else:
|
||||
return [MediaInfo(douban_info=doubaninfo).to_dict() for doubaninfo in doubaninfos]
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
def douban_info(doubanid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.context import MediaInfo, Context, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_user
|
||||
from app.schemas import NotExistMediaInfo, MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -53,41 +51,6 @@ def add_downloading(
|
||||
})
|
||||
|
||||
|
||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[NotExistMediaInfo])
|
||||
def exists(media_in: schemas.MediaInfo,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询缺失媒体信息
|
||||
"""
|
||||
# 媒体信息
|
||||
meta = MetaInfo(title=media_in.title)
|
||||
mtype = MediaType(media_in.type) if media_in.type else None
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if media_in.season:
|
||||
meta.begin_season = media_in.season
|
||||
meta.type = MediaType.TV
|
||||
if media_in.year:
|
||||
meta.year = media_in.year
|
||||
if media_in.tmdb_id or media_in.douban_id:
|
||||
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
|
||||
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
|
||||
else:
|
||||
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
|
||||
# 查询缺失信息
|
||||
if not mediainfo:
|
||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
||||
return [] if exist_flag else [NotExistMediaInfo()]
|
||||
elif no_exists and no_exists.get(mediakey):
|
||||
# 电视剧返回缺失的剧集
|
||||
return list(no_exists.get(mediakey).values())
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
def start_downloading(
|
||||
hashString: str,
|
||||
@@ -104,7 +67,7 @@ def stop_downloading(
|
||||
hashString: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
控制下载任务
|
||||
暂停下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||
return schemas.Response(success=True if ret else False)
|
||||
@@ -115,7 +78,7 @@ def remove_downloading(
|
||||
hashString: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
控制下载任务
|
||||
删除下载任务
|
||||
"""
|
||||
ret = DownloadChain().remove_downloading(hashString)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
@@ -42,17 +42,26 @@ def delete_download_history(history_in: schemas.DownloadHistory,
|
||||
def transfer_history(title: str = None,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
status: bool = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询转移历史记录
|
||||
"""
|
||||
if title == "失败":
|
||||
title = None
|
||||
status = False
|
||||
elif title == "成功":
|
||||
title = None
|
||||
status = True
|
||||
|
||||
if title:
|
||||
total = TransferHistory.count_by_title(db, title)
|
||||
result = TransferHistory.list_by_title(db, title, page, count)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
else:
|
||||
result = TransferHistory.list_by_page(db, page, count)
|
||||
total = TransferHistory.count(db)
|
||||
result = TransferHistory.list_by_page(db, page=page, count=count, status=status)
|
||||
total = TransferHistory.count(db, status=status)
|
||||
|
||||
return schemas.Response(success=True,
|
||||
data={
|
||||
|
||||
@@ -56,7 +56,9 @@ async def login_access_token(
|
||||
logger.info(f"用户 {user.name} 登录成功!")
|
||||
return schemas.Token(
|
||||
access_token=security.create_access_token(
|
||||
user.id,
|
||||
userid=user.id,
|
||||
username=user.name,
|
||||
super_user=user.is_superuser,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
),
|
||||
token_type="bearer",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
@@ -9,8 +8,6 @@ from app.core.config import settings
|
||||
from app.core.context import Context
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token, verify_uri_token
|
||||
from app.db import get_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
@@ -79,28 +76,6 @@ def search_by_title(title: str,
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
|
||||
def exists(title: str = None,
|
||||
year: int = None,
|
||||
mtype: str = None,
|
||||
tmdbid: int = None,
|
||||
season: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
meta = MetaInfo(title)
|
||||
if not season:
|
||||
season = meta.begin_season
|
||||
exist = MediaServerOper(db).exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
)
|
||||
return schemas.Response(success=True if exist else False, data={
|
||||
"item": exist or {}
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def media_info(mediaid: str, type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -131,5 +106,6 @@ def media_info(mediaid: str, type_name: str,
|
||||
return schemas.MediaInfo()
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if mediainfo:
|
||||
MediaChain().obtain_images(mediainfo)
|
||||
return mediainfo.to_dict()
|
||||
return schemas.MediaInfo()
|
||||
|
||||
146
app/api/endpoints/mediaserver.py
Normal file
146
app/api/endpoints/mediaserver.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.db.models import MediaServerItem
|
||||
from app.schemas import MediaType, NotExistMediaInfo
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/play/{itemid}", summary="在线播放")
|
||||
def play_item(itemid: str) -> schemas.Response:
|
||||
"""
|
||||
获取媒体服务器播放页面地址
|
||||
"""
|
||||
if not itemid:
|
||||
return schemas.Response(success=False, msg="参数错误")
|
||||
if not settings.MEDIASERVER:
|
||||
return schemas.Response(success=False, msg="未配置媒体服务器")
|
||||
mediaserver = settings.MEDIASERVER.split(",")[0]
|
||||
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
|
||||
# 重定向到play_url
|
||||
if not play_url:
|
||||
return schemas.Response(success=False, msg="未找到播放地址")
|
||||
return schemas.Response(success=True, data={
|
||||
"url": play_url
|
||||
})
|
||||
|
||||
|
||||
@router.get("/exists", summary="本地是否存在", response_model=schemas.Response)
|
||||
def exists(title: str = None,
|
||||
year: int = None,
|
||||
mtype: str = None,
|
||||
tmdbid: int = None,
|
||||
season: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
meta = MetaInfo(title)
|
||||
if not season:
|
||||
season = meta.begin_season
|
||||
# 返回对象
|
||||
ret_info = {}
|
||||
# 本地数据库是否存在
|
||||
exist: MediaServerItem = MediaServerOper(db).exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
)
|
||||
if exist:
|
||||
ret_info = {
|
||||
"id": exist.item_id
|
||||
}
|
||||
"""
|
||||
else:
|
||||
# 服务器是否存在
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict({
|
||||
"title": meta.name,
|
||||
"year": year or meta.year,
|
||||
"type": mtype or meta.type,
|
||||
"tmdb_id": tmdbid,
|
||||
"season": season
|
||||
})
|
||||
exist: schemas.ExistMediaInfo = MediaServerChain().media_exists(
|
||||
mediainfo=mediainfo
|
||||
)
|
||||
if exist:
|
||||
ret_info = {
|
||||
"id": exist.itemid
|
||||
}
|
||||
"""
|
||||
return schemas.Response(success=True if exist else False, data={
|
||||
"item": ret_info
|
||||
})
|
||||
|
||||
|
||||
@router.post("/notexists", summary="查询缺失媒体信息", response_model=List[schemas.NotExistMediaInfo])
|
||||
def not_exists(media_in: schemas.MediaInfo,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询缺失媒体信息
|
||||
"""
|
||||
# 媒体信息
|
||||
meta = MetaInfo(title=media_in.title)
|
||||
mtype = MediaType(media_in.type) if media_in.type else None
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
if media_in.season:
|
||||
meta.begin_season = media_in.season
|
||||
meta.type = MediaType.TV
|
||||
if media_in.year:
|
||||
meta.year = media_in.year
|
||||
if media_in.tmdb_id or media_in.douban_id:
|
||||
mediainfo = MediaChain().recognize_media(meta=meta, mtype=mtype,
|
||||
tmdbid=media_in.tmdb_id, doubanid=media_in.douban_id)
|
||||
else:
|
||||
mediainfo = MediaChain().recognize_by_meta(metainfo=meta)
|
||||
# 查询缺失信息
|
||||
if not mediainfo:
|
||||
raise HTTPException(status_code=404, detail="媒体信息不存在")
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
||||
return [] if exist_flag else [NotExistMediaInfo()]
|
||||
elif no_exists and no_exists.get(mediakey):
|
||||
# 电视剧返回缺失的剧集
|
||||
return list(no_exists.get(mediakey).values())
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def latest(count: int = 18,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
return MediaServerChain().latest(count=count, username=userinfo.username) or []
|
||||
|
||||
|
||||
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def playing(count: int = 12,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器正在播放条目
|
||||
"""
|
||||
return MediaServerChain().playing(count=count, username=userinfo.username) or []
|
||||
|
||||
|
||||
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
|
||||
def library(userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器媒体库列表
|
||||
"""
|
||||
return MediaServerChain().librarys(username=userinfo.username) or []
|
||||
@@ -35,6 +35,12 @@ def all_plugins(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
elif plugin.get("has_update"):
|
||||
plugin["installed"] = False
|
||||
plugins.append(plugin)
|
||||
# 本地插件存在但未安装,且本地插件不在online插件中
|
||||
plugin_ids = [plugin["id"] for plugin in plugins]
|
||||
for plugin in local_plugins:
|
||||
if plugin["id"] not in installed_ids \
|
||||
and plugin["id"] not in plugin_ids:
|
||||
plugins.append(plugin)
|
||||
return plugins
|
||||
|
||||
|
||||
@@ -62,7 +68,7 @@ def install_plugin(plugin_id: str,
|
||||
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
|
||||
if not state:
|
||||
# 安装失败
|
||||
return schemas.Response(success=False, msg=msg)
|
||||
return schemas.Response(success=False, message=msg)
|
||||
# 安装插件
|
||||
if plugin_id not in install_plugins:
|
||||
install_plugins.append(plugin_id)
|
||||
@@ -89,11 +95,23 @@ def plugin_form(plugin_id: str,
|
||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
"""
|
||||
根据插件ID获取插件配置信息
|
||||
根据插件ID获取插件数据页面
|
||||
"""
|
||||
return PluginManager().get_plugin_page(plugin_id)
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
"""
|
||||
根据插件ID重置插件配置
|
||||
"""
|
||||
# 删除配置
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id, {})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
|
||||
"""
|
||||
@@ -106,7 +124,7 @@ def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token
|
||||
def set_plugin_config(plugin_id: str, conf: dict,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据插件ID获取插件配置信息
|
||||
更新插件配置
|
||||
"""
|
||||
# 保存配置
|
||||
PluginManager().save_plugin_config(plugin_id, conf)
|
||||
|
||||
@@ -228,10 +228,11 @@ def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
|
||||
"""
|
||||
# 选中的rss站点
|
||||
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 所有站点
|
||||
all_site = Site.list_order_by_pri(db)
|
||||
if not selected_sites or not all_site:
|
||||
return []
|
||||
if not selected_sites:
|
||||
return all_site
|
||||
|
||||
# 选中的rss站点
|
||||
rss_sites = [site for site in all_site if site and site.id in selected_sites]
|
||||
|
||||
@@ -82,6 +82,7 @@ def create_subscribe(
|
||||
doubanid=subscribe_in.doubanid,
|
||||
username=current_user.name,
|
||||
best_version=subscribe_in.best_version,
|
||||
save_path=subscribe_in.save_path,
|
||||
exist_ok=True)
|
||||
return schemas.Response(success=True if sid else False, message=message, data={
|
||||
"id": sid
|
||||
@@ -264,7 +265,7 @@ def delete_subscribe(
|
||||
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
|
||||
authorization: str = Header(None)) -> Any:
|
||||
"""
|
||||
Jellyseerr/Overseerr订阅
|
||||
Jellyseerr/Overseerr网络勾子通知订阅
|
||||
"""
|
||||
if not authorization or authorization != settings.API_TOKEN:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
from typing import Union, Any
|
||||
|
||||
import tailer
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, HTTPException, Depends, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
@@ -24,6 +24,22 @@ from version import APP_VERSION
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/img/{imgurl:path}/{proxy}", summary="图片代理")
|
||||
def get_img(imgurl: str, proxy: bool = False) -> Any:
|
||||
"""
|
||||
通过图片代理(使用代理服务器)
|
||||
"""
|
||||
if not imgurl:
|
||||
return None
|
||||
if proxy:
|
||||
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
|
||||
else:
|
||||
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
|
||||
if response:
|
||||
return Response(content=response.content, media_type="image/jpeg")
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
|
||||
@@ -47,9 +47,11 @@ def manual_transfer(path: str = None,
|
||||
:param _: Token校验
|
||||
"""
|
||||
force = False
|
||||
target = Path(target) if target else None
|
||||
transfer = TransferChain()
|
||||
if logid:
|
||||
# 查询历史记录
|
||||
history = TransferHistory.get(db, logid)
|
||||
history: TransferHistory = TransferHistory.get(db, logid)
|
||||
if not history:
|
||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{logid}")
|
||||
# 强制转移
|
||||
@@ -59,19 +61,16 @@ def manual_transfer(path: str = None,
|
||||
# 目的路径
|
||||
if history.dest and str(history.dest) != "None":
|
||||
# 删除旧的已整理文件
|
||||
TransferChain().delete_files(Path(history.dest))
|
||||
transfer.delete_files(Path(history.dest))
|
||||
if not target:
|
||||
target = history.dest
|
||||
target = transfer.get_root_path(path=history.dest,
|
||||
type_name=history.type,
|
||||
category=history.category)
|
||||
elif path:
|
||||
in_path = Path(path)
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"缺少参数:path/logid")
|
||||
|
||||
if target and target != "None":
|
||||
target = Path(target)
|
||||
else:
|
||||
target = None
|
||||
|
||||
# 类型
|
||||
mtype = MediaType(type_name) if type_name else None
|
||||
# 自定义格式
|
||||
@@ -84,7 +83,7 @@ def manual_transfer(path: str = None,
|
||||
offset=episode_offset,
|
||||
)
|
||||
# 开始转移
|
||||
state, errormsg = TransferChain().manual_transfer(
|
||||
state, errormsg = transfer.manual_transfer(
|
||||
in_path=in_path,
|
||||
target=target,
|
||||
tmdbid=tmdbid,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
@@ -59,6 +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]+$'
|
||||
if not re.match(pattern, user_info.get("password")):
|
||||
return schemas.Response(success=False, message="密码需要同时包含大小写和数字")
|
||||
user_info["hashed_password"] = get_password_hash(user_info["password"])
|
||||
user_info.pop("password")
|
||||
user = User.get_by_name(db, name=user_info["name"])
|
||||
|
||||
@@ -421,15 +421,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:param force_nfo: 强制刮削nfo
|
||||
:param force_img: 强制刮削图片
|
||||
:return: 成功或失败
|
||||
"""
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, transfer_type=transfer_type)
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
|
||||
@@ -72,3 +72,33 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
return self.run_module("tv_hot", page=page, count=count)
|
||||
|
||||
def movie_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_movie_credits", doubanid=doubanid, page=page)
|
||||
|
||||
def tv_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_tv_credits", doubanid=doubanid, page=page)
|
||||
|
||||
def movie_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_movie_recommend", doubanid=doubanid)
|
||||
|
||||
def tv_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_tv_recommend", doubanid=doubanid)
|
||||
|
||||
@@ -55,6 +55,8 @@ class DownloadChain(ChainBase):
|
||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||
if torrent.pubdate:
|
||||
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
|
||||
if torrent.freedate:
|
||||
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
|
||||
if torrent.seeders:
|
||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
||||
@@ -192,7 +194,7 @@ class DownloadChain(ChainBase):
|
||||
channel=channel,
|
||||
userid=userid)
|
||||
if not content:
|
||||
return
|
||||
return None
|
||||
else:
|
||||
content = torrent_file
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
@@ -283,6 +285,10 @@ class DownloadChain(ChainBase):
|
||||
if not file_meta.begin_episode \
|
||||
or file_meta.begin_episode not in episodes:
|
||||
continue
|
||||
# 只处理视频格式
|
||||
if not Path(file).suffix \
|
||||
or Path(file).suffix not in settings.RMT_MEDIAEXT:
|
||||
continue
|
||||
files_to_add.append({
|
||||
"download_hash": _hash,
|
||||
"downloader": settings.DOWNLOADER,
|
||||
@@ -325,7 +331,8 @@ class DownloadChain(ChainBase):
|
||||
save_path: str = None,
|
||||
channel: MessageChannel = None,
|
||||
userid: str = None,
|
||||
username: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
username: str = None
|
||||
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
:param contexts: 资源上下文列表
|
||||
@@ -350,12 +357,13 @@ class DownloadChain(ChainBase):
|
||||
need = list(set(_need).difference(set(_current)))
|
||||
# 清除已下载的季信息
|
||||
seas = copy.deepcopy(no_exists.get(_mid))
|
||||
for _sea in list(seas):
|
||||
if _sea not in need:
|
||||
no_exists[_mid].pop(_sea)
|
||||
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
|
||||
no_exists.pop(_mid)
|
||||
break
|
||||
if seas:
|
||||
for _sea in list(seas):
|
||||
if _sea not in need:
|
||||
no_exists[_mid].pop(_sea)
|
||||
if not no_exists.get(_mid) and no_exists.get(_mid) is not None:
|
||||
no_exists.pop(_mid)
|
||||
break
|
||||
return need
|
||||
|
||||
def __update_episodes(_mid: Union[int, str], _sea: int, _need: list, _current: set) -> list:
|
||||
@@ -483,6 +491,9 @@ class DownloadChain(ChainBase):
|
||||
need_season = __update_seasons(_mid=need_mid,
|
||||
_need=need_season,
|
||||
_current=torrent_season)
|
||||
if not need_season:
|
||||
# 全部下载完成
|
||||
break
|
||||
# 电视剧季内的集匹配
|
||||
if no_exists:
|
||||
# TMDBID列表
|
||||
@@ -505,7 +516,7 @@ class DownloadChain(ChainBase):
|
||||
start_episode = tv.start_episode or 1
|
||||
# 缺失整季的转化为缺失集进行比较
|
||||
if not need_episodes:
|
||||
need_episodes = list(range(start_episode, total_episode))
|
||||
need_episodes = list(range(start_episode, total_episode + 1))
|
||||
# 循环种子
|
||||
for context in contexts:
|
||||
# 媒体信息
|
||||
@@ -636,7 +647,7 @@ class DownloadChain(ChainBase):
|
||||
mediainfo: MediaInfo,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
totals: Dict[int, int] = None
|
||||
) -> Tuple[bool, Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
) -> Tuple[bool, Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
检查媒体库,查询是否存在,对于剧集同时返回不存在的季集信息
|
||||
:param meta: 元数据
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import threading
|
||||
from typing import List, Union
|
||||
from typing import List, Union, Optional
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
@@ -20,11 +20,11 @@ class MediaServerChain(ChainBase):
|
||||
super().__init__()
|
||||
self.dboper = MediaServerOper()
|
||||
|
||||
def librarys(self, server: str) -> List[schemas.MediaServerLibrary]:
|
||||
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库
|
||||
"""
|
||||
return self.run_module("mediaserver_librarys", server=server)
|
||||
return self.run_module("mediaserver_librarys", server=server, username=username)
|
||||
|
||||
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
||||
"""
|
||||
@@ -44,6 +44,24 @@ class MediaServerChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
|
||||
|
||||
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
|
||||
|
||||
def latest(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
||||
|
||||
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
获取播放地址
|
||||
"""
|
||||
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
|
||||
|
||||
def sync(self):
|
||||
"""
|
||||
同步媒体库所有数据到本地数据库
|
||||
@@ -52,7 +70,7 @@ class MediaServerChain(ChainBase):
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
# 清空登记薄
|
||||
self.dboper.empty(server=settings.MEDIASERVER)
|
||||
self.dboper.empty()
|
||||
# 同步黑名单
|
||||
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
|
||||
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
from typing import Any
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional, Dict
|
||||
|
||||
from app.chain.download import *
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
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.types import EventType, MessageChannel
|
||||
from app.schemas.types import EventType, MessageChannel, MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
# 当前页面
|
||||
_current_page: int = 0
|
||||
@@ -33,7 +41,6 @@ class MessageChain(ChainBase):
|
||||
self.subscribechain = SubscribeChain()
|
||||
self.searchchain = SearchChain()
|
||||
self.medtachain = MediaChain()
|
||||
self.torrent = TorrentHelper()
|
||||
self.eventmanager = EventManager()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
@@ -137,7 +144,7 @@ class MessageChain(ChainBase):
|
||||
# 判断是否设置自动下载
|
||||
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
||||
# 匹配到自动下载用户
|
||||
if auto_download_user and any(userid == user for user in auto_download_user.split(",")):
|
||||
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,
|
||||
@@ -353,6 +360,13 @@ class MessageChain(ChainBase):
|
||||
else:
|
||||
# 未完成下载
|
||||
logger.info(f'{_current_media.title_year} 未下载未完整,添加订阅 ...')
|
||||
if downloads and _current_media.type == MediaType.TV:
|
||||
# 获取已下载剧集
|
||||
downloaded = [download.meta_info.begin_episode for download in downloads
|
||||
if download.meta_info.begin_episode]
|
||||
note = json.dumps(downloaded)
|
||||
else:
|
||||
note = None
|
||||
# 添加订阅,状态为R
|
||||
self.subscribechain.add(title=_current_media.title,
|
||||
year=_current_media.year,
|
||||
@@ -362,7 +376,8 @@ class MessageChain(ChainBase):
|
||||
channel=channel,
|
||||
userid=userid,
|
||||
username=username,
|
||||
state="R")
|
||||
state="R",
|
||||
note=note)
|
||||
|
||||
def __post_medias_message(self, channel: MessageChannel,
|
||||
title: str, items: list, userid: str, total: int):
|
||||
|
||||
@@ -2,7 +2,7 @@ import pickle
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
from typing import Dict, Tuple
|
||||
from typing import List, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -153,6 +153,7 @@ class SearchChain(ChainBase):
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
filter_rule=filter_rule)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
@@ -336,12 +337,14 @@ class SearchChain(ChainBase):
|
||||
|
||||
def filter_torrents_by_rule(self,
|
||||
torrents: List[TorrentInfo],
|
||||
filter_rule: Dict[str, str] = None
|
||||
mediainfo: MediaInfo,
|
||||
filter_rule: Dict[str, str] = None,
|
||||
) -> List[TorrentInfo]:
|
||||
"""
|
||||
使用过滤规则过滤种子
|
||||
:param torrents: 种子列表
|
||||
:param filter_rule: 过滤规则
|
||||
:param mediainfo: 媒体信息
|
||||
"""
|
||||
|
||||
if not filter_rule:
|
||||
@@ -359,6 +362,26 @@ class SearchChain(ChainBase):
|
||||
resolution = filter_rule.get("resolution")
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
# 电影大小
|
||||
movie_size = filter_rule.get("movie_size")
|
||||
# 剧集单集大小
|
||||
tv_size = filter_rule.get("tv_size")
|
||||
|
||||
def __get_size_range(size_str: str) -> Tuple[float, float]:
|
||||
"""
|
||||
获取大小范围
|
||||
"""
|
||||
if not size_str:
|
||||
return 0, 0
|
||||
try:
|
||||
size_range = size_str.split("-")
|
||||
if len(size_range) == 1:
|
||||
return 0, float(size_range[0])
|
||||
elif len(size_range) == 2:
|
||||
return float(size_range[0]), float(size_range[1])
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return 0, 0
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
@@ -394,6 +417,36 @@ class SearchChain(ChainBase):
|
||||
logger.info(f"{t.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
# 大小
|
||||
if movie_size or tv_size:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
size = tv_size
|
||||
else:
|
||||
size = movie_size
|
||||
# 大小范围
|
||||
begin_size, end_size = __get_size_range(size)
|
||||
if begin_size is not None and end_size is not None:
|
||||
meta = MetaInfo(title=t.title, subtitle=t.description)
|
||||
# 集数
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
season = meta.begin_season or 1
|
||||
if meta.total_episode:
|
||||
# 识别的总集数
|
||||
episodes_num = meta.total_episode
|
||||
else:
|
||||
# 整季集数
|
||||
episodes_num = len(mediainfo.seasons.get(season) or [1])
|
||||
# 比较大小
|
||||
if not (begin_size * 1024 ** 3 <= (t.size / episodes_num) <= end_size * 1024 ** 3):
|
||||
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} "
|
||||
f"共{episodes_num}集,不匹配大小规则 {size}")
|
||||
return False
|
||||
else:
|
||||
# 电影比较大小
|
||||
if not (begin_size * 1024 ** 3 <= t.size <= end_size * 1024 ** 3):
|
||||
logger.info(f"{t.title} {StringUtils.str_filesize(t.size)} 不匹配大小规则 {size}")
|
||||
return False
|
||||
return True
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
|
||||
@@ -199,7 +199,8 @@ class SubscribeChain(ChainBase):
|
||||
tmdbid=subscribe.tmdbid,
|
||||
doubanid=subscribe.doubanid)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
|
||||
# 非洗版状态
|
||||
@@ -235,12 +236,12 @@ class SubscribeChain(ChainBase):
|
||||
# 已存在
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||
continue
|
||||
|
||||
# 电视剧订阅处理缺失集
|
||||
if meta.type == MediaType.TV:
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
# 实际缺失集与订阅开始结束集范围进行整合
|
||||
no_exists = self.__get_subscribe_no_exits(
|
||||
no_exists=no_exists,
|
||||
mediakey=mediakey,
|
||||
@@ -249,7 +250,7 @@ class SubscribeChain(ChainBase):
|
||||
start_episode=subscribe.start_episode,
|
||||
|
||||
)
|
||||
# 打印缺失集信息
|
||||
# 打印汇总缺失集信息
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
@@ -279,13 +280,11 @@ class SubscribeChain(ChainBase):
|
||||
filter_rule=filter_rule)
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
||||
meta=meta, mediainfo=mediainfo)
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
# 过滤
|
||||
# 过滤搜索结果
|
||||
matched_contexts = []
|
||||
for context in contexts:
|
||||
torrent_meta = context.meta_info
|
||||
@@ -304,41 +303,30 @@ class SubscribeChain(ChainBase):
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 优先级小于已下载优先级的不要
|
||||
# 洗版时,优先级小于已下载优先级的不要
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order < subscribe.current_priority:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
|
||||
continue
|
||||
matched_contexts.append(context)
|
||||
|
||||
if not matched_contexts:
|
||||
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
||||
# 非洗版未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
||||
meta=meta, mediainfo=mediainfo)
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
# 自动下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts,
|
||||
no_exists=no_exists, username=subscribe.username)
|
||||
# 更新已经下载的集数
|
||||
if downloads \
|
||||
and meta.type == MediaType.TV \
|
||||
and not subscribe.best_version:
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
downloads, lefts = self.downloadchain.batch_download(
|
||||
contexts=matched_contexts,
|
||||
no_exists=no_exists,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path
|
||||
)
|
||||
|
||||
if downloads and not lefts:
|
||||
# 判断是否应完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, downloads=downloads)
|
||||
else:
|
||||
# 未完成下载
|
||||
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
# 更新订阅剩余集数和时间
|
||||
update_date = True if downloads else False
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
# 判断是否应完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
@@ -347,35 +335,74 @@ class SubscribeChain(ChainBase):
|
||||
else:
|
||||
self.message.put('所有订阅搜索完成!')
|
||||
|
||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo,
|
||||
mediainfo: MediaInfo, downloads: List[Context] = None):
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
|
||||
mediainfo: MediaInfo, downloads: List[Context]):
|
||||
"""
|
||||
判断是否应完成订阅
|
||||
更新订阅已下载资源的优先级
|
||||
"""
|
||||
if not downloads:
|
||||
return
|
||||
if not subscribe.best_version:
|
||||
# 全部下载完成
|
||||
logger.info(f'{mediainfo.title_year} 完成订阅')
|
||||
return
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
if priority == 100:
|
||||
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
|
||||
image=mediainfo.get_message_image()))
|
||||
elif downloads:
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
if priority == 100:
|
||||
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
|
||||
else:
|
||||
# 正在洗版,更新资源优先级
|
||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"current_priority": priority
|
||||
})
|
||||
|
||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo, mediainfo: MediaInfo,
|
||||
downloads: List[Context] = None,
|
||||
lefts: Dict[Union[int | str], Dict[int, NotExistMediaInfo]] = None,
|
||||
force: bool = False):
|
||||
"""
|
||||
判断是否应完成订阅
|
||||
"""
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
# 是否有剩余集
|
||||
no_lefts = not lefts or not lefts.get(mediakey)
|
||||
# 是否完成订阅
|
||||
if not subscribe.best_version:
|
||||
# 非洗板
|
||||
if ((no_lefts and meta.type == MediaType.TV)
|
||||
or (downloads and meta.type == MediaType.MOVIE)
|
||||
or force):
|
||||
# 全部下载完成
|
||||
logger.info(f'{mediainfo.title_year} 完成订阅')
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
elif downloads and meta.type == MediaType.TV:
|
||||
# 电视剧更新已下载集数
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
# 更新订阅剩余集数和时间
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=True)
|
||||
else:
|
||||
# 正在洗版,更新资源优先级
|
||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级')
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"current_priority": priority
|
||||
})
|
||||
# 未下载到内容且不完整
|
||||
logger.info(f'{mediainfo.title_year} 未下载完整,继续订阅 ...')
|
||||
if meta.type == MediaType.TV:
|
||||
# 更新订阅剩余集数
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
mediainfo=mediainfo, update_date=False)
|
||||
elif downloads:
|
||||
# 洗板,下载到了内容,更新资源优先级
|
||||
self.update_subscribe_priority(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, downloads=downloads)
|
||||
else:
|
||||
# 洗版,未下载到内容
|
||||
logger.info(f'{mediainfo.title_year} 继续洗版 ...')
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
@@ -495,9 +522,11 @@ 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)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
@@ -532,12 +561,12 @@ class SubscribeChain(ChainBase):
|
||||
# 已存在
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo, force=True)
|
||||
continue
|
||||
|
||||
# 电视剧订阅
|
||||
if meta.type == MediaType.TV:
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
# 整合实际缺失集与订阅开始集结束集
|
||||
no_exists = self.__get_subscribe_no_exits(
|
||||
no_exists=no_exists,
|
||||
mediakey=mediakey,
|
||||
@@ -546,7 +575,7 @@ class SubscribeChain(ChainBase):
|
||||
start_episode=subscribe.start_episode,
|
||||
|
||||
)
|
||||
# 打印缺失集信息
|
||||
# 打印汇总缺失集信息
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
no_exists_info = no_exists.get(mediakey).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
@@ -633,35 +662,33 @@ class SubscribeChain(ChainBase):
|
||||
filter_rule=filter_rule):
|
||||
continue
|
||||
|
||||
# 洗版时,优先级小于已下载优先级的不要
|
||||
if subscribe.best_version:
|
||||
if subscribe.current_priority \
|
||||
and torrent_info.pri_order < subscribe.current_priority:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 优先级低于已下载优先级')
|
||||
continue
|
||||
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
_match_context.append(context)
|
||||
|
||||
# 开始下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
if _match_context:
|
||||
# 批量择优下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context, no_exists=no_exists,
|
||||
username=subscribe.username)
|
||||
# 更新已经下载的集数
|
||||
if downloads and meta.type == MediaType.TV:
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
if not _match_context:
|
||||
# 未匹配到资源
|
||||
logger.info(f'{mediainfo.title_year} 未匹配到符合条件的资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, lefts=no_exists)
|
||||
continue
|
||||
|
||||
if downloads and not lefts:
|
||||
# 判断是否要完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, downloads=downloads)
|
||||
else:
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
update_date = True if downloads else False
|
||||
# 未完成下载,计算剩余集数
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
else:
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
||||
meta=meta, mediainfo=mediainfo)
|
||||
# 开始批量择优下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context,
|
||||
no_exists=no_exists,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path)
|
||||
# 判断是否要完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
@@ -684,7 +711,8 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
@@ -756,14 +784,15 @@ class SubscribeChain(ChainBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
|
||||
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
"""
|
||||
更新订阅剩余集数
|
||||
"""
|
||||
if not lefts:
|
||||
return
|
||||
mediakey = subscribe.tmdbid or subscribe.doubanid
|
||||
left_seasons = lefts.get(mediakey)
|
||||
if left_seasons:
|
||||
@@ -786,9 +815,6 @@ class SubscribeChain(ChainBase):
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
else:
|
||||
# 判断是否应完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
|
||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
@@ -850,7 +876,7 @@ class SubscribeChain(ChainBase):
|
||||
self.remote_list(channel, userid)
|
||||
|
||||
@staticmethod
|
||||
def __get_subscribe_no_exits(no_exists: Dict[int, Dict[int, NotExistMediaInfo]],
|
||||
def __get_subscribe_no_exits(no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]],
|
||||
mediakey: Union[str, int],
|
||||
begin_season: int,
|
||||
total_episode: int,
|
||||
|
||||
@@ -62,28 +62,28 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
根据TMDBID查询类似电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("movie_similar", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
|
||||
|
||||
def tv_similar(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tv_similar", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
|
||||
|
||||
def movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("movie_recommend", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
|
||||
|
||||
def tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tv_recommend", tmdbid=tmdbid)
|
||||
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
|
||||
|
||||
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
@@ -91,7 +91,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("movie_credits", tmdbid=tmdbid, page=page)
|
||||
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
@@ -99,14 +99,14 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("tv_credits", tmdbid=tmdbid, page=page)
|
||||
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def person_detail(self, person_id: int) -> dict:
|
||||
"""
|
||||
根据TMDBID查询演职员详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("person_detail", person_id=person_id)
|
||||
return self.run_module("tmdb_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
@@ -114,7 +114,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("person_credits", person_id=person_id, page=page)
|
||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=3600))
|
||||
def get_random_wallpager(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType
|
||||
@@ -34,6 +35,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
self.rsshelper = RssHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.mediachain = MediaChain()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
@@ -60,7 +62,16 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
return self.load_cache(self._rss_file) or {}
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=600))
|
||||
def clear_torrents(self):
|
||||
"""
|
||||
清理种子缓存数据
|
||||
"""
|
||||
logger.info(f'开始清理种子缓存数据 ...')
|
||||
self.remove_cache(self._spider_file)
|
||||
self.remove_cache(self._rss_file)
|
||||
logger.info(f'种子缓存数据清理完成')
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=595))
|
||||
def browse(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||
@@ -73,7 +84,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
return []
|
||||
return self.refresh_torrents(site=site)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=300))
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=295))
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||
@@ -134,6 +145,11 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 读取缓存
|
||||
torrents_cache = self.get_torrents()
|
||||
|
||||
# 缓存过滤掉无效种子
|
||||
for _domain, _torrents in torrents_cache.items():
|
||||
torrents_cache[_domain] = [_torrent for _torrent in _torrents
|
||||
if not self.torrenthelper.is_invalid(_torrent.torrent_info.enclosure)]
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
# 遍历站点缓存资源
|
||||
|
||||
@@ -259,8 +259,8 @@ class TransferChain(ChainBase):
|
||||
)
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!",
|
||||
text=f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
@@ -481,6 +481,24 @@ class TransferChain(ChainBase):
|
||||
text=errmsg, userid=userid))
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def get_root_path(path: str, type_name: str, category: str) -> Path:
|
||||
"""
|
||||
计算媒体库目录的根路径
|
||||
"""
|
||||
if not path or path == "None":
|
||||
return None
|
||||
index = -2
|
||||
if type_name != '电影':
|
||||
index = -3
|
||||
if category:
|
||||
index -= 1
|
||||
if '/' in path:
|
||||
retpath = '/'.join(path.split('/')[:index])
|
||||
else:
|
||||
retpath = '\\'.join(path.split('\\')[:index])
|
||||
return Path(retpath)
|
||||
|
||||
def re_transfer(self, logid: int, mtype: MediaType = None,
|
||||
mediaid: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
@@ -498,7 +516,7 @@ class TransferChain(ChainBase):
|
||||
src_path = Path(history.src)
|
||||
if not src_path.exists():
|
||||
return False, f"源目录不存在:{src_path}"
|
||||
dest_path = Path(history.dest) if history.dest else None
|
||||
dest_path = self.get_root_path(path=history.dest, type_name=history.type, category=history.category)
|
||||
# 查询媒体信息
|
||||
if mtype and mediaid:
|
||||
mediainfo = self.recognize_media(mtype=mtype, tmdbid=int(mediaid) if str(mediaid).isdigit() else None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseSettings
|
||||
|
||||
@@ -35,8 +35,6 @@ class Settings(BaseSettings):
|
||||
CONFIG_DIR: str = None
|
||||
# 超级管理员
|
||||
SUPERUSER: str = "admin"
|
||||
# 超级管理员初始密码
|
||||
SUPERUSER_PASSWORD: str = "password"
|
||||
# API密钥,需要更换
|
||||
API_TOKEN: str = "moviepilot"
|
||||
# 登录页面电影海报,tmdb/bing
|
||||
@@ -59,6 +57,8 @@ class Settings(BaseSettings):
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
# TVDB API Key
|
||||
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
|
||||
# Fanart开关
|
||||
FANART_ENABLE: bool = True
|
||||
# Fanart API Key
|
||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||
# 支持的后缀格式
|
||||
@@ -156,19 +156,25 @@ class Settings(BaseSettings):
|
||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
MEDIASERVER: str = "emby"
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL: int = 6
|
||||
MEDIASERVER_SYNC_INTERVAL: Optional[int] = 6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST: str = None
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST: str = None
|
||||
# EMBY外网地址,http(s)://DOMAIN:PORT,未设置时使用EMBY_HOST
|
||||
EMBY_PLAY_HOST: str = None
|
||||
# EMBY Api Key
|
||||
EMBY_API_KEY: str = None
|
||||
# Jellyfin服务器地址,IP:PORT
|
||||
JELLYFIN_HOST: str = None
|
||||
# Jellyfin外网地址,http(s)://DOMAIN:PORT,未设置时使用JELLYFIN_HOST
|
||||
JELLYFIN_PLAY_HOST: str = None
|
||||
# Jellyfin Api Key
|
||||
JELLYFIN_API_KEY: str = None
|
||||
# Plex服务器地址,IP:PORT
|
||||
PLEX_HOST: str = None
|
||||
# Plex外网地址,http(s)://DOMAIN:PORT,未设置时使用PLEX_HOST
|
||||
PLEX_PLAY_HOST: str = None
|
||||
# Plex Token
|
||||
PLEX_TOKEN: str = None
|
||||
# 转移方式 link/copy/move/softlink
|
||||
@@ -180,18 +186,18 @@ class Settings(BaseSettings):
|
||||
# CookieCloud端对端加密密码
|
||||
COOKIECLOUD_PASSWORD: str = None
|
||||
# CookieCloud同步间隔(分钟)
|
||||
COOKIECLOUD_INTERVAL: int = 60 * 24
|
||||
COOKIECLOUD_INTERVAL: Optional[int] = 60 * 24
|
||||
# OCR服务器地址
|
||||
OCR_HOST: str = "https://movie-pilot.org"
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH: str = None
|
||||
# 电影媒体库目录名,默认"电影"
|
||||
LIBRARY_MOVIE_NAME: str = None
|
||||
# 电视剧媒体库目录名,默认"电视剧"
|
||||
LIBRARY_TV_NAME: str = None
|
||||
# 动漫媒体库目录名,默认"电视剧/动漫"
|
||||
# 电影媒体库目录名
|
||||
LIBRARY_MOVIE_NAME: str = "电影"
|
||||
# 电视剧媒体库目录名
|
||||
LIBRARY_TV_NAME: str = "电视剧"
|
||||
# 动漫媒体库目录名,不设置时使用电视剧目录
|
||||
LIBRARY_ANIME_NAME: str = None
|
||||
# 二级分类
|
||||
LIBRARY_CATEGORY: bool = True
|
||||
@@ -211,9 +217,11 @@ class Settings(BaseSettings):
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/"
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: str = None
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
|
||||
@property
|
||||
def INNER_CONFIG_PATH(self):
|
||||
|
||||
@@ -6,6 +6,7 @@ from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -44,6 +45,8 @@ class TorrentInfo:
|
||||
pubdate: str = None
|
||||
# 已过时间
|
||||
date_elapsed: str = None
|
||||
# 免费截止时间
|
||||
freedate: str = None
|
||||
# 上传因子
|
||||
uploadvolumefactor: float = None
|
||||
# 下载因子
|
||||
@@ -90,7 +93,9 @@ class TorrentInfo:
|
||||
"1.0 1.0": "普通",
|
||||
"1.0 0.0": "免费",
|
||||
"2.0 1.0": "2X",
|
||||
"4.0 1.0": "4X",
|
||||
"2.0 0.0": "2X免费",
|
||||
"4.0 0.0": "4X免费",
|
||||
"1.0 0.5": "50%",
|
||||
"2.0 0.5": "2X 50%",
|
||||
"1.0 0.7": "70%",
|
||||
@@ -105,12 +110,22 @@ class TorrentInfo:
|
||||
"""
|
||||
return self.get_free_string(self.uploadvolumefactor, self.downloadvolumefactor)
|
||||
|
||||
@property
|
||||
def freedate_diff(self):
|
||||
"""
|
||||
返回免费剩余时间
|
||||
"""
|
||||
if not self.freedate:
|
||||
return ""
|
||||
return StringUtils.diff_time_str(self.freedate)
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
返回字典
|
||||
"""
|
||||
dicts = asdict(self)
|
||||
dicts["volume_factor"] = self.volume_factor
|
||||
dicts["freedate_diff"] = self.freedate_diff
|
||||
return dicts
|
||||
|
||||
|
||||
@@ -495,6 +510,18 @@ class MediaInfo:
|
||||
self.season_years = {
|
||||
season: self.year
|
||||
}
|
||||
# 风格
|
||||
if not self.genres:
|
||||
self.genres = [{"id": genre, "name": genre} for genre in info.get("genres") or []]
|
||||
# 时长
|
||||
if not self.runtime and info.get("durations"):
|
||||
# 查找数字
|
||||
match = re.search(r'\d+', info.get("durations")[0])
|
||||
if match:
|
||||
self.runtime = int(match.group())
|
||||
# 国家
|
||||
if not self.production_countries:
|
||||
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
|
||||
# 剩余属性赋值
|
||||
for key, value in info.items():
|
||||
if not hasattr(self, key):
|
||||
|
||||
@@ -270,7 +270,7 @@ class MetaVideo(MetaBase):
|
||||
self.tokens.get_next()
|
||||
self._last_token_type = "part"
|
||||
self._continue_flag = False
|
||||
self._stop_name_flag = False
|
||||
# self._stop_name_flag = False
|
||||
|
||||
def __init_year(self, token: str):
|
||||
if not self.name:
|
||||
|
||||
@@ -60,12 +60,16 @@ def MetaInfoPath(path: Path) -> MetaBase:
|
||||
根据路径识别元数据
|
||||
:param path: 路径
|
||||
"""
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=path.parent.name)
|
||||
# 文件元数据,不包含后缀
|
||||
file_meta = MetaInfo(title=path.stem)
|
||||
# 上级目录元数据
|
||||
dir_meta = MetaInfo(title=path.parent.name)
|
||||
# 合并元数据
|
||||
file_meta.merge(dir_meta)
|
||||
# 上上级目录元数据
|
||||
root_meta = MetaInfo(title=path.parent.parent.name)
|
||||
# 合并元数据
|
||||
file_meta.merge(root_meta)
|
||||
return file_meta
|
||||
|
||||
|
||||
@@ -120,7 +124,7 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\d+', result)
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
match mtype[0]:
|
||||
case "movie":
|
||||
|
||||
@@ -147,6 +147,14 @@ class PluginManager(metaclass=Singleton):
|
||||
return False
|
||||
return self.systemconfig.set(self._config_key % pid, conf)
|
||||
|
||||
def delete_plugin_config(self, pid: str) -> bool:
|
||||
"""
|
||||
删除插件配置
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return False
|
||||
return self.systemconfig.delete(self._config_key % pid)
|
||||
|
||||
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
获取插件表单
|
||||
@@ -205,6 +213,26 @@ class PluginManager(metaclass=Singleton):
|
||||
ret_apis.extend(apis)
|
||||
return ret_apis
|
||||
|
||||
def get_plugin_services(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron、interval、date、CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwagrs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
ret_services = []
|
||||
for pid, plugin in self._running_plugins.items():
|
||||
if hasattr(plugin, "get_service") \
|
||||
and ObjectUtils.check_method(plugin.get_service):
|
||||
services = plugin.get_service()
|
||||
if services:
|
||||
ret_services.extend(services)
|
||||
return ret_services
|
||||
|
||||
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
运行插件方法
|
||||
@@ -235,6 +263,8 @@ class PluginManager(metaclass=Singleton):
|
||||
markets = settings.PLUGIN_MARKET.split(",")
|
||||
for market in markets:
|
||||
online_plugins = self.pluginhelper.get_plugins(market) or {}
|
||||
if not online_plugins:
|
||||
logger.warn(f"获取插件库失败 {market}")
|
||||
for pid, plugin in online_plugins.items():
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
@@ -288,9 +318,6 @@ class PluginManager(metaclass=Singleton):
|
||||
# 图标
|
||||
if plugin.get("icon"):
|
||||
conf.update({"plugin_icon": plugin.get("icon")})
|
||||
# 主题色
|
||||
if plugin.get("color"):
|
||||
conf.update({"plugin_color": plugin.get("color")})
|
||||
# 作者
|
||||
if plugin.get("author"):
|
||||
conf.update({"plugin_author": plugin.get("author")})
|
||||
@@ -358,9 +385,6 @@ class PluginManager(metaclass=Singleton):
|
||||
# 图标
|
||||
if hasattr(plugin, "plugin_icon"):
|
||||
conf.update({"plugin_icon": plugin.plugin_icon})
|
||||
# 主题色
|
||||
if hasattr(plugin, "plugin_color"):
|
||||
conf.update({"plugin_color": plugin.plugin_color})
|
||||
# 作者
|
||||
if hasattr(plugin, "plugin_author"):
|
||||
conf.update({"plugin_author": plugin.plugin_author})
|
||||
|
||||
@@ -26,7 +26,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
|
||||
|
||||
|
||||
def create_access_token(
|
||||
subject: Union[str, Any], expires_delta: timedelta = None
|
||||
userid: Union[str, Any], username: str, super_user: bool = False,
|
||||
expires_delta: timedelta = None
|
||||
) -> str:
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
@@ -34,7 +35,12 @@ def create_access_token(
|
||||
expire = datetime.utcnow() + timedelta(
|
||||
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
to_encode = {"exp": expire, "sub": str(subject)}
|
||||
to_encode = {
|
||||
"exp": expire,
|
||||
"sub": str(userid),
|
||||
"username": username,
|
||||
"super_user": super_user
|
||||
}
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from typing import Any, Self, List
|
||||
from typing import Tuple, Optional, Generator
|
||||
|
||||
from sqlalchemy import create_engine, QueuePool
|
||||
from sqlalchemy.orm import sessionmaker, Session, scoped_session
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import declared_attr
|
||||
from sqlalchemy.orm import sessionmaker, Session, scoped_session, as_declarative
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
@@ -135,6 +138,52 @@ def db_query(func):
|
||||
return wrapper
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
id: Any
|
||||
__name__: str
|
||||
|
||||
@db_update
|
||||
def create(self, db: Session):
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
for key, value in payload.items():
|
||||
setattr(self, key, value)
|
||||
if inspect(self).detached:
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete(cls, db: Session, rid):
|
||||
db.query(cls).filter(cls.id == rid).delete()
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
result = db.query(cls).all()
|
||||
return list(result)
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
return self.__name__.lower()
|
||||
|
||||
|
||||
class DbOper:
|
||||
"""
|
||||
数据库操作基类
|
||||
|
||||
@@ -57,7 +57,14 @@ class DownloadHistoryOper(DbOper):
|
||||
按fullpath查询下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
|
||||
|
||||
def get_files_by_fullpath(self, fullpath: str) -> List[DownloadFiles]:
|
||||
"""
|
||||
按fullpath查询下载文件记录
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
return DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=True)
|
||||
|
||||
def get_files_by_savepath(self, fullpath: str) -> List[DownloadFiles]:
|
||||
"""
|
||||
@@ -78,7 +85,7 @@ class DownloadHistoryOper(DbOper):
|
||||
按fullpath查询下载文件记录hash
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath=fullpath, all_files=False)
|
||||
if fileinfo:
|
||||
return fileinfo.download_hash
|
||||
return ""
|
||||
@@ -115,3 +122,13 @@ class DownloadHistoryOper(DbOper):
|
||||
return DownloadHistory.list_by_user_date(db=self._db,
|
||||
date=date,
|
||||
username=username)
|
||||
|
||||
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: str = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_date(db=self._db,
|
||||
date=date,
|
||||
type=type,
|
||||
tmdbid=tmdbid,
|
||||
seasons=seasons)
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import random
|
||||
import string
|
||||
|
||||
from alembic.command import upgrade
|
||||
from alembic.config import Config
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.db import Engine, SessionFactory
|
||||
from app.db.models import Base
|
||||
from app.db.models.user import User
|
||||
from app.db import Engine, SessionFactory, Base
|
||||
from app.db.models import *
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -16,21 +15,29 @@ def init_db():
|
||||
"""
|
||||
初始化数据库
|
||||
"""
|
||||
# 导入模块,避免建表缺失
|
||||
for module in Path(__file__).with_name("models").glob("*.py"):
|
||||
importlib.import_module(f"app.db.models.{module.stem}")
|
||||
# 全量建表
|
||||
Base.metadata.create_all(bind=Engine)
|
||||
|
||||
|
||||
def init_super_user():
|
||||
"""
|
||||
初始化超级管理员
|
||||
"""
|
||||
# 初始化超级管理员
|
||||
with SessionFactory() as db:
|
||||
user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
||||
if not user:
|
||||
user = User(
|
||||
_user = User.get_by_name(db=db, name=settings.SUPERUSER)
|
||||
if not _user:
|
||||
# 定义包含数字、大小写字母的字符集合
|
||||
characters = string.ascii_letters + string.digits
|
||||
# 生成随机密码
|
||||
random_password = ''.join(random.choice(characters) for _ in range(16))
|
||||
logger.info(f"【超级管理员初始密码】{random_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。")
|
||||
_user = User(
|
||||
name=settings.SUPERUSER,
|
||||
hashed_password=get_password_hash(settings.SUPERUSER_PASSWORD),
|
||||
hashed_password=get_password_hash(random_password),
|
||||
is_superuser=True,
|
||||
)
|
||||
user.create(db)
|
||||
_user.create(db)
|
||||
|
||||
|
||||
def update_db():
|
||||
|
||||
@@ -25,7 +25,7 @@ class MediaServerOper(DbOper):
|
||||
return True
|
||||
return False
|
||||
|
||||
def empty(self, server: str):
|
||||
def empty(self, server: Optional[str] = None):
|
||||
"""
|
||||
清空媒体服务器数据
|
||||
"""
|
||||
|
||||
@@ -1,52 +1,9 @@
|
||||
from typing import Any, Self, List
|
||||
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.orm import as_declarative, declared_attr, Session
|
||||
|
||||
from app.db import db_update, db_query
|
||||
|
||||
|
||||
@as_declarative()
|
||||
class Base:
|
||||
id: Any
|
||||
__name__: str
|
||||
|
||||
@db_update
|
||||
def create(self, db: Session):
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def get(cls, db: Session, rid: int) -> Self:
|
||||
return db.query(cls).filter(cls.id == rid).first()
|
||||
|
||||
@db_update
|
||||
def update(self, db: Session, payload: dict):
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
for key, value in payload.items():
|
||||
setattr(self, key, value)
|
||||
if inspect(self).detached:
|
||||
db.add(self)
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def delete(cls, db: Session, rid):
|
||||
db.query(cls).filter(cls.id == rid).delete()
|
||||
|
||||
@classmethod
|
||||
@db_update
|
||||
def truncate(cls, db: Session):
|
||||
db.query(cls).delete()
|
||||
|
||||
@classmethod
|
||||
@db_query
|
||||
def list(cls, db: Session) -> List[Self]:
|
||||
result = db.query(cls).all()
|
||||
return list(result)
|
||||
|
||||
def to_dict(self):
|
||||
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
|
||||
|
||||
@declared_attr
|
||||
def __tablename__(self) -> str:
|
||||
return self.__name__.lower()
|
||||
from .downloadhistory import DownloadHistory, DownloadFiles
|
||||
from .mediaserver import MediaServerItem
|
||||
from .plugindata import PluginData
|
||||
from .site import Site
|
||||
from .siteicon import SiteIcon
|
||||
from .subscribe import Subscribe
|
||||
from .systemconfig import SystemConfig
|
||||
from .transferhistory import TransferHistory
|
||||
from .user import User
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query
|
||||
from app.db.models import Base, db_update
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class DownloadHistory(Base):
|
||||
@@ -123,6 +122,24 @@ class DownloadHistory(Base):
|
||||
DownloadHistory.id.desc()).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_date(db: Session, date: str, type: str, tmdbid: str, seasons: str = None):
|
||||
"""
|
||||
查询某时间之后的下载历史
|
||||
"""
|
||||
if seasons:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
|
||||
DownloadHistory.type == type,
|
||||
DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == seasons).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date > date,
|
||||
DownloadHistory.type == type,
|
||||
DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
|
||||
class DownloadFiles(Base):
|
||||
"""
|
||||
@@ -157,9 +174,13 @@ class DownloadFiles(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_fullpath(db: Session, fullpath: str):
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
def get_by_fullpath(db: Session, fullpath: str, all_files: bool = False):
|
||||
if not all_files:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).first()
|
||||
else:
|
||||
return db.query(DownloadFiles).filter(DownloadFiles.fullpath == fullpath).order_by(
|
||||
DownloadFiles.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query
|
||||
from app.db.models import Base, db_update
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class MediaServerItem(Base):
|
||||
"""
|
||||
站点表
|
||||
媒体服务器媒体条目表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 服务器类型
|
||||
@@ -48,8 +48,11 @@ class MediaServerItem(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
def empty(db: Session, server: str):
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
def empty(db: Session, server: Optional[str] = None):
|
||||
if server is None:
|
||||
db.query(MediaServerItem).delete()
|
||||
else:
|
||||
db.query(MediaServerItem).filter(MediaServerItem.server == server).delete()
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query
|
||||
from app.db.models import Base, db_update
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class PluginData(Base):
|
||||
@@ -3,8 +3,7 @@ from datetime import datetime
|
||||
from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query
|
||||
from app.db.models import Base, db_update
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class Site(Base):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, Base
|
||||
|
||||
|
||||
class SiteIcon(Base):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_update, db_query
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class Subscribe(Base):
|
||||
@@ -66,6 +65,8 @@ class Subscribe(Base):
|
||||
best_version = Column(Integer, default=0)
|
||||
# 当前优先级
|
||||
current_priority = Column(Integer)
|
||||
# 保存路径
|
||||
save_path = Column(String)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_update, db_query
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
|
||||
@@ -3,8 +3,7 @@ import time
|
||||
from sqlalchemy import Column, Integer, String, Sequence, Boolean, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query
|
||||
from app.db.models import Base, db_update
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class TransferHistory(Base):
|
||||
@@ -49,17 +48,28 @@ class TransferHistory(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30):
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
def list_by_title(db: Session, title: str, page: int = 1, count: int = 30, status: bool = None):
|
||||
if status is not None:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.status == status).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.title.like(f'%{title}%')).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_page(db: Session, page: int = 1, count: int = 30):
|
||||
result = db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
def list_by_page(db: Session, page: int = 1, count: int = 30, status: bool = None):
|
||||
if status is not None:
|
||||
result = db.query(TransferHistory).filter(TransferHistory.status == status).order_by(
|
||||
TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
else:
|
||||
result = db.query(TransferHistory).order_by(TransferHistory.date.desc()).offset((page - 1) * count).limit(
|
||||
count).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@@ -93,13 +103,20 @@ class TransferHistory(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def count(db: Session):
|
||||
return db.query(func.count(TransferHistory.id)).first()[0]
|
||||
def count(db: Session, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).first()[0]
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def count_by_title(db: Session, title: str):
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
||||
def count_by_title(db: Session, title: str, status: bool = None):
|
||||
if status is not None:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%'),
|
||||
TransferHistory.status == status).first()[0]
|
||||
else:
|
||||
return db.query(func.count(TransferHistory.id)).filter(TransferHistory.title.like(f'%{title}%')).first()[0]
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -2,8 +2,7 @@ from sqlalchemy import Boolean, Column, Integer, String, Sequence
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.security import verify_password
|
||||
from app.db import db_update, db_query
|
||||
from app.db.models import Base
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.plugin import PluginData
|
||||
from app.db.models.plugindata import PluginData
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,20 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
return self.__SYSTEMCONF
|
||||
return self.__SYSTEMCONF.get(key)
|
||||
|
||||
def delete(self, key: Union[str, SystemConfigKey]):
|
||||
"""
|
||||
删除系统设置
|
||||
"""
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
# 更新内存
|
||||
self.__SYSTEMCONF.pop(key, None)
|
||||
# 写入数据库
|
||||
conf = SystemConfig.get_by_key(self._db, key)
|
||||
if conf:
|
||||
conf.delete(self._db, conf.id)
|
||||
return True
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import importlib
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ModuleHelper:
|
||||
@@ -35,3 +36,17 @@ class ModuleHelper:
|
||||
print(f'加载模块 {package_name} 失败:{err}')
|
||||
|
||||
return submodules
|
||||
|
||||
@staticmethod
|
||||
def dynamic_import_all_modules(base_path: Path, package_name: str):
|
||||
"""
|
||||
动态导入目录下所有模块
|
||||
"""
|
||||
modules = []
|
||||
# 遍历文件夹,找到所有模块文件
|
||||
for file in base_path.glob("*.py"):
|
||||
file_name = file.stem
|
||||
if file_name != "__init__":
|
||||
modules.append(file_name)
|
||||
full_module_name = f"{package_name}.{file_name}"
|
||||
importlib.import_module(full_module_name)
|
||||
|
||||
@@ -16,6 +16,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
插件市场管理,下载安装插件到本地
|
||||
"""
|
||||
|
||||
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
|
||||
|
||||
@cached(cache=TTLCache(maxsize=10, ttl=1800))
|
||||
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
|
||||
"""
|
||||
@@ -24,32 +26,53 @@ class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
if not repo_url:
|
||||
return {}
|
||||
res = RequestUtils(proxies=settings.PROXY, timeout=10).get_res(f"{repo_url}package.json")
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return {}
|
||||
raw_url = self._base_url % (user, repo)
|
||||
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)
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def install(pid: str, repo_url: str) -> Tuple[bool, str]:
|
||||
def get_repo_info(repo_url: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
安装插件
|
||||
获取Github仓库信息
|
||||
:param repo_url: Github仓库地址
|
||||
"""
|
||||
# 从Github的repo_url获取用户和项目名
|
||||
if not repo_url:
|
||||
return None, None
|
||||
if not repo_url.endswith("/"):
|
||||
repo_url += "/"
|
||||
if repo_url.count("/") < 6:
|
||||
repo_url = f"{repo_url}main/"
|
||||
try:
|
||||
user, repo = repo_url.split("/")[-4:-2]
|
||||
except Exception as e:
|
||||
return False, f"不支持的插件仓库地址格式:{str(e)}"
|
||||
if not user or not repo:
|
||||
return False, "不支持的插件仓库地址格式"
|
||||
print(str(e))
|
||||
return None, None
|
||||
return user, repo
|
||||
|
||||
def install(self, pid: str, repo_url: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
安装插件
|
||||
"""
|
||||
if SystemUtils.is_frozen():
|
||||
return False, "可执行文件模式下,只能安装本地插件"
|
||||
|
||||
# 从Github的repo_url获取用户和项目名
|
||||
user, repo = self.get_repo_info(repo_url)
|
||||
if not user or not repo:
|
||||
return False, "不支持的插件仓库地址格式"
|
||||
|
||||
def __get_filelist(_p: str) -> Tuple[Optional[list], Optional[str]]:
|
||||
"""
|
||||
获取插件的文件列表
|
||||
"""
|
||||
file_api = f"https://api.github.com/repos/{user}/{repo}/contents/plugins/{_p.lower()}"
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(file_api)
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS, timeout=30).get_res(file_api)
|
||||
if not r or r.status_code != 200:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
ret = r.json()
|
||||
@@ -66,7 +89,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
for item in _l:
|
||||
if item.get("download_url"):
|
||||
# 下载插件文件
|
||||
res = RequestUtils(proxies=settings.PROXY).get_res(item["download_url"])
|
||||
res = RequestUtils(proxies=settings.PROXY,
|
||||
headers=settings.GITHUB_HEADERS, timeout=60).get_res(item["download_url"])
|
||||
if not res:
|
||||
return False, f"文件 {item.get('name')} 下载失败!"
|
||||
elif res.status_code != 200:
|
||||
@@ -83,7 +107,7 @@ class PluginHelper(metaclass=Singleton):
|
||||
l, m = __get_filelist(p)
|
||||
if not l:
|
||||
return False, m
|
||||
return __download_files(p, l)
|
||||
__download_files(p, l)
|
||||
return True, ""
|
||||
|
||||
if not pid or not repo_url:
|
||||
@@ -123,5 +147,5 @@ class PluginHelper(metaclass=Singleton):
|
||||
# 插件目录下如有requirements.txt则安装依赖
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if requirements_file.exists():
|
||||
SystemUtils.execute(f"pip install -r {requirements_file}")
|
||||
SystemUtils.execute(f"pip install -r {requirements_file} > /dev/null 2>&1")
|
||||
return True, ""
|
||||
|
||||
103
app/helper/resource.py
Normal file
103
app/helper/resource.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class ResourceHelper(metaclass=Singleton):
|
||||
"""
|
||||
检测和更新资源包
|
||||
"""
|
||||
# 资源包的git仓库地址
|
||||
_repo = "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.json"
|
||||
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources"
|
||||
_base_dir: Path = settings.ROOT_PATH
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.check()
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
检测是否有更新,如有则下载安装
|
||||
"""
|
||||
if not settings.AUTO_UPDATE_RESOURCE:
|
||||
return
|
||||
if SystemUtils.is_frozen():
|
||||
return
|
||||
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)
|
||||
else:
|
||||
logger.warn("无法连接资源包仓库!")
|
||||
return
|
||||
online_version = resource_info.get("version")
|
||||
if online_version:
|
||||
logger.info(f"最新资源包版本:v{online_version}")
|
||||
# 需要更新的资源包
|
||||
need_updates = {}
|
||||
# 资源明细
|
||||
resources: dict = resource_info.get("resources") or {}
|
||||
for rname, resource in resources.items():
|
||||
rtype = resource.get("type")
|
||||
platform = resource.get("platform")
|
||||
target = resource.get("target")
|
||||
version = resource.get("version")
|
||||
# 判断平台
|
||||
if platform and platform != SystemUtils.platform:
|
||||
continue
|
||||
# 判断本地是否存在
|
||||
local_path = self._base_dir / target / rname
|
||||
if not local_path.exists():
|
||||
continue
|
||||
# 判断版本号
|
||||
if rtype == "auth":
|
||||
# 站点认证资源
|
||||
local_version = self.siteshelper.auth_version
|
||||
elif rtype == "sites":
|
||||
# 站点索引资源
|
||||
local_version = self.siteshelper.indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, local_version) > 0:
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
# 需要安装
|
||||
need_updates[rname] = target
|
||||
if need_updates:
|
||||
# 下载文件信息列表
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=30).get_res(self._files_api)
|
||||
if not r or r.status_code != 200:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
files_info = r.json()
|
||||
for item in files_info:
|
||||
save_path = need_updates.get(item.get("name"))
|
||||
if not save_path:
|
||||
continue
|
||||
if item.get("download_url"):
|
||||
# 下载资源文件
|
||||
res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=180).get_res(item["download_url"])
|
||||
if not res:
|
||||
logger.error(f"文件 {item.get('name')} 下载失败!")
|
||||
elif res.status_code != 200:
|
||||
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
|
||||
# 创建插件文件夹
|
||||
file_path = self._base_dir / save_path / item.get("name")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 写入文件
|
||||
file_path.write_bytes(res.content)
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemUtils.restart()
|
||||
else:
|
||||
logger.info("所有资源已最新,无需更新")
|
||||
@@ -14,13 +14,17 @@ from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.schemas.types import MediaType, SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TorrentHelper:
|
||||
class TorrentHelper(metaclass=Singleton):
|
||||
"""
|
||||
种子帮助类
|
||||
"""
|
||||
|
||||
# 失败的种子:站点链接
|
||||
_invalid_torrents = []
|
||||
|
||||
def __init__(self):
|
||||
self.system_config = SystemConfigOper()
|
||||
|
||||
@@ -123,6 +127,8 @@ class TorrentHelper:
|
||||
elif req.status_code == 429:
|
||||
return None, None, "", [], "触发站点流控,请稍后重试"
|
||||
else:
|
||||
# 把错误的种子记下来,避免重复使用
|
||||
self.add_invalid(url)
|
||||
return None, None, "", [], f"下载种子出错,状态码:{req.status_code}"
|
||||
|
||||
@staticmethod
|
||||
@@ -276,3 +282,16 @@ class TorrentHelper:
|
||||
continue
|
||||
episodes = list(set(episodes).union(set(meta.episode_list)))
|
||||
return episodes
|
||||
|
||||
def is_invalid(self, url: str) -> bool:
|
||||
"""
|
||||
判断种子是否是无效种子
|
||||
"""
|
||||
return url in self._invalid_torrents
|
||||
|
||||
def add_invalid(self, url: str):
|
||||
"""
|
||||
添加无效种子
|
||||
"""
|
||||
if url not in self._invalid_torrents:
|
||||
self._invalid_torrents.append(url)
|
||||
|
||||
55
app/main.py
55
app/main.py
@@ -19,12 +19,15 @@ if SystemUtils.is_frozen():
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.init import init_db, update_db
|
||||
from app.db.init import init_db, update_db, init_super_user
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.helper.display import DisplayHelper
|
||||
from app.helper.resource import ResourceHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.command import Command
|
||||
from app.command import Command, CommandChian
|
||||
from app.schemas import Notification, NotificationType
|
||||
|
||||
# App
|
||||
App = FastAPI(title=settings.PROJECT_NAME,
|
||||
@@ -60,7 +63,9 @@ def start_frontend():
|
||||
"""
|
||||
启动前端服务
|
||||
"""
|
||||
if not SystemUtils.is_frozen():
|
||||
# 仅Windows可执行文件支持内嵌nginx
|
||||
if not SystemUtils.is_frozen() \
|
||||
or not SystemUtils.is_windows():
|
||||
return
|
||||
# 临时Nginx目录
|
||||
nginx_path = settings.ROOT_PATH / 'nginx'
|
||||
@@ -73,27 +78,20 @@ def start_frontend():
|
||||
SystemUtils.move(nginx_path, run_nginx_dir)
|
||||
# 启动Nginx
|
||||
import subprocess
|
||||
if SystemUtils.is_windows():
|
||||
subprocess.Popen("start nginx.exe",
|
||||
cwd=run_nginx_dir,
|
||||
shell=True)
|
||||
else:
|
||||
subprocess.Popen("nohup ./nginx &",
|
||||
cwd=run_nginx_dir,
|
||||
shell=True)
|
||||
subprocess.Popen("start nginx.exe",
|
||||
cwd=run_nginx_dir,
|
||||
shell=True)
|
||||
|
||||
|
||||
def stop_frontend():
|
||||
"""
|
||||
停止前端服务
|
||||
"""
|
||||
if not SystemUtils.is_frozen():
|
||||
if not SystemUtils.is_frozen() \
|
||||
or not SystemUtils.is_windows():
|
||||
return
|
||||
import subprocess
|
||||
if SystemUtils.is_windows():
|
||||
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
||||
else:
|
||||
subprocess.Popen(f"killall nginx", shell=True)
|
||||
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
||||
|
||||
|
||||
def start_tray():
|
||||
@@ -104,6 +102,9 @@ def start_tray():
|
||||
if not SystemUtils.is_frozen():
|
||||
return
|
||||
|
||||
if not SystemUtils.is_windows():
|
||||
return
|
||||
|
||||
def open_web():
|
||||
"""
|
||||
调用浏览器打开前端页面
|
||||
@@ -139,6 +140,22 @@ def start_tray():
|
||||
threading.Thread(target=TrayIcon.run, daemon=True).start()
|
||||
|
||||
|
||||
def check_auth():
|
||||
"""
|
||||
检查认证状态
|
||||
"""
|
||||
if SitesHelper().auth_level < 2:
|
||||
err_msg = "用户认证失败,站点相关功能将无法使用!"
|
||||
MessageHelper().put(f"注意:{err_msg}")
|
||||
CommandChian().post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title="MoviePilot用户认证",
|
||||
text=err_msg
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@App.on_event("shutdown")
|
||||
def shutdown_server():
|
||||
"""
|
||||
@@ -165,10 +182,14 @@ def start_module():
|
||||
"""
|
||||
启动模块
|
||||
"""
|
||||
# 初始化超级管理员
|
||||
init_super_user()
|
||||
# 虚拟显示
|
||||
DisplayHelper()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
# 资源包检测
|
||||
ResourceHelper()
|
||||
# 加载模块
|
||||
ModuleManager()
|
||||
# 加载插件
|
||||
@@ -181,6 +202,8 @@ def start_module():
|
||||
init_routers()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
# 检查认证状态
|
||||
check_auth()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -561,7 +561,7 @@ class DoubanModule(_ModuleBase):
|
||||
continue
|
||||
if mtype and mtype.value != type_name:
|
||||
continue
|
||||
if mtype == MediaType.TV and not season:
|
||||
if mtype and mtype == MediaType.TV and not season:
|
||||
season = 1
|
||||
item = item_obj.get("target")
|
||||
title = item.get("title")
|
||||
@@ -588,12 +588,15 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 传输类型
|
||||
:param force_nfo: 是否强制刮削nfo
|
||||
:param force_img: 是否强制刮削图片
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "douban":
|
||||
@@ -618,12 +621,21 @@ class DoubanModule(_ModuleBase):
|
||||
else:
|
||||
doubaninfo = self.douban_info(doubanid=mediainfo.douban_id,
|
||||
mtype=mediainfo.type)
|
||||
if not doubaninfo:
|
||||
logger(f"未获取到 {mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
|
||||
return
|
||||
# 豆瓣媒体信息
|
||||
mediainfo = MediaInfo(douban_info=doubaninfo)
|
||||
# 补充图片
|
||||
self.obtain_images(mediainfo)
|
||||
# 刮削路径
|
||||
scrape_path = path / path.name
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
mediainfo=mediainfo,
|
||||
file_path=scrape_path,
|
||||
transfer_type=transfer_type)
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
else:
|
||||
# 目录下的所有文件
|
||||
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
|
||||
@@ -649,18 +661,104 @@ class DoubanModule(_ModuleBase):
|
||||
else:
|
||||
doubaninfo = self.douban_info(doubanid=mediainfo.douban_id,
|
||||
mtype=mediainfo.type)
|
||||
if not doubaninfo:
|
||||
logger(f"未获取到 {mediainfo.douban_id} 的豆瓣媒体信息,无法刮削!")
|
||||
continue
|
||||
# 豆瓣媒体信息
|
||||
mediainfo = MediaInfo(douban_info=doubaninfo)
|
||||
# 补充图片
|
||||
self.obtain_images(mediainfo)
|
||||
# 刮削
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
mediainfo=mediainfo,
|
||||
file_path=file,
|
||||
transfer_type=transfer_type)
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
except Exception as e:
|
||||
logger.error(f"刮削文件 {file} 失败,原因:{str(e)}")
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
补充抓取媒体信息图片
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
if not mediainfo.douban_id:
|
||||
return None
|
||||
if mediainfo.backdrop_path:
|
||||
# 没有图片缺失
|
||||
return mediainfo
|
||||
# 调用图片接口
|
||||
if not mediainfo.backdrop_path:
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
info = self.doubanapi.movie_photos(mediainfo.douban_id)
|
||||
else:
|
||||
info = self.doubanapi.tv_photos(mediainfo.douban_id)
|
||||
if not info:
|
||||
return mediainfo
|
||||
images = info.get("photos")
|
||||
# 背景图
|
||||
if images:
|
||||
backdrop = images[0].get("image", {}).get("large") or {}
|
||||
if backdrop:
|
||||
mediainfo.backdrop_path = backdrop.get("url")
|
||||
return mediainfo
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清除缓存
|
||||
"""
|
||||
logger.info("开始清除豆瓣缓存 ...")
|
||||
self.doubanapi.clear_cache()
|
||||
self.cache.clear()
|
||||
logger.info("豆瓣缓存清除完成")
|
||||
|
||||
def douban_movie_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
"""
|
||||
result = self.doubanapi.movie_celebrities(subject_id=doubanid)
|
||||
if not result:
|
||||
return []
|
||||
ret_list = result.get("actors") or []
|
||||
if ret_list:
|
||||
return ret_list[(page - 1) * count: page * count]
|
||||
else:
|
||||
return []
|
||||
|
||||
def douban_tv_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职员表
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
"""
|
||||
result = self.doubanapi.tv_celebrities(subject_id=doubanid)
|
||||
if not result:
|
||||
return []
|
||||
ret_list = result.get("actors") or []
|
||||
if ret_list:
|
||||
return ret_list[(page - 1) * count: page * count]
|
||||
else:
|
||||
return []
|
||||
|
||||
def douban_movie_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.doubanapi.movie_recommendations(subject_id=doubanid) or []
|
||||
|
||||
def douban_tv_recommend(self, doubanid: str) -> List[dict]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.doubanapi.tv_recommendations(subject_id=doubanid) or []
|
||||
|
||||
@@ -427,6 +427,54 @@ class DoubanApi(metaclass=Singleton):
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影推荐
|
||||
:param subject_id: 电影id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommendations(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧推荐
|
||||
:param subject_id: 电视剧id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_recommendations"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_photos(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影剧照
|
||||
:param subject_id: 电影id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_photos(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧剧照
|
||||
:param subject_id: 电视剧id
|
||||
:param start: 开始
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清空LRU缓存
|
||||
|
||||
@@ -14,45 +14,67 @@ from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DoubanScraper:
|
||||
|
||||
_transfer_type = settings.TRANSFER_TYPE
|
||||
_force_nfo = False
|
||||
_force_img = False
|
||||
|
||||
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
file_path: Path, transfer_type: str):
|
||||
file_path: Path, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False):
|
||||
"""
|
||||
生成刮削文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param file_path: 文件路径或者目录路径
|
||||
:param transfer_type: 转输类型
|
||||
:param force_nfo: 强制生成NFO
|
||||
:param force_img: 强制生成图片
|
||||
"""
|
||||
|
||||
self._transfer_type = transfer_type
|
||||
self._force_nfo = force_nfo
|
||||
self._force_img = force_img
|
||||
|
||||
try:
|
||||
# 电影
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 强制或者不已存在时才处理
|
||||
if not file_path.with_name("movie.nfo").exists() \
|
||||
and not file_path.with_suffix(".nfo").exists():
|
||||
if self._force_nfo or (not file_path.with_name("movie.nfo").exists()
|
||||
and not file_path.with_suffix(".nfo").exists()):
|
||||
# 生成电影描述文件
|
||||
self.__gen_movie_nfo_file(mediainfo=mediainfo,
|
||||
file_path=file_path)
|
||||
# 生成电影图片
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}"))
|
||||
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=image_path)
|
||||
# 背景图
|
||||
if mediainfo.backdrop_path:
|
||||
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.backdrop_path,
|
||||
file_path=image_path)
|
||||
# 电视剧
|
||||
else:
|
||||
# 不存在时才处理
|
||||
if not file_path.parent.with_name("tvshow.nfo").exists():
|
||||
if self._force_nfo or not file_path.parent.with_name("tvshow.nfo").exists():
|
||||
# 根目录描述文件
|
||||
self.__gen_tv_nfo_file(mediainfo=mediainfo,
|
||||
dir_path=file_path.parents[1])
|
||||
# 生成根目录图片
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}"))
|
||||
image_path = file_path.with_name(f"poster{Path(mediainfo.poster_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.poster_path,
|
||||
file_path=image_path)
|
||||
# 背景图
|
||||
if mediainfo.backdrop_path:
|
||||
image_path = file_path.with_name(f"backdrop{Path(mediainfo.backdrop_path).suffix}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=mediainfo.backdrop_path,
|
||||
file_path=image_path)
|
||||
# 季目录NFO
|
||||
if not file_path.with_name("season.nfo").exists():
|
||||
if self._force_nfo or not file_path.with_name("season.nfo").exists():
|
||||
self.__gen_tv_season_nfo_file(mediainfo=mediainfo,
|
||||
season=meta.begin_season,
|
||||
season_path=file_path.parent)
|
||||
@@ -167,8 +189,6 @@ class DoubanScraper:
|
||||
"""
|
||||
下载图片并保存
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
@@ -193,8 +213,6 @@ class DoubanScraper:
|
||||
"""
|
||||
保存NFO
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, xml_str)
|
||||
|
||||
@@ -103,13 +103,13 @@ class EmbyModule(_ModuleBase):
|
||||
media_statistic.user_count = self.emby.get_user_count()
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
if server != "emby":
|
||||
if server and server != "emby":
|
||||
return None
|
||||
return self.emby.get_librarys()
|
||||
return self.emby.get_librarys(username)
|
||||
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
@@ -141,3 +141,29 @@ class EmbyModule(_ModuleBase):
|
||||
season=season,
|
||||
episodes=episodes
|
||||
) for season, episodes in seasoninfo.items()]
|
||||
|
||||
def mediaserver_playing(self, count: int = 20,
|
||||
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
if server and server != "emby":
|
||||
return []
|
||||
return self.emby.get_resume(num=count, username=username)
|
||||
|
||||
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
获取媒体库播放地址
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
return self.emby.get_play_url(item_id)
|
||||
|
||||
def mediaserver_latest(self, count: int = 20,
|
||||
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
if server and server != "emby":
|
||||
return []
|
||||
return self.emby.get_latest(num=count, username=username)
|
||||
|
||||
@@ -22,9 +22,16 @@ class Emby(metaclass=Singleton):
|
||||
self._host += "/"
|
||||
if not self._host.startswith("http"):
|
||||
self._host = "http://" + self._host
|
||||
self._playhost = settings.EMBY_PLAY_HOST
|
||||
if self._playhost:
|
||||
if not self._playhost.endswith("/"):
|
||||
self._playhost += "/"
|
||||
if not self._playhost.startswith("http"):
|
||||
self._playhost = "http://" + self._playhost
|
||||
self._apikey = settings.EMBY_API_KEY
|
||||
self.user = self.get_user(settings.SUPERUSER)
|
||||
self.folders = self.get_emby_folders()
|
||||
self.serverid = self.get_server_id()
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
"""
|
||||
@@ -59,13 +66,52 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def __get_emby_librarys(self) -> List[dict]:
|
||||
def get_emby_virtual_folders(self) -> List[dict]:
|
||||
"""
|
||||
获取Emby媒体库所有路径列表(包含共享路径)
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%semby/Library/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
library_items = res.json().get("Items")
|
||||
librarys = []
|
||||
for library_item in library_items:
|
||||
library_name = library_item.get('Name')
|
||||
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
|
||||
library_paths = []
|
||||
for path in pathInfos:
|
||||
if path.get('NetworkPath'):
|
||||
library_paths.append(path.get('NetworkPath'))
|
||||
else:
|
||||
library_paths.append(path.get('Path'))
|
||||
|
||||
if library_name and library_paths:
|
||||
librarys.append({
|
||||
'Name': library_name,
|
||||
'Path': library_paths
|
||||
})
|
||||
return librarys
|
||||
else:
|
||||
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def __get_emby_librarys(self, username: str = None) -> List[dict]:
|
||||
"""
|
||||
获取Emby媒体库列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = f"{self._host}emby/Users/{self.user}/Views?api_key={self._apikey}"
|
||||
if username:
|
||||
user = self.get_user(username)
|
||||
else:
|
||||
user = self.user
|
||||
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
@@ -77,14 +123,17 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接User/Views 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
libraries = []
|
||||
for library in self.__get_emby_librarys() or []:
|
||||
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||
for library in self.__get_emby_librarys(username) or []:
|
||||
if library.get("Name") in black_list:
|
||||
continue
|
||||
match library.get("CollectionType"):
|
||||
case "movies":
|
||||
library_type = MediaType.MOVIE.value
|
||||
@@ -92,13 +141,17 @@ class Emby(metaclass=Singleton):
|
||||
library_type = MediaType.TV.value
|
||||
case _:
|
||||
continue
|
||||
image = self.__get_local_image_by_id(library.get("Id"))
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
server="emby",
|
||||
id=library.get("Id"),
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type
|
||||
type=library_type,
|
||||
image=image,
|
||||
link=f'{self._playhost or self._host}web/index.html'
|
||||
f'#!/videos?serverId={self.serverid}&parentId={library.get("Id")}'
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
@@ -366,7 +419,7 @@ class Emby(metaclass=Singleton):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
# 返回
|
||||
return tv_item.get("Id"), season_episodes
|
||||
return item_id, season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None, None
|
||||
@@ -846,7 +899,7 @@ class Emby(metaclass=Singleton):
|
||||
eventItem.overview = message.get('Item', {}).get('Overview')
|
||||
eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage')
|
||||
if not eventItem.percentage:
|
||||
if message.get('PlaybackInfo', {}).get('PositionTicks'):
|
||||
if message.get('PlaybackInfo', {}).get('PositionTicks') and message.get('Item', {}).get('RunTimeTicks'):
|
||||
eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \
|
||||
message.get('Item', {}).get('RunTimeTicks') * 100
|
||||
if message.get('Session'):
|
||||
@@ -907,3 +960,160 @@ class Emby(metaclass=Singleton):
|
||||
except Exception as e:
|
||||
logger.error(f"连接Emby出错:" + str(e))
|
||||
return None
|
||||
|
||||
def get_play_url(self, item_id: str) -> str:
|
||||
"""
|
||||
拼装媒体播放链接
|
||||
:param item_id: 媒体的的ID
|
||||
"""
|
||||
return f"{self._playhost or self._host}web/index.html#!" \
|
||||
f"/item?id={item_id}&context=home&serverId={self.serverid}"
|
||||
|
||||
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
|
||||
"""
|
||||
获取Emby的Backdrop图片地址
|
||||
:param: item_id: 在Emby中的ID
|
||||
:param: image_tag: 图片的tag
|
||||
:param: remote 是否远程使用,TG微信等客户端调用应为True
|
||||
:param: inner 是否NT内部调用,为True是会使用NT中转
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return ""
|
||||
if not image_tag or not item_id:
|
||||
return ""
|
||||
return f"{self._host}Items/{item_id}/" \
|
||||
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
|
||||
|
||||
def __get_local_image_by_id(self, item_id: str) -> str:
|
||||
"""
|
||||
根据ItemId从媒体服务器查询本地图片地址
|
||||
:param: item_id: 在Emby中的ID
|
||||
:param: remote 是否远程使用,TG微信等客户端调用应为True
|
||||
:param: inner 是否NT内部调用,为True是会使用NT中转
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return ""
|
||||
return "%sItems/%s/Images/Primary" % (self._host, item_id)
|
||||
|
||||
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
获得继续观看
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
if username:
|
||||
user = self.get_user(username)
|
||||
else:
|
||||
user = self.user
|
||||
req_url = (f"{self._host}Users/{user}/Items/Resume?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
result = res.json().get("Items") or []
|
||||
ret_resume = []
|
||||
# 用户媒体库文件夹列表(排除黑名单)
|
||||
library_folders = self.get_user_library_folders()
|
||||
for item in result:
|
||||
if len(ret_resume) == num:
|
||||
break
|
||||
if item.get("Type") not in ["Movie", "Episode"]:
|
||||
continue
|
||||
item_path = item.get("Path")
|
||||
if item_path and library_folders and not any(
|
||||
str(item_path).startswith(folder) for folder in library_folders):
|
||||
continue
|
||||
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
|
||||
link = self.get_play_url(item.get("Id"))
|
||||
if item_type == MediaType.MOVIE.value:
|
||||
title = item.get("Name")
|
||||
subtitle = item.get("ProductionYear")
|
||||
else:
|
||||
title = f'{item.get("SeriesName")}'
|
||||
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
|
||||
if item_type == MediaType.MOVIE.value:
|
||||
if item.get("BackdropImageTags"):
|
||||
image = self.__get_backdrop_url(item_id=item.get("Id"),
|
||||
image_tag=item.get("BackdropImageTags")[0])
|
||||
else:
|
||||
image = self.__get_local_image_by_id(item.get("Id"))
|
||||
else:
|
||||
image = self.__get_backdrop_url(item_id=item.get("SeriesId"),
|
||||
image_tag=item.get("SeriesPrimaryImageTag"))
|
||||
if not image:
|
||||
image = self.__get_local_image_by_id(item.get("SeriesId"))
|
||||
ret_resume.append(schemas.MediaServerPlayItem(
|
||||
id=item.get("Id"),
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
percent=item.get("UserData", {}).get("PlayedPercentage")
|
||||
))
|
||||
return ret_resume
|
||||
else:
|
||||
logger.error(f"Users/Items/Resume 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items/Resume出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_latest(self, num: int = 20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
获得最近更新
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
if username:
|
||||
user = self.get_user(username)
|
||||
else:
|
||||
user = self.user
|
||||
req_url = (f"{self._host}Users/{user}/Items/Latest?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
result = res.json() or []
|
||||
ret_latest = []
|
||||
# 用户媒体库文件夹列表(排除黑名单)
|
||||
library_folders = self.get_user_library_folders()
|
||||
for item in result:
|
||||
if len(ret_latest) == num:
|
||||
break
|
||||
if item.get("Type") not in ["Movie", "Series"]:
|
||||
continue
|
||||
item_path = item.get("Path")
|
||||
if item_path and library_folders and not any(
|
||||
str(item_path).startswith(folder) for folder in library_folders):
|
||||
continue
|
||||
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
|
||||
link = self.get_play_url(item.get("Id"))
|
||||
image = self.__get_local_image_by_id(item_id=item.get("Id"))
|
||||
ret_latest.append(schemas.MediaServerPlayItem(
|
||||
id=item.get("Id"),
|
||||
title=item.get("Name"),
|
||||
subtitle=item.get("ProductionYear"),
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link
|
||||
))
|
||||
return ret_latest
|
||||
else:
|
||||
logger.error(f"Users/Items/Latest 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items/Latest出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_user_library_folders(self):
|
||||
"""
|
||||
获取Emby媒体库文件夹列表(排除黑名单)
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
library_folders = []
|
||||
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||
for library in self.get_emby_virtual_folders() or []:
|
||||
if library.get("Name") in black_list:
|
||||
continue
|
||||
library_folders += [folder for folder in library.get("Path")]
|
||||
return library_folders
|
||||
|
||||
@@ -326,6 +326,8 @@ class FanartModule(_ModuleBase):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if not settings.FANART_ENABLE:
|
||||
return None
|
||||
if not mediainfo.tmdb_id and not mediainfo.tvdb_id:
|
||||
return None
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
|
||||
@@ -45,10 +45,20 @@ class FileTransferModule(_ModuleBase):
|
||||
"""
|
||||
# 获取目标路径
|
||||
if not target:
|
||||
# 未指定目的目录,根据源目录选择一个媒体库
|
||||
target = self.get_target_path(in_path=path)
|
||||
elif not target.exists() or target.is_file():
|
||||
# 目的路径不存在或者是文件时,找对应的媒体库目录
|
||||
target = self.get_library_path(target)
|
||||
# 拼装媒体库一、二级子目录
|
||||
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target)
|
||||
else:
|
||||
# 指定了目的目录
|
||||
if target.is_file():
|
||||
logger.error(f"转移目标路径是一个文件 {target} 是一个文件")
|
||||
return TransferInfo(success=False,
|
||||
path=path,
|
||||
message=f"{target} 不是有效目录")
|
||||
# 只拼装二级子目录(不要一级目录)
|
||||
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target, typename_dir=False)
|
||||
|
||||
if not target:
|
||||
logger.error("未找到媒体库目录,无法转移文件")
|
||||
return TransferInfo(success=False,
|
||||
@@ -56,6 +66,7 @@ class FileTransferModule(_ModuleBase):
|
||||
message="未找到媒体库目录")
|
||||
else:
|
||||
logger.info(f"获取转移目标路径:{target}")
|
||||
|
||||
# 转移
|
||||
return self.transfer_media(in_path=path,
|
||||
in_meta=meta,
|
||||
@@ -333,33 +344,42 @@ class FileTransferModule(_ModuleBase):
|
||||
over_flag=over_flag)
|
||||
|
||||
@staticmethod
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path) -> Path:
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path, typename_dir: bool = True) -> Path:
|
||||
"""
|
||||
根据设置并装媒体库目录
|
||||
:param mediainfo: 媒体信息
|
||||
:target_dir: 媒体库根目录
|
||||
:typename_dir: 是否加上类型目录
|
||||
"""
|
||||
if not target_dir:
|
||||
return target_dir
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
if settings.LIBRARY_MOVIE_NAME:
|
||||
if typename_dir:
|
||||
# 目的目录加上类型和二级分类
|
||||
target_dir = target_dir / settings.LIBRARY_MOVIE_NAME / mediainfo.category
|
||||
else:
|
||||
# 目的目录加上类型和二级分类
|
||||
target_dir = target_dir / mediainfo.type.value / mediainfo.category
|
||||
# 目的目录加上二级分类
|
||||
target_dir = target_dir / mediainfo.category
|
||||
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
if settings.LIBRARY_ANIME_NAME \
|
||||
and mediainfo.genre_ids \
|
||||
if mediainfo.genre_ids \
|
||||
and set(mediainfo.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
target_dir = target_dir / settings.LIBRARY_ANIME_NAME / mediainfo.category
|
||||
elif settings.LIBRARY_TV_NAME:
|
||||
# 电视剧
|
||||
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
|
||||
if typename_dir:
|
||||
target_dir = target_dir / (settings.LIBRARY_ANIME_NAME
|
||||
or settings.LIBRARY_TV_NAME) / mediainfo.category
|
||||
else:
|
||||
target_dir = target_dir / mediainfo.category
|
||||
else:
|
||||
# 目的目录加上类型和二级分类
|
||||
target_dir = target_dir / mediainfo.type.value / mediainfo.category
|
||||
# 电视剧
|
||||
if typename_dir:
|
||||
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
|
||||
else:
|
||||
target_dir = target_dir / mediainfo.category
|
||||
|
||||
return target_dir
|
||||
|
||||
def transfer_media(self,
|
||||
@@ -389,12 +409,8 @@ class FileTransferModule(_ModuleBase):
|
||||
if transfer_type not in ['rclone_copy', 'rclone_move']:
|
||||
# 检查目标路径
|
||||
if not target_dir.exists():
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{target_dir} 目标路径不存在")
|
||||
|
||||
# 媒体库目的目录
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
logger.info(f"目标路径不存在,正在创建:{target_dir} ...")
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
@@ -547,12 +563,14 @@ class FileTransferModule(_ModuleBase):
|
||||
return {
|
||||
# 标题
|
||||
"title": mediainfo.title,
|
||||
# 原文件名
|
||||
"original_name": f"{meta.org_string}{file_ext}",
|
||||
# 原语种标题
|
||||
"original_title": mediainfo.original_title,
|
||||
# 识别名称
|
||||
# 原文件名
|
||||
"original_name": f"{meta.org_string}{file_ext}",
|
||||
# 识别名称(优先使用中文)
|
||||
"name": meta.name,
|
||||
# 识别的英文名称(可能为空)
|
||||
"en_name": meta.en_name,
|
||||
# 年份
|
||||
"year": mediainfo.year or meta.year,
|
||||
# 资源类型
|
||||
@@ -573,6 +591,8 @@ class FileTransferModule(_ModuleBase):
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
# IMDBID
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
# 豆瓣ID
|
||||
"doubanid": mediainfo.douban_id,
|
||||
# 季号
|
||||
"season": meta.season_seq,
|
||||
# 集号
|
||||
@@ -653,20 +673,19 @@ class FileTransferModule(_ModuleBase):
|
||||
continue
|
||||
if target_path:
|
||||
return target_path
|
||||
# 顺序匹配第1个满足空间存储要求的目录
|
||||
if in_path.exists():
|
||||
file_size = in_path.stat().st_size
|
||||
for path in dest_paths:
|
||||
if SystemUtils.free_space(path) > file_size:
|
||||
return path
|
||||
# 顺序匹配第1个满足空间存储要求的目录
|
||||
if in_path.exists():
|
||||
file_size = in_path.stat().st_size
|
||||
for path in dest_paths:
|
||||
if SystemUtils.free_space(path) > file_size:
|
||||
return path
|
||||
# 默认返回第1个
|
||||
return dest_paths[0]
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在于本地文件系统
|
||||
判断媒体文件是否存在于本地文件系统,只支持标准媒体库结构
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param itemid: 媒体服务器ItemID
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if not settings.LIBRARY_PATHS:
|
||||
|
||||
@@ -43,7 +43,7 @@ class IndexerModule(_ModuleBase):
|
||||
# 确认搜索的名字
|
||||
if not keywords:
|
||||
# 浏览种子页
|
||||
keywords = [None]
|
||||
keywords = ['']
|
||||
|
||||
# 开始索引
|
||||
result_array = []
|
||||
|
||||
@@ -12,9 +12,9 @@ from ruamel.yaml import CommentedMap
|
||||
from app.core.config import settings
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class TorrentSpider:
|
||||
@@ -547,6 +547,29 @@ class TorrentSpider:
|
||||
else:
|
||||
self.torrents_info['labels'] = []
|
||||
|
||||
def __get_free_date(self, torrent):
|
||||
# free date
|
||||
if 'freedate' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('freedate', {})
|
||||
freedate = torrent(selector.get('selector', '')).clone()
|
||||
self.__remove(freedate, selector)
|
||||
items = self.__attribute_or_text(freedate, selector)
|
||||
self.torrents_info['freedate'] = self.__index(items, selector)
|
||||
self.torrents_info['freedate'] = self.__filter_text(self.torrents_info.get('freedate'),
|
||||
selector.get('filters'))
|
||||
|
||||
def __get_hit_and_run(self, torrent):
|
||||
# hitandrun
|
||||
if 'hr' not in self.fields:
|
||||
return
|
||||
selector = self.fields.get('hr', {})
|
||||
hit_and_run = torrent(selector.get('selector', ''))
|
||||
if hit_and_run:
|
||||
self.torrents_info['hit_and_run'] = True
|
||||
else:
|
||||
self.torrents_info['hit_and_run'] = False
|
||||
|
||||
def get_info(self, torrent) -> dict:
|
||||
"""
|
||||
解析单条种子数据
|
||||
@@ -566,13 +589,15 @@ class TorrentSpider:
|
||||
self.__get_uploadvolumefactor(torrent)
|
||||
self.__get_pubdate(torrent)
|
||||
self.__get_date_elapsed(torrent)
|
||||
self.__get_free_date(torrent)
|
||||
self.__get_labels(torrent)
|
||||
self.__get_hit_and_run(torrent)
|
||||
except Exception as err:
|
||||
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
|
||||
return self.torrents_info
|
||||
|
||||
@staticmethod
|
||||
def __filter_text(text, filters):
|
||||
def __filter_text(text: str, filters: list):
|
||||
"""
|
||||
对文件进行处理
|
||||
"""
|
||||
@@ -613,7 +638,7 @@ class TorrentSpider:
|
||||
item.remove(v)
|
||||
|
||||
@staticmethod
|
||||
def __attribute_or_text(item, selector):
|
||||
def __attribute_or_text(item, selector: dict):
|
||||
if not selector:
|
||||
return item
|
||||
if not item:
|
||||
@@ -625,7 +650,7 @@ class TorrentSpider:
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def __index(items, selector):
|
||||
def __index(items: list, selector: dict):
|
||||
if not items:
|
||||
return None
|
||||
if selector:
|
||||
|
||||
@@ -101,13 +101,13 @@ class JellyfinModule(_ModuleBase):
|
||||
media_statistic.user_count = self.jellyfin.get_user_count()
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
def mediaserver_librarys(self, server: str = None, username: str = None) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
if server and server != "jellyfin":
|
||||
return None
|
||||
return self.jellyfin.get_librarys()
|
||||
return self.jellyfin.get_librarys(username)
|
||||
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
@@ -139,3 +139,29 @@ class JellyfinModule(_ModuleBase):
|
||||
season=season,
|
||||
episodes=episodes
|
||||
) for season, episodes in seasoninfo.items()]
|
||||
|
||||
def mediaserver_playing(self, count: int = 20,
|
||||
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
if server and server != "jellyfin":
|
||||
return []
|
||||
return self.jellyfin.get_resume(num=count, username=username)
|
||||
|
||||
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
获取媒体库播放地址
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
return self.jellyfin.get_play_url(item_id)
|
||||
|
||||
def mediaserver_latest(self, count: int = 20,
|
||||
server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
if server and server != "jellyfin":
|
||||
return []
|
||||
return self.jellyfin.get_latest(num=count, username=username)
|
||||
|
||||
@@ -20,6 +20,12 @@ class Jellyfin(metaclass=Singleton):
|
||||
self._host += "/"
|
||||
if not self._host.startswith("http"):
|
||||
self._host = "http://" + self._host
|
||||
self._playhost = settings.JELLYFIN_PLAY_HOST
|
||||
if self._playhost:
|
||||
if not self._playhost.endswith("/"):
|
||||
self._playhost += "/"
|
||||
if not self._playhost.startswith("http"):
|
||||
self._playhost = "http://" + self._playhost
|
||||
self._apikey = settings.JELLYFIN_API_KEY
|
||||
self.user = self.get_user(settings.SUPERUSER)
|
||||
self.serverid = self.get_server_id()
|
||||
@@ -39,13 +45,70 @@ class Jellyfin(metaclass=Singleton):
|
||||
self.user = self.get_user()
|
||||
self.serverid = self.get_server_id()
|
||||
|
||||
def __get_jellyfin_librarys(self) -> List[dict]:
|
||||
def get_jellyfin_folders(self) -> List[dict]:
|
||||
"""
|
||||
获取Jellyfin媒体库路径列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json()
|
||||
else:
|
||||
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_jellyfin_virtual_folders(self) -> List[dict]:
|
||||
"""
|
||||
获取Jellyfin媒体库所有路径列表(包含共享路径)
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%sLibrary/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
library_items = res.json().get("Items")
|
||||
librarys = []
|
||||
for library_item in library_items:
|
||||
library_name = library_item.get('Name')
|
||||
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
|
||||
library_paths = []
|
||||
for path in pathInfos:
|
||||
if path.get('NetworkPath'):
|
||||
library_paths.append(path.get('NetworkPath'))
|
||||
else:
|
||||
library_paths.append(path.get('Path'))
|
||||
|
||||
if library_name and library_paths:
|
||||
librarys.append({
|
||||
'Name': library_name,
|
||||
'Path': library_paths
|
||||
})
|
||||
return librarys
|
||||
else:
|
||||
logger.error(f"Library/VirtualFolders/Query 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/VirtualFolders/Query 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def __get_jellyfin_librarys(self, username: str = None) -> List[dict]:
|
||||
"""
|
||||
获取Jellyfin媒体库的信息
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = f"{self._host}Users/{self.user}/Views?api_key={self._apikey}"
|
||||
if username:
|
||||
user = self.get_user(username)
|
||||
else:
|
||||
user = self.user
|
||||
req_url = f"{self._host}Users/{user}/Views?api_key={self._apikey}"
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
@@ -57,14 +120,17 @@ class Jellyfin(metaclass=Singleton):
|
||||
logger.error(f"连接Users/Views 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_librarys(self):
|
||||
def get_librarys(self, username: str = None) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
libraries = []
|
||||
for library in self.__get_jellyfin_librarys() or []:
|
||||
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||
for library in self.__get_jellyfin_librarys(username) or []:
|
||||
if library.get("Name") in black_list:
|
||||
continue
|
||||
match library.get("CollectionType"):
|
||||
case "movies":
|
||||
library_type = MediaType.MOVIE.value
|
||||
@@ -72,13 +138,21 @@ class Jellyfin(metaclass=Singleton):
|
||||
library_type = MediaType.TV.value
|
||||
case _:
|
||||
continue
|
||||
image = self.__get_local_image_by_id(library.get("Id"))
|
||||
link = f"{self._playhost or self._host}web/index.html#!" \
|
||||
f"/movies.html?topParentId={library.get('Id')}" \
|
||||
if library_type == MediaType.MOVIE.value \
|
||||
else f"{self._playhost or self._host}web/index.html#!" \
|
||||
f"/tv.html?topParentId={library.get('Id')}"
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
server="jellyfin",
|
||||
id=library.get("Id"),
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type
|
||||
type=library_type,
|
||||
image=image,
|
||||
link=link
|
||||
))
|
||||
return libraries
|
||||
|
||||
@@ -332,7 +406,7 @@ class Jellyfin(metaclass=Singleton):
|
||||
if not season_episodes.get(season_index):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
return tv_info.get('Id'), season_episodes
|
||||
return item_id, season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None, None
|
||||
@@ -587,3 +661,154 @@ class Jellyfin(metaclass=Singleton):
|
||||
except Exception as e:
|
||||
logger.error(f"连接Jellyfin出错:" + str(e))
|
||||
return None
|
||||
|
||||
def get_play_url(self, item_id: str) -> str:
|
||||
"""
|
||||
拼装媒体播放链接
|
||||
:param item_id: 媒体的的ID
|
||||
"""
|
||||
return f"{self._playhost or self._host}web/index.html#!" \
|
||||
f"/details?id={item_id}&serverId={self.serverid}"
|
||||
|
||||
def __get_local_image_by_id(self, item_id: str) -> str:
|
||||
"""
|
||||
根据ItemId从媒体服务器查询有声书图片地址
|
||||
:param: item_id: 在Emby中的ID
|
||||
:param: remote 是否远程使用,TG微信等客户端调用应为True
|
||||
:param: inner 是否NT内部调用,为True是会使用NT中转
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return ""
|
||||
return "%sItems/%s/Images/Primary" % (self._host, item_id)
|
||||
|
||||
def __get_backdrop_url(self, item_id: str, image_tag: str) -> str:
|
||||
"""
|
||||
获取Backdrop图片地址
|
||||
:param: item_id: 在Emby中的ID
|
||||
:param: image_tag: 图片的tag
|
||||
:param: remote 是否远程使用,TG微信等客户端调用应为True
|
||||
:param: inner 是否NT内部调用,为True是会使用NT中转
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return ""
|
||||
if not image_tag or not item_id:
|
||||
return ""
|
||||
return f"{self._host}Items/{item_id}/" \
|
||||
f"Images/Backdrop?tag={image_tag}&fillWidth=666&api_key={self._apikey}"
|
||||
|
||||
def get_resume(self, num: int = 12, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
获得继续观看
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
if username:
|
||||
user = self.get_user(username)
|
||||
else:
|
||||
user = self.user
|
||||
req_url = (f"{self._host}Users/{user}/Items/Resume?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
result = res.json().get("Items") or []
|
||||
ret_resume = []
|
||||
# 用户媒体库文件夹列表(排除黑名单)
|
||||
library_folders = self.get_user_library_folders()
|
||||
for item in result:
|
||||
if len(ret_resume) == num:
|
||||
break
|
||||
if item.get("Type") not in ["Movie", "Episode"]:
|
||||
continue
|
||||
item_path = item.get("Path")
|
||||
if item_path and library_folders and not any(
|
||||
str(item_path).startswith(folder) for folder in library_folders):
|
||||
continue
|
||||
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
|
||||
link = self.get_play_url(item.get("Id"))
|
||||
if item.get("BackdropImageTags"):
|
||||
image = self.__get_backdrop_url(item_id=item.get("Id"),
|
||||
image_tag=item.get("BackdropImageTags")[0])
|
||||
else:
|
||||
image = self.__get_local_image_by_id(item.get("Id"))
|
||||
if item_type == MediaType.MOVIE.value:
|
||||
title = item.get("Name")
|
||||
subtitle = item.get("ProductionYear")
|
||||
else:
|
||||
title = f'{item.get("SeriesName")}'
|
||||
subtitle = f'S{item.get("ParentIndexNumber")}:{item.get("IndexNumber")} - {item.get("Name")}'
|
||||
ret_resume.append(schemas.MediaServerPlayItem(
|
||||
id=item.get("Id"),
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
percent=item.get("UserData", {}).get("PlayedPercentage")
|
||||
))
|
||||
return ret_resume
|
||||
else:
|
||||
logger.error(f"Users/Items/Resume 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items/Resume出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_latest(self, num=20, username: str = None) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
获得最近更新
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
if username:
|
||||
user = self.get_user(username)
|
||||
else:
|
||||
user = self.user
|
||||
req_url = (f"{self._host}Users/{user}/Items/Latest?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
result = res.json() or []
|
||||
ret_latest = []
|
||||
# 用户媒体库文件夹列表(排除黑名单)
|
||||
library_folders = self.get_user_library_folders()
|
||||
for item in result:
|
||||
if len(ret_latest) == num:
|
||||
break
|
||||
if item.get("Type") not in ["Movie", "Series"]:
|
||||
continue
|
||||
item_path = item.get("Path")
|
||||
if item_path and library_folders and not any(
|
||||
str(item_path).startswith(folder) for folder in library_folders):
|
||||
continue
|
||||
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
|
||||
link = self.get_play_url(item.get("Id"))
|
||||
image = self.__get_local_image_by_id(item_id=item.get("Id"))
|
||||
ret_latest.append(schemas.MediaServerPlayItem(
|
||||
id=item.get("Id"),
|
||||
title=item.get("Name"),
|
||||
subtitle=item.get("ProductionYear"),
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link
|
||||
))
|
||||
return ret_latest
|
||||
else:
|
||||
logger.error(f"Users/Items/Latest 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items/Latest出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_user_library_folders(self):
|
||||
"""
|
||||
获取Emby媒体库文件夹列表(排除黑名单)
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
library_folders = []
|
||||
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||
for library in self.get_jellyfin_virtual_folders() or []:
|
||||
if library.get("Name") in black_list:
|
||||
continue
|
||||
library_folders += [folder for folder in library.get("Path")]
|
||||
return library_folders
|
||||
|
||||
@@ -95,11 +95,11 @@ class PlexModule(_ModuleBase):
|
||||
media_statistic.user_count = 1
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
def mediaserver_librarys(self, server: str = None, **kwargs) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
媒体库列表
|
||||
"""
|
||||
if server != "plex":
|
||||
if server and server != "plex":
|
||||
return None
|
||||
return self.plex.get_librarys()
|
||||
|
||||
@@ -133,3 +133,27 @@ class PlexModule(_ModuleBase):
|
||||
season=season,
|
||||
episodes=episodes
|
||||
) for season, episodes in seasoninfo.items()]
|
||||
|
||||
def mediaserver_playing(self, count: int = 20, server: str = None, **kwargs) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
if server and server != "plex":
|
||||
return []
|
||||
return self.plex.get_resume(count)
|
||||
|
||||
def mediaserver_latest(self, count: int = 20, server: str = None, **kwargs) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
if server and server != "plex":
|
||||
return []
|
||||
return self.plex.get_latest(count)
|
||||
|
||||
def mediaserver_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
获取媒体库播放地址
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
return self.plex.get_play_url(item_id)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Tuple, Generator, Any
|
||||
from urllib.parse import quote_plus
|
||||
@@ -15,6 +16,8 @@ from app.utils.singleton import Singleton
|
||||
|
||||
class Plex(metaclass=Singleton):
|
||||
|
||||
_plex = None
|
||||
|
||||
def __init__(self):
|
||||
self._host = settings.PLEX_HOST
|
||||
if self._host:
|
||||
@@ -22,6 +25,12 @@ class Plex(metaclass=Singleton):
|
||||
self._host += "/"
|
||||
if not self._host.startswith("http"):
|
||||
self._host = "http://" + self._host
|
||||
self._playhost = settings.PLEX_PLAY_HOST
|
||||
if self._playhost:
|
||||
if not self._playhost.endswith("/"):
|
||||
self._playhost += "/"
|
||||
if not self._playhost.startswith("http"):
|
||||
self._playhost = "http://" + self._playhost
|
||||
self._token = settings.PLEX_TOKEN
|
||||
if self._host and self._token:
|
||||
try:
|
||||
@@ -50,6 +59,43 @@ class Plex(metaclass=Singleton):
|
||||
self._plex = None
|
||||
logger.error(f"Plex服务器连接失败:{str(e)}")
|
||||
|
||||
@lru_cache(maxsize=10)
|
||||
def __get_library_images(self, library_key: str, mtype: int) -> Optional[List[str]]:
|
||||
"""
|
||||
获取媒体服务器最近添加的媒体的图片列表
|
||||
param: library_key
|
||||
param: type type的含义: 1 电影 2 剧集 详见 plexapi/utils.py中SEARCHTYPES的定义
|
||||
"""
|
||||
if not self._plex:
|
||||
return None
|
||||
# 返回结果
|
||||
poster_urls = {}
|
||||
# 页码计数
|
||||
container_start = 0
|
||||
# 需要的总条数/每页的条数
|
||||
total_size = 4
|
||||
# 如果总数不足,接续获取下一页
|
||||
while len(poster_urls) < total_size:
|
||||
items = self._plex.fetchItems(f"/hubs/home/recentlyAdded?type={mtype}§ionID={library_key}",
|
||||
container_size=total_size,
|
||||
container_start=container_start)
|
||||
for item in items:
|
||||
if item.type == 'episode':
|
||||
# 如果是剧集的单集,则去找上级的图片
|
||||
if item.parentThumb is not None:
|
||||
poster_urls[item.parentThumb] = None
|
||||
else:
|
||||
# 否则就用自己的图片
|
||||
if item.thumb is not None:
|
||||
poster_urls[item.thumb] = None
|
||||
if len(poster_urls) == total_size:
|
||||
break
|
||||
if len(items) < total_size:
|
||||
break
|
||||
container_start += total_size
|
||||
return [f"{self._host.rstrip('/') + url}?X-Plex-Token={self._token}" for url in
|
||||
list(poster_urls.keys())[:total_size]]
|
||||
|
||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
@@ -62,12 +108,17 @@ class Plex(metaclass=Singleton):
|
||||
logger.error(f"获取媒体服务器所有媒体库列表出错:{str(err)}")
|
||||
return []
|
||||
libraries = []
|
||||
black_list = (settings.MEDIASERVER_SYNC_BLACKLIST or '').split(",")
|
||||
for library in self._libraries:
|
||||
if library.title in black_list:
|
||||
continue
|
||||
match library.type:
|
||||
case "movie":
|
||||
library_type = MediaType.MOVIE.value
|
||||
image_list = self.__get_library_images(library.key, 1)
|
||||
case "show":
|
||||
library_type = MediaType.TV.value
|
||||
image_list = self.__get_library_images(library.key, 2)
|
||||
case _:
|
||||
continue
|
||||
libraries.append(
|
||||
@@ -75,7 +126,10 @@ class Plex(metaclass=Singleton):
|
||||
id=library.key,
|
||||
name=library.title,
|
||||
path=library.locations,
|
||||
type=library_type
|
||||
type=library_type,
|
||||
image_list=image_list,
|
||||
link=f"{self._playhost or self._host}web/index.html#!/media/{self._plex.machineIdentifier}"
|
||||
f"/com.plexapp.plugins.library?source={library.key}"
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
@@ -543,3 +597,63 @@ class Plex(metaclass=Singleton):
|
||||
获取plex对象,以便直接操作
|
||||
"""
|
||||
return self._plex
|
||||
|
||||
def get_play_url(self, item_id: str) -> str:
|
||||
"""
|
||||
拼装媒体播放链接
|
||||
:param item_id: 媒体的的ID
|
||||
"""
|
||||
return f'{self._playhost or self._host}web/index.html#!/server/{self._plex.machineIdentifier}/details?key={item_id}'
|
||||
|
||||
def get_resume(self, num: int = 12) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
获取继续观看的媒体
|
||||
"""
|
||||
if not self._plex:
|
||||
return []
|
||||
items = self._plex.fetchItems('/hubs/continueWatching/items', container_start=0, container_size=num)
|
||||
ret_resume = []
|
||||
for item in items:
|
||||
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
|
||||
if item_type == MediaType.MOVIE.value:
|
||||
title = item.title
|
||||
subtitle = item.year
|
||||
else:
|
||||
title = item.grandparentTitle
|
||||
subtitle = f"S{item.parentIndex}:E{item.index} - {item.title}"
|
||||
link = self.get_play_url(item.key)
|
||||
image = item.artUrl
|
||||
ret_resume.append(schemas.MediaServerPlayItem(
|
||||
id=item.key,
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link,
|
||||
percent=item.viewOffset / item.duration * 100 if item.viewOffset and item.duration else 0
|
||||
))
|
||||
return ret_resume[:num]
|
||||
|
||||
def get_latest(self, num: int = 20) -> Optional[List[schemas.MediaServerPlayItem]]:
|
||||
"""
|
||||
获取最近添加媒体
|
||||
"""
|
||||
if not self._plex:
|
||||
return None
|
||||
items = self._plex.fetchItems('/library/recentlyAdded', container_start=0, container_size=num)
|
||||
ret_resume = []
|
||||
for item in items:
|
||||
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
|
||||
link = self.get_play_url(item.key)
|
||||
title = item.title if item_type == MediaType.MOVIE.value else \
|
||||
"%s 第%s季" % (item.parentTitle, item.index)
|
||||
image = item.posterUrl
|
||||
ret_resume.append(schemas.MediaServerPlayItem(
|
||||
id=item.key,
|
||||
title=title,
|
||||
subtitle=item.year,
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link
|
||||
))
|
||||
return ret_resume[:num]
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from typing import Set, Tuple, Optional, Union, List
|
||||
|
||||
from qbittorrentapi import TorrentFilesList
|
||||
from torrentool.torrent import Torrent
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
@@ -47,6 +48,21 @@ class QbittorrentModule(_ModuleBase):
|
||||
:param category: 分类
|
||||
:return: 种子Hash,错误信息
|
||||
"""
|
||||
|
||||
def __get_torrent_info() -> Tuple[str, int]:
|
||||
"""
|
||||
获取种子名称
|
||||
"""
|
||||
try:
|
||||
if isinstance(content, Path):
|
||||
torrentinfo = Torrent.from_file(content)
|
||||
else:
|
||||
torrentinfo = Torrent.from_string(content)
|
||||
return torrentinfo.name, torrentinfo.total_size
|
||||
except Exception as e:
|
||||
logger.error(f"获取种子名称失败:{e}")
|
||||
return "", 0
|
||||
|
||||
if not content:
|
||||
return
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
@@ -70,6 +86,28 @@ class QbittorrentModule(_ModuleBase):
|
||||
category=category
|
||||
)
|
||||
if not state:
|
||||
# 读取种子的名称
|
||||
torrent_name, torrent_size = __get_torrent_info()
|
||||
if not torrent_name:
|
||||
return None, f"添加种子任务失败:无法读取种子文件"
|
||||
# 查询所有下载器的种子
|
||||
torrents, error = self.qbittorrent.get_torrents()
|
||||
if error:
|
||||
return None, "无法连接qbittorrent下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
if torrent.get("name") == torrent_name and torrent.get("total_size") == torrent_size:
|
||||
torrent_hash = torrent.get("hash")
|
||||
torrent_tags = [str(tag).strip() for tag in torrent.get("tags").split(',')]
|
||||
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.get('name')}")
|
||||
# 给种子打上标签
|
||||
if "已整理" in torrent_tags:
|
||||
self.qbittorrent.remove_torrents_tag(ids=torrent_hash, tag=['已整理'])
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in torrent_tags:
|
||||
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
|
||||
self.qbittorrent.set_torrents_tag(ids=torrent_hash, tags=[settings.TORRENT_TAG])
|
||||
return torrent_hash, f"下载任务已存在"
|
||||
return None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
# 获取种子Hash
|
||||
|
||||
@@ -16,14 +16,20 @@ class Qbittorrent(metaclass=Singleton):
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_username: str = None
|
||||
_passowrd: str = None
|
||||
_password: str = None
|
||||
|
||||
qbc: Client = None
|
||||
|
||||
def __init__(self):
|
||||
self._host, self._port = StringUtils.get_domain_address(address=settings.QB_HOST, prefix=True)
|
||||
self._username = settings.QB_USER
|
||||
self._password = settings.QB_PASSWORD
|
||||
def __init__(self, host: str = None, port: int = None, username: str = None, password: str = None):
|
||||
"""
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
if host and port:
|
||||
self._host, self._port = host, port
|
||||
else:
|
||||
self._host, self._port = StringUtils.get_domain_address(address=settings.QB_HOST, prefix=True)
|
||||
self._username = username if username else settings.QB_USER
|
||||
self._password = password if password else settings.QB_PASSWORD
|
||||
if self._host and self._port:
|
||||
self.qbc = self.__login_qbittorrent()
|
||||
|
||||
@@ -352,26 +358,28 @@ class Qbittorrent(metaclass=Singleton):
|
||||
logger.error(f"设置速度限制出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def recheck_torrents(self, ids: Union[str, list]):
|
||||
def recheck_torrents(self, ids: Union[str, list]) -> bool:
|
||||
"""
|
||||
重新校验种子
|
||||
"""
|
||||
if not self.qbc:
|
||||
return False
|
||||
try:
|
||||
return self.qbc.torrents_recheck(torrent_hashes=ids)
|
||||
self.qbc.torrents_recheck(torrent_hashes=ids)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"重新校验种子出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def add_trackers(self, ids: Union[str, list], trackers: list):
|
||||
def update_tracker(self, hash_string: str, tracker_list: list) -> bool:
|
||||
"""
|
||||
添加tracker
|
||||
"""
|
||||
if not self.qbc:
|
||||
return False
|
||||
try:
|
||||
return self.qbc.torrents_add_trackers(torrent_hashes=ids, urls=trackers)
|
||||
self.qbc.torrents_add_trackers(torrent_hash=hash_string, urls=tracker_list)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"添加tracker出错:{str(err)}")
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -174,6 +175,7 @@ class Telegram(metaclass=Singleton):
|
||||
logger.error(f"发送消息失败:{msg_e}")
|
||||
return False
|
||||
|
||||
@retry(Exception, logger=logger)
|
||||
def __send_request(self, userid: str = None, image="", caption="") -> bool:
|
||||
"""
|
||||
向Telegram发送报文
|
||||
@@ -181,7 +183,9 @@ class Telegram(metaclass=Singleton):
|
||||
|
||||
if image:
|
||||
req = RequestUtils(proxies=settings.PROXY).get_res(image)
|
||||
if req and req.content:
|
||||
if req is None:
|
||||
raise Exception("获取图片失败")
|
||||
if req.content:
|
||||
image_file = Path(settings.TEMP_PATH) / Path(image).name
|
||||
image_file.write_bytes(req.content)
|
||||
photo = InputFile(image_file)
|
||||
@@ -189,12 +193,23 @@ class Telegram(metaclass=Singleton):
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
parse_mode="Markdown")
|
||||
if ret is None:
|
||||
raise Exception("发送图片消息失败")
|
||||
if ret:
|
||||
return True
|
||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
||||
text=caption,
|
||||
parse_mode="Markdown")
|
||||
|
||||
# 按4096分段循环发送消息
|
||||
ret = None
|
||||
if len(caption) > 4095:
|
||||
for i in range(0, len(caption), 4095):
|
||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
||||
text=caption[i:i + 4095],
|
||||
parse_mode="Markdown")
|
||||
else:
|
||||
ret = self._bot.send_message(chat_id=userid or self._telegram_chat_id,
|
||||
text=caption,
|
||||
parse_mode="Markdown")
|
||||
if ret is None:
|
||||
raise Exception("发送文本消息失败")
|
||||
return True if ret else False
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]):
|
||||
|
||||
@@ -212,12 +212,15 @@ class TheMovieDbModule(_ModuleBase):
|
||||
|
||||
return [MediaInfo(tmdb_info=info) for info in results]
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移类型
|
||||
:param force_nfo: 强制刮削nfo
|
||||
:param force_img: 强制刮削图片
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "themoviedb":
|
||||
@@ -229,13 +232,17 @@ class TheMovieDbModule(_ModuleBase):
|
||||
scrape_path = path / path.name
|
||||
self.scraper.gen_scraper_files(mediainfo=mediainfo,
|
||||
file_path=scrape_path,
|
||||
transfer_type=transfer_type)
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
elif path.is_file():
|
||||
# 单个文件
|
||||
logger.info(f"开始刮削媒体库文件:{path} ...")
|
||||
self.scraper.gen_scraper_files(mediainfo=mediainfo,
|
||||
file_path=path,
|
||||
transfer_type=transfer_type)
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
else:
|
||||
# 目录下的所有文件
|
||||
logger.info(f"开始刮削目录:{path} ...")
|
||||
@@ -244,7 +251,9 @@ class TheMovieDbModule(_ModuleBase):
|
||||
continue
|
||||
self.scraper.gen_scraper_files(mediainfo=mediainfo,
|
||||
file_path=file,
|
||||
transfer_type=transfer_type)
|
||||
transfer_type=transfer_type,
|
||||
force_nfo=force_nfo,
|
||||
force_img=force_img)
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str,
|
||||
@@ -312,6 +321,8 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
if not mediainfo.tmdb_id:
|
||||
return mediainfo
|
||||
if mediainfo.logo_path \
|
||||
@@ -382,35 +393,35 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/{image_prefix}{image_path}"
|
||||
return None
|
||||
|
||||
def movie_similar(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_movie_similar(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询类似电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_movie_similar(tmdbid=tmdbid)
|
||||
|
||||
def tv_similar(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_tv_similar(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_tv_similar(tmdbid=tmdbid)
|
||||
|
||||
def movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_movie_recommend(tmdbid=tmdbid)
|
||||
|
||||
def tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_tv_recommend(tmdbid=tmdbid)
|
||||
|
||||
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
def tmdb_movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表
|
||||
:param tmdbid: TMDBID
|
||||
@@ -418,7 +429,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
return self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)
|
||||
|
||||
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
def tmdb_tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职员表
|
||||
:param tmdbid: TMDBID
|
||||
@@ -426,14 +437,14 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
return self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)
|
||||
|
||||
def person_detail(self, person_id: int) -> dict:
|
||||
def tmdb_person_detail(self, person_id: int) -> dict:
|
||||
"""
|
||||
根据TMDBID查询人物详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.tmdb.get_person_detail(person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||
def tmdb_person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||
"""
|
||||
根据TMDBID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
@@ -445,5 +456,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
清除缓存
|
||||
"""
|
||||
logger.info("开始清除TMDB缓存 ...")
|
||||
self.tmdb.clear_cache()
|
||||
self.cache.clear()
|
||||
logger.info("TMDB缓存清除完成")
|
||||
|
||||
@@ -19,19 +19,26 @@ from app.utils.system import SystemUtils
|
||||
class TmdbScraper:
|
||||
tmdb = None
|
||||
_transfer_type = settings.TRANSFER_TYPE
|
||||
_force_nfo = False
|
||||
_force_img = False
|
||||
|
||||
def __init__(self, tmdb):
|
||||
self.tmdb = tmdb
|
||||
|
||||
def gen_scraper_files(self, mediainfo: MediaInfo, file_path: Path, transfer_type: str):
|
||||
def gen_scraper_files(self, mediainfo: MediaInfo, file_path: Path, transfer_type: str,
|
||||
force_nfo: bool = False, force_img: bool = False):
|
||||
"""
|
||||
生成刮削文件,包括NFO和图片,传入路径为文件路径
|
||||
:param mediainfo: 媒体信息
|
||||
:param file_path: 文件路径或者目录路径
|
||||
:param transfer_type: 传输类型
|
||||
:param force_nfo: 是否强制生成NFO
|
||||
:param force_img: 是否强制生成图片
|
||||
"""
|
||||
|
||||
self._transfer_type = transfer_type
|
||||
self._force_nfo = force_nfo
|
||||
self._force_img = force_img
|
||||
|
||||
def __get_episode_detail(_seasoninfo: dict, _episode: int):
|
||||
"""
|
||||
@@ -46,8 +53,8 @@ class TmdbScraper:
|
||||
# 电影,路径为文件名 名称/名称.xxx 或者蓝光原盘目录 名称/名称
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 不已存在时才处理
|
||||
if not file_path.with_name("movie.nfo").exists() \
|
||||
and not file_path.with_suffix(".nfo").exists():
|
||||
if self._force_nfo or (not file_path.with_name("movie.nfo").exists()
|
||||
and not file_path.with_suffix(".nfo").exists()):
|
||||
# 生成电影描述文件
|
||||
self.__gen_movie_nfo_file(mediainfo=mediainfo,
|
||||
file_path=file_path)
|
||||
@@ -59,33 +66,37 @@ class TmdbScraper:
|
||||
and isinstance(attr_value, str) \
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
self.__save_image(url=attr_value,
|
||||
file_path=file_path.with_name(image_name))
|
||||
image_path = file_path.with_name(image_name)
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=attr_value,
|
||||
file_path=image_path)
|
||||
# 电视剧,路径为每一季的文件名 名称/Season xx/名称 SxxExx.xxx
|
||||
else:
|
||||
# 识别
|
||||
meta = MetaInfo(file_path.stem)
|
||||
# 根目录不存在时才处理
|
||||
if not file_path.parent.with_name("tvshow.nfo").exists():
|
||||
if self._force_nfo or not file_path.parent.with_name("tvshow.nfo").exists():
|
||||
# 根目录描述文件
|
||||
self.__gen_tv_nfo_file(mediainfo=mediainfo,
|
||||
dir_path=file_path.parents[1])
|
||||
# 生成根目录图片
|
||||
for attr_name, attr_value in vars(mediainfo).items():
|
||||
if attr_value \
|
||||
if attr_name \
|
||||
and attr_name.endswith("_path") \
|
||||
and not attr_name.startswith("season") \
|
||||
and attr_value \
|
||||
and isinstance(attr_value, str) \
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
self.__save_image(url=attr_value,
|
||||
file_path=file_path.parent.with_name(image_name))
|
||||
image_path = file_path.parent.with_name(image_name)
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=attr_value,
|
||||
file_path=image_path)
|
||||
# 查询季信息
|
||||
seasoninfo = self.tmdb.get_tv_season_detail(mediainfo.tmdb_id, meta.begin_season)
|
||||
if seasoninfo:
|
||||
# 季目录NFO
|
||||
if not file_path.with_name("season.nfo").exists():
|
||||
if self._force_nfo or not file_path.with_name("season.nfo").exists():
|
||||
self.__gen_tv_season_nfo_file(seasoninfo=seasoninfo,
|
||||
season=meta.begin_season,
|
||||
season_path=file_path.parent)
|
||||
@@ -96,7 +107,9 @@ class TmdbScraper:
|
||||
ext = Path(seasoninfo.get('poster_path')).suffix
|
||||
# URL
|
||||
url = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{seasoninfo.get('poster_path')}"
|
||||
self.__save_image(url, file_path.parent.with_name(f"season{sea_seq}-poster{ext}"))
|
||||
image_path = file_path.parent.with_name(f"season{sea_seq}-poster{ext}")
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=url, file_path=image_path)
|
||||
# 季的其它图片
|
||||
for attr_name, attr_value in vars(mediainfo).items():
|
||||
if attr_value \
|
||||
@@ -106,13 +119,15 @@ class TmdbScraper:
|
||||
and isinstance(attr_value, str) \
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
self.__save_image(url=attr_value,
|
||||
file_path=file_path.parent.with_name(image_name))
|
||||
image_path = file_path.parent.with_name(image_name)
|
||||
if self._force_img or not image_path.exists():
|
||||
self.__save_image(url=attr_value,
|
||||
file_path=image_path)
|
||||
# 查询集详情
|
||||
episodeinfo = __get_episode_detail(seasoninfo, meta.begin_episode)
|
||||
if episodeinfo:
|
||||
# 集NFO
|
||||
if not file_path.with_suffix(".nfo").exists():
|
||||
if self._force_nfo or not file_path.with_suffix(".nfo").exists():
|
||||
self.__gen_tv_episode_nfo_file(episodeinfo=episodeinfo,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=meta.begin_season,
|
||||
@@ -120,10 +135,12 @@ class TmdbScraper:
|
||||
file_path=file_path)
|
||||
# 集的图片
|
||||
episode_image = episodeinfo.get("still_path")
|
||||
if episode_image:
|
||||
image_path = file_path.with_name(file_path.stem + "-thumb.jpg").with_suffix(
|
||||
Path(episode_image).suffix)
|
||||
if episode_image and (self._force_img or not image_path.exists()):
|
||||
self.__save_image(
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode_image}",
|
||||
file_path.with_suffix(Path(episode_image).suffix))
|
||||
image_path)
|
||||
except Exception as e:
|
||||
logger.error(f"{file_path} 刮削失败:{str(e)}")
|
||||
|
||||
@@ -339,8 +356,6 @@ class TmdbScraper:
|
||||
"""
|
||||
下载图片并保存
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
try:
|
||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||
r = RequestUtils().get_res(url=url, raise_exception=True)
|
||||
@@ -361,8 +376,6 @@ class TmdbScraper:
|
||||
"""
|
||||
保存NFO
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, xml_str)
|
||||
|
||||
@@ -2,6 +2,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Set, Tuple, Optional, Union, List
|
||||
|
||||
from torrentool.torrent import Torrent
|
||||
from transmission_rpc import File
|
||||
|
||||
from app import schemas
|
||||
@@ -47,6 +48,21 @@ class TransmissionModule(_ModuleBase):
|
||||
:param category: 分类,TR中未使用
|
||||
:return: 种子Hash
|
||||
"""
|
||||
|
||||
def __get_torrent_info() -> Tuple[str, int]:
|
||||
"""
|
||||
获取种子名称
|
||||
"""
|
||||
try:
|
||||
if isinstance(content, Path):
|
||||
torrentinfo = Torrent.from_file(content)
|
||||
else:
|
||||
torrentinfo = Torrent.from_string(content)
|
||||
return torrentinfo.name, torrentinfo.total_size
|
||||
except Exception as e:
|
||||
logger.error(f"获取种子名称失败:{e}")
|
||||
return "", 0
|
||||
|
||||
if not content:
|
||||
return
|
||||
if isinstance(content, Path) and not content.exists():
|
||||
@@ -68,6 +84,33 @@ class TransmissionModule(_ModuleBase):
|
||||
cookie=cookie
|
||||
)
|
||||
if not torrent:
|
||||
# 读取种子的名称
|
||||
torrent_name, torrent_size = __get_torrent_info()
|
||||
if not torrent_name:
|
||||
return None, f"添加种子任务失败:无法读取种子文件"
|
||||
# 查询所有下载器的种子
|
||||
torrents, error = self.transmission.get_torrents()
|
||||
if error:
|
||||
return None, "无法连接transmission下载器"
|
||||
if torrents:
|
||||
for torrent in torrents:
|
||||
# 名称与大小相等则认为是同一个种子
|
||||
if torrent.name == torrent_name and torrent.total_size == torrent_size:
|
||||
torrent_hash = torrent.hashString
|
||||
logger.warn(f"下载器中已存在该种子任务:{torrent_hash} - {torrent.name}")
|
||||
# 给种子打上标签
|
||||
if settings.TORRENT_TAG:
|
||||
logger.info(f"给种子 {torrent_hash} 打上标签:{settings.TORRENT_TAG}")
|
||||
# 种子标签
|
||||
labels = [str(tag).strip()
|
||||
for tag in torrent.labels] if hasattr(torrent, "labels") else []
|
||||
if "已整理" in labels:
|
||||
labels.remove("已整理")
|
||||
self.transmission.set_torrent_tag(ids=torrent_hash, tags=labels)
|
||||
if settings.TORRENT_TAG and settings.TORRENT_TAG not in labels:
|
||||
labels.append(settings.TORRENT_TAG)
|
||||
self.transmission.set_torrent_tag(ids=torrent_hash, tags=labels)
|
||||
return torrent_hash, f"下载任务已存在"
|
||||
return None, f"添加种子任务失败:{content}"
|
||||
else:
|
||||
torrent_hash = torrent.hashString
|
||||
|
||||
@@ -14,7 +14,7 @@ class Transmission(metaclass=Singleton):
|
||||
_host: str = None
|
||||
_port: int = None
|
||||
_username: str = None
|
||||
_passowrd: str = None
|
||||
_password: str = None
|
||||
|
||||
trc: Optional[Client] = None
|
||||
|
||||
@@ -24,10 +24,16 @@ class Transmission(metaclass=Singleton):
|
||||
"peersGettingFromUs", "peersSendingToUs", "uploadRatio", "uploadedEver", "downloadedEver", "downloadDir",
|
||||
"error", "errorString", "doneDate", "queuePosition", "activityDate", "trackers"]
|
||||
|
||||
def __init__(self):
|
||||
self._host, self._port = StringUtils.get_domain_address(address=settings.TR_HOST, prefix=False)
|
||||
self._username = settings.TR_USER
|
||||
self._password = settings.TR_PASSWORD
|
||||
def __init__(self, host: str = None, port: int = None, username: str = None, password: str = None):
|
||||
"""
|
||||
若不设置参数,则创建配置文件设置的下载器
|
||||
"""
|
||||
if host and port:
|
||||
self._host, self._port = host, port
|
||||
else:
|
||||
self._host, self._port = StringUtils.get_domain_address(address=settings.TR_HOST, prefix=False)
|
||||
self._username = username if username else settings.TR_USER
|
||||
self._password = password if password else settings.TR_PASSWORD
|
||||
if self._host and self._port:
|
||||
self.trc = self.__login_transmission()
|
||||
|
||||
@@ -271,36 +277,25 @@ class Transmission(metaclass=Singleton):
|
||||
logger.error(f"设置速度限制出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def recheck_torrents(self, ids: Union[str, list]):
|
||||
def recheck_torrents(self, ids: Union[str, list]) -> bool:
|
||||
"""
|
||||
重新校验种子
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
try:
|
||||
return self.trc.verify_torrent(ids=ids)
|
||||
self.trc.verify_torrent(ids=ids)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"重新校验种子出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def add_trackers(self, ids: Union[str, list], trackers: list):
|
||||
"""
|
||||
添加Tracker
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
try:
|
||||
return self.trc.change_torrent(ids=ids, tracker_list=[trackers])
|
||||
except Exception as err:
|
||||
logger.error(f"添加Tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def change_torrent(self,
|
||||
hash_string: str,
|
||||
upload_limit=None,
|
||||
download_limit=None,
|
||||
ratio_limit=None,
|
||||
seeding_time_limit=None):
|
||||
seeding_time_limit=None) -> bool:
|
||||
"""
|
||||
设置种子
|
||||
:param hash_string: ID
|
||||
@@ -346,6 +341,22 @@ class Transmission(metaclass=Singleton):
|
||||
seedRatioLimit=seedRatioLimit,
|
||||
seedIdleMode=seedIdleMode,
|
||||
seedIdleLimit=seedIdleLimit)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"设置种子出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def update_tracker(self, hash_string: str, tracker_list: list = None) -> bool:
|
||||
"""
|
||||
tr4.0及以上弃用直接设置tracker 共用change方法
|
||||
https://github.com/trim21/transmission-rpc/blob/8eb82629492a0eeb0bb565f82c872bf9ccdcb313/transmission_rpc/client.py#L654
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
try:
|
||||
self.trc.change_torrent(ids=hash_string,
|
||||
tracker_list=tracker_list)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
@@ -57,7 +57,7 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
@abstractmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件命令
|
||||
注册插件远程命令
|
||||
[{
|
||||
"cmd": "/xx",
|
||||
"event": EventType.xx,
|
||||
@@ -71,7 +71,7 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
@abstractmethod
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件API
|
||||
注册插件API
|
||||
[{
|
||||
"path": "/xx",
|
||||
"endpoint": self.xxx,
|
||||
@@ -82,6 +82,19 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
|
||||
@@ -13,8 +13,10 @@ from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.timer import TimerUtils
|
||||
@@ -43,6 +45,14 @@ class Scheduler(metaclass=Singleton):
|
||||
_event = threading.Event()
|
||||
|
||||
def __init__(self):
|
||||
|
||||
def clear_cache():
|
||||
"""
|
||||
清理缓存
|
||||
"""
|
||||
TorrentsChain().clear_cache()
|
||||
SchedulerChain().clear_cache()
|
||||
|
||||
# 各服务的运行状态
|
||||
self._jobs = {
|
||||
"cookiecloud": {
|
||||
@@ -73,7 +83,7 @@ class Scheduler(metaclass=Singleton):
|
||||
"running": False,
|
||||
},
|
||||
"clear_cache": {
|
||||
"func": SchedulerChain().clear_cache,
|
||||
"func": clear_cache,
|
||||
"running": False,
|
||||
}
|
||||
}
|
||||
@@ -218,6 +228,27 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
|
||||
# 注册插件公共服务
|
||||
plugin_services = PluginManager().get_plugin_services()
|
||||
for service in plugin_services:
|
||||
try:
|
||||
self._jobs[service["id"]] = {
|
||||
"func": service["func"],
|
||||
"running": False,
|
||||
}
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
service["trigger"],
|
||||
id=service["id"],
|
||||
name=service["name"],
|
||||
**service["kwargs"],
|
||||
kwargs={
|
||||
'job_id': service["id"]
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件服务失败:{str(e)} - {service}")
|
||||
|
||||
# 打印服务
|
||||
logger.debug(self._scheduler.print_jobs())
|
||||
|
||||
|
||||
@@ -196,6 +196,8 @@ class TorrentInfo(BaseModel):
|
||||
pubdate: Optional[str] = None
|
||||
# 已过时间
|
||||
date_elapsed: Optional[str] = None
|
||||
# 免费截止时间
|
||||
freedate: Optional[str] = None
|
||||
# 上传因子
|
||||
uploadvolumefactor: Optional[float] = None
|
||||
# 下载因子
|
||||
@@ -208,6 +210,8 @@ class TorrentInfo(BaseModel):
|
||||
pri_order: Optional[int] = 0
|
||||
# 促销
|
||||
volume_factor: Optional[str] = None
|
||||
# 剩余免费时间
|
||||
freedate_diff: Optional[str] = None
|
||||
|
||||
|
||||
class Context(BaseModel):
|
||||
|
||||
@@ -66,6 +66,10 @@ class MediaServerLibrary(BaseModel):
|
||||
type: Optional[str] = None
|
||||
# 封面图
|
||||
image: Optional[str] = None
|
||||
# 封面图列表
|
||||
image_list: Optional[List[str]] = None
|
||||
# 跳转链接
|
||||
link: Optional[str] = None
|
||||
|
||||
|
||||
class MediaServerItem(BaseModel):
|
||||
@@ -139,3 +143,16 @@ class WebhookEventInfo(BaseModel):
|
||||
save_reason: Optional[str] = None
|
||||
item_isvirtual: Optional[bool] = None
|
||||
media_type: Optional[str] = None
|
||||
|
||||
|
||||
class MediaServerPlayItem(BaseModel):
|
||||
"""
|
||||
媒体服务器可播放项目信息
|
||||
"""
|
||||
id: Optional[Union[str, int]] = None
|
||||
title: Optional[str] = None
|
||||
subtitle: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
link: Optional[str] = None
|
||||
percent: Optional[float] = None
|
||||
|
||||
@@ -14,8 +14,6 @@ class Plugin(BaseModel):
|
||||
plugin_desc: Optional[str] = None
|
||||
# 插件图标
|
||||
plugin_icon: Optional[str] = None
|
||||
# 主题色
|
||||
plugin_color: Optional[str] = None
|
||||
# 插件版本
|
||||
plugin_version: Optional[str] = None
|
||||
# 插件作者
|
||||
|
||||
@@ -57,6 +57,8 @@ class Subscribe(BaseModel):
|
||||
best_version: Optional[int] = 0
|
||||
# 当前优先级
|
||||
current_priority: Optional[int] = None
|
||||
# 保存路径
|
||||
save_path: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
@@ -49,3 +49,14 @@ class TmdbPerson(BaseModel):
|
||||
popularity: Optional[float] = None
|
||||
images: Optional[dict] = {}
|
||||
biography: Optional[str] = None
|
||||
|
||||
|
||||
class DoubanPerson(BaseModel):
|
||||
id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
roles: Optional[list] = []
|
||||
title: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
character: Optional[str] = None
|
||||
avatar: Optional[dict] = None
|
||||
latin_name: Optional[str] = None
|
||||
|
||||
@@ -14,3 +14,7 @@ class Token(BaseModel):
|
||||
class TokenPayload(BaseModel):
|
||||
# 用户ID
|
||||
sub: Optional[int] = None
|
||||
# 用户名
|
||||
username: Optional[str] = None
|
||||
# 超级用户
|
||||
super_user: Optional[bool] = None
|
||||
|
||||
@@ -16,16 +16,12 @@ class TorrentStatus(Enum):
|
||||
class EventType(Enum):
|
||||
# 插件重载
|
||||
PluginReload = "plugin.reload"
|
||||
# 插件动作
|
||||
PluginAction = "plugin.action"
|
||||
# 执行命令
|
||||
CommandExcute = "command.excute"
|
||||
# 站点签到
|
||||
SiteSignin = "site.signin"
|
||||
# 站点数据统计
|
||||
SiteStatistic = "site.statistic"
|
||||
# 站点删除
|
||||
SiteDeleted = "site.deleted"
|
||||
# 豆瓣想看
|
||||
DoubanSync = "douban.sync"
|
||||
# Webhook消息
|
||||
WebhookMessage = "webhook.message"
|
||||
# 转移完成
|
||||
@@ -44,14 +40,6 @@ class EventType(Enum):
|
||||
NameRecognize = "name.recognize"
|
||||
# 名称识别结果
|
||||
NameRecognizeResult = "name.recognize.result"
|
||||
# 目录监控同步
|
||||
DirectorySync = "directory.sync"
|
||||
# Cloudflare IP优选
|
||||
CloudFlareSpeedTest = "cloudflare.speedtest"
|
||||
# 站点自动登录更新Cookie、Ua
|
||||
SiteLogin = "site.login"
|
||||
# 网盘删除
|
||||
NetworkDiskDel = "networkdisk.del"
|
||||
|
||||
|
||||
# 系统配置Key字典
|
||||
|
||||
@@ -9,9 +9,10 @@ class Singleton(abc.ABCMeta, type):
|
||||
_instances: dict = {}
|
||||
|
||||
def __call__(cls, *args, **kwargs):
|
||||
if cls not in cls._instances:
|
||||
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
|
||||
return cls._instances[cls]
|
||||
key = (cls, args, frozenset(kwargs.items()))
|
||||
if key not in cls._instances:
|
||||
cls._instances[key] = super().__call__(*args, **kwargs)
|
||||
return cls._instances[key]
|
||||
|
||||
|
||||
class AbstractSingleton(abc.ABC, metaclass=Singleton):
|
||||
|
||||
@@ -711,3 +711,29 @@ class StringUtils:
|
||||
return -1
|
||||
else:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def diff_time_str(time_str: str):
|
||||
"""
|
||||
输入YYYY-MM-DD HH24:MI:SS 格式的时间字符串,返回距离现在的剩余时间:xx天xx小时xx分钟
|
||||
"""
|
||||
if not time_str:
|
||||
return ''
|
||||
try:
|
||||
time_obj = datetime.datetime.strptime(time_str, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
return ''
|
||||
now = datetime.datetime.now()
|
||||
diff = time_obj - now
|
||||
diff_seconds = diff.seconds
|
||||
diff_days = diff.days
|
||||
diff_hours = diff_seconds // 3600
|
||||
diff_minutes = (diff_seconds % 3600) // 60
|
||||
if diff_days > 0:
|
||||
return f'{diff_days}天{diff_hours}小时{diff_minutes}分钟'
|
||||
elif diff_hours > 0:
|
||||
return f'{diff_hours}小时{diff_minutes}分钟'
|
||||
elif diff_minutes > 0:
|
||||
return f'{diff_minutes}分钟'
|
||||
else:
|
||||
return ''
|
||||
|
||||
228
config/app.env
228
config/app.env
@@ -3,18 +3,110 @@
|
||||
#######################################################################
|
||||
|
||||
####################################
|
||||
# 基础设置 #
|
||||
# 系统设置 #
|
||||
####################################
|
||||
# 【*】API监听地址
|
||||
# 【*】API监听地址(注意不是前端访问地址)
|
||||
HOST=0.0.0.0
|
||||
# 是否调试模式,打开后将输出更多日志
|
||||
DEBUG=false
|
||||
# 是否开发模式,打开后后台服务将不会启动
|
||||
DEV=false
|
||||
# 【*】超级管理员,设置后一但重启将固化到数据库中,修改将无效
|
||||
# 【*】超级管理员,设置后一但重启将固化到数据库中,修改将无效(初始化超级管理员密码仅会生成一次,请在日志中查看并自行登录系统修改)
|
||||
SUPERUSER=admin
|
||||
# 【*】超级管理员初始密码,设置后一但重启将固化到数据库中,修改将无效
|
||||
SUPERUSER_PASSWORD=password
|
||||
# 大内存模式,开启后会增加缓存数量,但会占用更多内存
|
||||
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
|
||||
@@ -25,8 +117,23 @@ TMDB_IMAGE_DOMAIN=image.tmdb.org
|
||||
TMDB_API_DOMAIN=api.themoviedb.org
|
||||
# 媒体识别来源 themoviedb/douban,使用themoviedb时需要确保能正常连接api.themoviedb.org,使用douban时不支持二级分类
|
||||
RECOGNIZE_SOURCE=themoviedb
|
||||
# 大内存模式,开启后会增加缓存数量,但会占用更多内存
|
||||
BIG_MEMORY_MODE=false
|
||||
# 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=
|
||||
|
||||
####################################
|
||||
# 媒体识别&刮削 #
|
||||
@@ -39,22 +146,24 @@ SCRAP_FOLLOW_TMDB=true
|
||||
SCRAP_SOURCE=themoviedb
|
||||
|
||||
####################################
|
||||
# 媒体库 #
|
||||
# 文件整理 & 媒体库 #
|
||||
####################################
|
||||
# 【*】转移方式 link/copy/move/softlink/rclone_copy/rclone_move
|
||||
TRANSFER_TYPE=copy
|
||||
# 转移覆盖模式,`nerver`/`size`/`always`/`latest`,分别表示`不覆盖同名文件`/`同名文件根据文件大小覆盖(大覆盖小)`/`总是覆盖同名文件`/`仅保留最新版本,删除旧版本文件(包括非同名文件)`
|
||||
OVERWRITE_MODE=size
|
||||
|
||||
# 【*】媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH=
|
||||
LIBRARY_PATH=/media
|
||||
# 电影媒体库目录名,默认电影
|
||||
LIBRARY_MOVIE_NAME=
|
||||
LIBRARY_MOVIE_NAME=电影
|
||||
# 电视剧媒体库目录名,默认电视剧
|
||||
LIBRARY_TV_NAME=
|
||||
LIBRARY_TV_NAME=电视剧
|
||||
# 动漫媒体库目录名,默认电视剧/动漫
|
||||
LIBRARY_ANIME_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/
|
||||
@@ -83,74 +192,12 @@ SUBSCRIBE_MODE=spider
|
||||
SUBSCRIBE_RSS_INTERVAL=30
|
||||
# 订阅搜索开关,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集
|
||||
SUBSCRIBE_SEARCH=false
|
||||
# 交互搜索自动下载用户ID(消息通知渠道的用户ID),使用,分割,未设置需要用户手动选择资源或者回复`0`
|
||||
# 交互搜索自动下载用户ID(消息通知渠道的用户ID),使用,分割,设置为 all 代表所有用户自动择优下载,未设置需要用户手动选择资源或者回复`0`才自动择优下载
|
||||
AUTO_DOWNLOAD_USER=
|
||||
|
||||
####################################
|
||||
# 消息通知 #
|
||||
####################################
|
||||
# 【*】消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
MESSAGER=telegram
|
||||
# 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/transmission,仅支持单个下载器,做为主下载器使用,非主下载器只要配置了参数仍可在插件等使用
|
||||
DOWNLOADER=qbittorrent
|
||||
# 下载器监控开关
|
||||
DOWNLOADER_MONITOR=true
|
||||
# Qbittorrent地址,IP:PORT
|
||||
QB_HOST=
|
||||
# Qbittorrent用户名
|
||||
QB_USER=
|
||||
# Qbittorrent密码
|
||||
QB_PASSWORD=
|
||||
# Qbittorrent分类自动管理
|
||||
QB_CATEGORY=false
|
||||
# Qbittorrent按顺序下载
|
||||
QB_SEQUENTIAL=true
|
||||
# Qbittorrent忽略队列限制,强制继续
|
||||
QB_FORCE_RESUME=false
|
||||
# Transmission地址,IP:PORT
|
||||
TR_HOST=
|
||||
# Transmission用户名
|
||||
TR_USER=
|
||||
# Transmission密码
|
||||
TR_PASSWORD=
|
||||
# 种子标签
|
||||
TORRENT_TAG=MOVIEPILOT
|
||||
# 【*】下载保存目录,容器内映射路径需要一致,支持不同类型设置不同的下载目录(跨盘)
|
||||
DOWNLOAD_PATH=/downloads
|
||||
# 电影下载保存目录(路径),容器内映射路径需要一致
|
||||
@@ -159,38 +206,19 @@ 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
|
||||
|
||||
####################################
|
||||
# 媒体服务器 #
|
||||
####################################
|
||||
# 【*】媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
MEDIASERVER=emby
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL=6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST=
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST=
|
||||
# EMBY Api Key
|
||||
EMBY_API_KEY=
|
||||
# Jellyfin服务器地址,IP:PORT
|
||||
JELLYFIN_HOST=
|
||||
# Jellyfin Api Key
|
||||
JELLYFIN_API_KEY=
|
||||
# Plex服务器地址,IP:PORT
|
||||
PLEX_HOST=
|
||||
# Plex Token
|
||||
PLEX_TOKEN=
|
||||
|
||||
####################################
|
||||
# 第三方服务 #
|
||||
# 扩展 #
|
||||
####################################
|
||||
# OCR服务器地址
|
||||
OCR_HOST=https://movie-pilot.org
|
||||
# 插件市场仓库地址,多个地址使用`,`分隔,保留最后的/
|
||||
PLUGIN_MARKET=https://raw.githubusercontent.com/jxxghp/MoviePilot-Plugins/main/
|
||||
PLUGIN_MARKET=https://github.com/jxxghp/MoviePilot-Plugins
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
from app.db.models import Base
|
||||
from app.db import Base
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
30
database/versions/d71e624f0208_1_0_12.py
Normal file
30
database/versions/d71e624f0208_1_0_12.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""1_0_12
|
||||
|
||||
Revision ID: d71e624f0208
|
||||
Revises: 06abf3e7090b
|
||||
Create Date: 2023-12-12 13:26:34.039497
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd71e624f0208'
|
||||
down_revision = '06abf3e7090b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
try:
|
||||
with op.batch_alter_table("subscribe") as batch_op:
|
||||
batch_op.add_column(sa.Column('save_path', sa.String, nullable=True))
|
||||
except Exception as e:
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -8,17 +8,22 @@ def collect_pkg_data(package: str, include_py_files: bool = False, subdir: str =
|
||||
from PyInstaller.utils.hooks import get_package_paths, PY_IGNORE_EXTENSIONS
|
||||
from PyInstaller.building.datastruct import TOC
|
||||
|
||||
data_toc = TOC()
|
||||
|
||||
# Accept only strings as packages.
|
||||
if type(package) is not str:
|
||||
raise ValueError
|
||||
|
||||
pkg_base, pkg_dir = get_package_paths(package)
|
||||
try:
|
||||
pkg_base, pkg_dir = get_package_paths(package)
|
||||
except ValueError:
|
||||
return data_toc
|
||||
if subdir:
|
||||
pkg_path = Path(pkg_dir) / subdir
|
||||
else:
|
||||
pkg_path = Path(pkg_dir)
|
||||
# Walk through all file in the given package, looking for data files.
|
||||
data_toc = TOC()
|
||||
if not pkg_path.exists():
|
||||
return data_toc
|
||||
for file in pkg_path.rglob('*'):
|
||||
if file.is_file():
|
||||
extension = file.suffix
|
||||
@@ -37,6 +42,8 @@ def collect_local_submodules(package: str):
|
||||
package_dir = Path(package.replace('.', os.sep))
|
||||
submodules = [package]
|
||||
# Walk through all file in the given package, looking for data files.
|
||||
if not package_dir.exists():
|
||||
return []
|
||||
for file in package_dir.rglob('*.py'):
|
||||
if file.name == '__init__.py':
|
||||
module = f"{file.parent}".replace(os.sep, '.')
|
||||
2
update
2
update
@@ -84,7 +84,7 @@ if [[ "${MOVIEPILOT_AUTO_UPDATE}" = "true" ]] || [[ "${MOVIEPILOT_AUTO_UPDATE}"
|
||||
echo "不使用代理更新程序"
|
||||
fi
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
CURL_HEADERS="--header 'Authorization: Bearer ${GITHUB_TOKEN}'"
|
||||
CURL_HEADERS="--oauth2-bearer ${GITHUB_TOKEN}"
|
||||
else
|
||||
CURL_HEADERS=""
|
||||
fi
|
||||
|
||||
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.4.2-1'
|
||||
APP_VERSION = 'v1.6.1'
|
||||
|
||||
Reference in New Issue
Block a user