mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-10 17:42:45 +08:00
Compare commits
240 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7defcff0e5 | ||
|
|
d9e767f87d | ||
|
|
2b82173fba | ||
|
|
1425b15333 | ||
|
|
8d82d0f4fd | ||
|
|
d352f09d4e | ||
|
|
aebd121939 | ||
|
|
81eed0d06d | ||
|
|
bacb7aaeb4 | ||
|
|
b238c6ad11 | ||
|
|
5c8b843030 | ||
|
|
58acc62e16 | ||
|
|
ca5a240fc4 | ||
|
|
dd5887d18d | ||
|
|
97669405d0 | ||
|
|
bf2ea271b6 | ||
|
|
afd91bf760 | ||
|
|
7e982eaf4d | ||
|
|
5f13824aa6 | ||
|
|
9ca8e3f4a8 | ||
|
|
9b749035c9 | ||
|
|
b8e09a6b06 | ||
|
|
4bb95d519d | ||
|
|
04280021b4 | ||
|
|
355dad9205 | ||
|
|
a6714d3712 | ||
|
|
fe53819a81 | ||
|
|
6965415c52 | ||
|
|
9be671fa2c | ||
|
|
27b4f206a1 | ||
|
|
a2b0c9bd3a | ||
|
|
ebc46d7d3b | ||
|
|
eb4e4b5141 | ||
|
|
be11ef72a9 | ||
|
|
a278c80951 | ||
|
|
6ee6de48ff | ||
|
|
671bdad77c | ||
|
|
a9ff8ec96d | ||
|
|
d1678355f1 | ||
|
|
ea399daef9 | ||
|
|
e1122af97c | ||
|
|
21861111e6 | ||
|
|
bd1e83ee8a | ||
|
|
43da33bc50 | ||
|
|
a09a207407 | ||
|
|
0aa3aa8521 | ||
|
|
d9c6375252 | ||
|
|
f1f187fc77 | ||
|
|
99d22554a1 | ||
|
|
4835f6c6c9 | ||
|
|
5be2bf0633 | ||
|
|
7c7bc0b504 | ||
|
|
e0939fee75 | ||
|
|
82226f1956 | ||
|
|
cfb43b4b04 | ||
|
|
ebe2795eae | ||
|
|
f7f747278d | ||
|
|
58f17e89b6 | ||
|
|
433ca2ec28 | ||
|
|
ffac57ad4d | ||
|
|
0d2a4c50d6 | ||
|
|
02c2edc30e | ||
|
|
65975235d4 | ||
|
|
07a6abde0e | ||
|
|
fa47d9adeb | ||
|
|
18d08c3672 | ||
|
|
cf20049b7f | ||
|
|
e3ce3302da | ||
|
|
d20951e7a0 | ||
|
|
8a565bb79f | ||
|
|
cfdc8fb2c3 | ||
|
|
111f830664 | ||
|
|
2821d6a9dc | ||
|
|
495d98c2b2 | ||
|
|
e1e2779e48 | ||
|
|
363318f4f0 | ||
|
|
521b960364 | ||
|
|
d2bcb197eb | ||
|
|
b0f9ca52e3 | ||
|
|
01a3efd402 | ||
|
|
a50427948a | ||
|
|
5614f10962 | ||
|
|
5ff80dbe89 | ||
|
|
278835c5d4 | ||
|
|
92cdd67f3a | ||
|
|
c56b58cc56 | ||
|
|
8bd4c21511 | ||
|
|
b94f201667 | ||
|
|
125e9eb30a | ||
|
|
ea09d8c8d4 | ||
|
|
de0237f348 | ||
|
|
62143bf7b6 | ||
|
|
3088bbb2f8 | ||
|
|
43647e59a4 | ||
|
|
a740330e66 | ||
|
|
10d4766353 | ||
|
|
0a845fe8b6 | ||
|
|
90dc52bb70 | ||
|
|
3816e2fba8 | ||
|
|
20d92ca577 | ||
|
|
db92761964 | ||
|
|
3af5870733 | ||
|
|
53d01267b8 | ||
|
|
5ff21641f9 | ||
|
|
643f2e3e66 | ||
|
|
ae839235eb | ||
|
|
5af94144ce | ||
|
|
4281692321 | ||
|
|
bd6d6b6882 | ||
|
|
fabb02a8a0 | ||
|
|
223e655b6f | ||
|
|
f0cb5b3e85 | ||
|
|
7579aae823 | ||
|
|
36a8b6d780 | ||
|
|
0471167b74 | ||
|
|
28b996e54b | ||
|
|
d0989f72a9 | ||
|
|
663e61e3a1 | ||
|
|
71b0090947 | ||
|
|
8ff0f81f47 | ||
|
|
4186613a86 | ||
|
|
4e1be23317 | ||
|
|
0e9e626ab6 | ||
|
|
3d5761157a | ||
|
|
c407800b30 | ||
|
|
c888a37aba | ||
|
|
d43998efee | ||
|
|
8813e84053 | ||
|
|
cf5a746f53 | ||
|
|
f9f58fc559 | ||
|
|
f59b5b6d27 | ||
|
|
30b3ad4a99 | ||
|
|
dfb9ce7520 | ||
|
|
6c365f552e | ||
|
|
a81ee7d89a | ||
|
|
5c9039e6d0 | ||
|
|
cce2e13e21 | ||
|
|
0da87abc71 | ||
|
|
6a2eecc744 | ||
|
|
c049e13c1c | ||
|
|
7ec49ce076 | ||
|
|
5be2fc35b5 | ||
|
|
0b84312559 | ||
|
|
8bb43b52bc | ||
|
|
bd348f118c | ||
|
|
4a3a3483d0 | ||
|
|
fd6314f19f | ||
|
|
17a9f3a626 | ||
|
|
75c898e6eb | ||
|
|
089d4785aa | ||
|
|
4d48295f72 | ||
|
|
ed119b7beb | ||
|
|
90d5a8b0c9 | ||
|
|
dd5c0de7b1 | ||
|
|
73bdca282c | ||
|
|
360a54581f | ||
|
|
1fc7587cbb | ||
|
|
dcd46f1627 | ||
|
|
d8644a20c0 | ||
|
|
23b47f98c1 | ||
|
|
347c91fa0b | ||
|
|
ac961b37b4 | ||
|
|
068c49a79a | ||
|
|
e7174b402c | ||
|
|
d21267090a | ||
|
|
51dc2c33a0 | ||
|
|
8aef488ab6 | ||
|
|
0cbf45f9b9 | ||
|
|
c0ae32d654 | ||
|
|
ff1b0e02d6 | ||
|
|
76a8b02fe5 | ||
|
|
43f594393c | ||
|
|
008e11d63f | ||
|
|
9dd610f245 | ||
|
|
c5d087aad6 | ||
|
|
576c5741f9 | ||
|
|
51387c31c4 | ||
|
|
c2a40876e2 | ||
|
|
c06bdf0491 | ||
|
|
f726130c31 | ||
|
|
4033ffeb15 | ||
|
|
f81af8e9fb | ||
|
|
e3f9260299 | ||
|
|
c80ccaf74b | ||
|
|
0e60c976be | ||
|
|
805c7d2701 | ||
|
|
4499f001dd | ||
|
|
71c6a3718b | ||
|
|
6404f9d45c | ||
|
|
ce357540eb | ||
|
|
e56cfd6ad4 | ||
|
|
25e5f7a9f6 | ||
|
|
6d69ac42e5 | ||
|
|
6a71bed821 | ||
|
|
1718758d1c | ||
|
|
7a37078e90 | ||
|
|
26b5ad6a44 | ||
|
|
fa884c9608 | ||
|
|
6927b5fbd3 | ||
|
|
59fca63d4a | ||
|
|
7489d6a912 | ||
|
|
b437fd6021 | ||
|
|
c303ab0765 | ||
|
|
9daff87f2f | ||
|
|
f20b1bcfe9 | ||
|
|
2f71e401be | ||
|
|
0840e0bcbc | ||
|
|
933af7485c | ||
|
|
baddaabd73 | ||
|
|
8028866cee | ||
|
|
242894cec2 | ||
|
|
967ad3a507 | ||
|
|
2dbe049a91 | ||
|
|
c5afc65cbd | ||
|
|
e35bacecd5 | ||
|
|
d84c86b0f6 | ||
|
|
73ae09b041 | ||
|
|
a11318390d | ||
|
|
1714990e2e | ||
|
|
44cd5f52e0 | ||
|
|
59b9dc354e | ||
|
|
591969015f | ||
|
|
6118e235c3 | ||
|
|
228b1a11d0 | ||
|
|
c8a1e59310 | ||
|
|
b0f7a11328 | ||
|
|
b753e50580 | ||
|
|
3002bf4dd2 | ||
|
|
0cbe8f5cdc | ||
|
|
1a03d19469 | ||
|
|
b7c1106744 | ||
|
|
d6c6c999fc | ||
|
|
408703d4a3 | ||
|
|
40a612c327 | ||
|
|
e519fc484b | ||
|
|
e430a3e88b | ||
|
|
316f61bf69 | ||
|
|
750c4441db | ||
|
|
ae60040120 | ||
|
|
666d6eb048 |
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@@ -80,11 +80,13 @@ jobs:
|
||||
|
||||
- name: Prepare Frontend
|
||||
run: |
|
||||
# 下载nginx
|
||||
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
|
||||
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
|
||||
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
|
||||
Remove-Item -Path "nginx.zip"
|
||||
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
|
||||
# 下载前端
|
||||
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
|
||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
|
||||
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
|
||||
@@ -96,11 +98,31 @@ jobs:
|
||||
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
|
||||
New-Item -Path "nginx/logs" -ItemType Directory -Force
|
||||
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
|
||||
# 下载插件 jxxghp
|
||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force
|
||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
||||
# 下载插件 thsrite
|
||||
Invoke-WebRequest -Uri "https://github.com/thsrite/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
||||
# 下载插件 honue
|
||||
Invoke-WebRequest -Uri "https://github.com/honue/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
||||
# 下载插件 InfinityPacer
|
||||
Invoke-WebRequest -Uri "https://github.com/InfinityPacer/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
|
||||
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path "MoviePilot-Plugins-main.zip"
|
||||
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
|
||||
# 下载资源
|
||||
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
|
||||
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
|
||||
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
|
||||
@@ -137,6 +159,7 @@ jobs:
|
||||
python -m pip install --upgrade pip
|
||||
pip install wheel pyinstaller
|
||||
pip install -r requirements.txt
|
||||
find app/plugins -name requirements.txt -exec pip install -r {} \;
|
||||
|
||||
- name: Prepare Frontend
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,4 +15,5 @@ config/user.db
|
||||
config/sites/**
|
||||
*.pyc
|
||||
*.log
|
||||
.vscode
|
||||
.vscode
|
||||
venv
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.11.4-slim-bullseye
|
||||
FROM python:3.11.4-slim-bookworm
|
||||
ARG MOVIEPILOT_VERSION
|
||||
ENV LANG="C.UTF-8" \
|
||||
TZ="Asia/Shanghai" \
|
||||
@@ -16,6 +16,7 @@ ENV LANG="C.UTF-8" \
|
||||
IYUU_SIGN=""
|
||||
WORKDIR "/app"
|
||||
RUN apt-get update -y \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
|
||||
38
README.md
38
README.md
@@ -48,7 +48,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- Windows
|
||||
|
||||
1. 独立执行文件版本:下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录,访问:http://localhost:3000
|
||||
2. 安装包版本:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
|
||||
2. 安装包版本【推荐】:[Windows-MoviePilot](https://github.com/developer-wlj/Windows-MoviePilot)
|
||||
|
||||
- 群晖套件
|
||||
|
||||
@@ -82,7 +82,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- **❗AUTH_SITE:** 认证站点(认证通过后才能使用站点相关功能),支持配置多个认证站点,使用`,`分隔,如:`iyuu,hhclub`,会依次执行认证操作,直到有一个站点认证成功。
|
||||
|
||||
配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
认证资源`v1.2.4+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`
|
||||
认证资源`v1.2.8+`支持:`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`ptba` /`icc2022`/`ptlsp`/`xingtan`/`ptvicomo`/`agsvpt`/`hdkyl`/`qingwa`/`discfan`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
@@ -103,6 +103,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
| agsvpt | `AGSVPT_UID`:用户ID<br/>`AGSVPT_PASSKEY`:密钥 |
|
||||
| hdkyl | `HDKYL_UID`:用户ID<br/>`HDKYL_PASSKEY`:密钥 |
|
||||
| qingwa | `QINGWA_UID`:用户ID<br/>`QINGWA_PASSKEY`:密钥 |
|
||||
| discfan | `DISCFAN_UID`:用户ID<br/>`DISCFAN_PASSKEY`:密钥 |
|
||||
|
||||
|
||||
### 2. **环境变量 / 配置文件**
|
||||
@@ -115,25 +116,25 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- **DOH_ENABLE:** DNS over HTTPS开关,`true`/`false`,默认`true`,开启后会使用DOH对api.themoviedb.org等域名进行解析,以减少被DNS污染的情况,提升网络连通性
|
||||
- **META_CACHE_EXPIRE:** 元数据识别缓存过期时间(小时),数字型,不配置或者配置为0时使用系统默认(大内存模式为7天,否则为3天),调大该值可减少themoviedb的访问次数
|
||||
- **GITHUB_TOKEN:** Github token,提高自动更新、插件安装等请求Github Api的限流阈值,格式:ghp_****
|
||||
- **DEV:** 开发者模式,`true`/`false`,默认`false`,开启后会暂停所有定时任务
|
||||
- **DEV:** 开发者模式,`true`/`false`,默认`false`,仅用于本地开发使用,开启后会暂停所有定时任务,且插件代码文件的修改无需重启会自动重载生效
|
||||
- **AUTO_UPDATE_RESOURCE**:启动时自动检测和更新资源包(站点索引及认证等),`true`/`false`,默认`true`,需要能正常连接Github,仅支持Docker镜像
|
||||
---
|
||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`、`tmdb.movie-pilot.org` 或其它中转代理服务地址,能连通即可
|
||||
- **TMDB_IMAGE_DOMAIN:** TMDB图片地址,默认`image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`static-mdb.v.geilijiasu.com`
|
||||
- **WALLPAPER:** 登录首页电影海报,`tmdb`/`bing`,默认`tmdb`
|
||||
- **RECOGNIZE_SOURCE:** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类
|
||||
- **RECOGNIZE_SOURCE:** 媒体信息识别来源,`themoviedb`/`douban`,默认`themoviedb`,使用`douban`时不支持二级分类,且受豆瓣控流限制
|
||||
- **FANART_ENABLE:** Fanart开关,`true`/`false`,默认`true`,关闭后刮削的图片类型会大幅减少
|
||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`,为`false`时即使TMDB信息变化了也会仍然按历史记录中已入库的信息进行刮削
|
||||
---
|
||||
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID(消息通知渠道的用户ID),多个用户使用,分割,设置为 all 代表全部用户自动择优下载,未设置需要手动选择资源或者回复`0`才自动择优下载
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
- **SEARCH_MULTIPLE_NAME:** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
|
||||
- **SUBSCRIBE_STATISTIC_SHARE:** 是否匿名分享订阅数据,用于统计和展示用户热门订阅,`true`/`false`,默认`true`
|
||||
- **PLUGIN_STATISTIC_SHARE:** 是否匿名分享插件安装统计数据,用于统计和显示插件下载安装次数,`true`/`false`,默认`true`
|
||||
---
|
||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||
---
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
---
|
||||
- **SEARCH_MULTIPLE_NAME:** 搜索时是否使用多个名称搜索,`true`/`false`,默认`false`,开启后会使用多个名称进行搜索,搜索结果会更全面,但会增加搜索时间;关闭时只要其中一个名称搜索到结果或全部名称搜索完毕即停止
|
||||
---
|
||||
- **MOVIE_RENAME_FORMAT:** 电影重命名格式,基于jinjia2语法
|
||||
|
||||
`MOVIE_RENAME_FORMAT`支持的配置项:
|
||||
@@ -190,8 +191,12 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
|
||||
### 4. **插件扩展**
|
||||
|
||||
- **PLUGIN_MARKET:** 插件市场仓库地址,仅支持Github仓库`main`分支,多个地址使用`,`分隔,默认为官方插件仓库:`https://github.com/jxxghp/MoviePilot-Plugins` ,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork,或者查看频道置顶了解更多第三方插件仓库。
|
||||
|
||||
- **PLUGIN_MARKET:** 插件市场仓库地址,仅支持Github仓库`main`分支,多个地址使用`,`分隔,通过查看[MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)项目的fork,或者查看频道置顶了解更多第三方插件仓库,目前已有 `130+` 插件。
|
||||
默认已内置以下插件库:
|
||||
1. https://github.com/jxxghp/MoviePilot-Plugins
|
||||
2. https://github.com/thsrite/MoviePilot-Plugins
|
||||
3. https://github.com/honue/MoviePilot-Plugins
|
||||
4. https://github.com/InfinityPacer/MoviePilot-Plugins
|
||||
|
||||
## 使用
|
||||
|
||||
@@ -203,6 +208,7 @@ MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- 需要通过环境变量设置用户认证信息且认证成功后才能使用站点相关功能,未认证通过时站点相关的插件也会无法显示。
|
||||
### 3. **文件整理**
|
||||
- 默认通过监控下载器实现下载完成后自动整理入库并刮削媒体信息,需要后台打开`下载器监控`开关,且仅会处理通过MoviePilot添加下载的任务。
|
||||
- 下载器监控默认轮循间隔为5分钟,如果是使用qbittorrent,可在 `QB设置`->`下载完成时运行外部程序` 处填入:`curl "http://localhost:3000/api/v1/transfer/now?token=moviepilot" `,实现无需等待轮循下载完成后立即整理入库(地址、端口和token按实际调整,curl也可更换为wget)。
|
||||
- 使用`目录监控`等插件实现更灵活的自动整理。
|
||||
### 4. **通知交互**
|
||||
- 支持通过`微信`/`Telegram`/`Slack`/`SynologyChat`/`VoceChat`等渠道远程管理和订阅下载,其中 微信/Telegram 将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示)。
|
||||
@@ -258,9 +264,15 @@ sudo sysctl -p
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
@@ -17,14 +17,13 @@ def calendar(page: int = 1,
|
||||
"""
|
||||
浏览Bangumi每日放送
|
||||
"""
|
||||
infos = BangumiChain().calendar(page=page, count=count)
|
||||
if not infos:
|
||||
return []
|
||||
medias = [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return [media.to_dict() for media in medias]
|
||||
medias = BangumiChain().calendar()
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.BangumiPerson])
|
||||
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
|
||||
def bangumi_credits(bangumiid: int,
|
||||
page: int = 1,
|
||||
count: int = 20,
|
||||
@@ -32,23 +31,46 @@ def bangumi_credits(bangumiid: int,
|
||||
"""
|
||||
查询Bangumi演职员表
|
||||
"""
|
||||
persons = BangumiChain().bangumi_credits(bangumiid, page=page, count=count)
|
||||
if not persons:
|
||||
return []
|
||||
return [schemas.BangumiPerson(**person) for person in persons]
|
||||
persons = BangumiChain().bangumi_credits(bangumiid)
|
||||
if persons:
|
||||
return persons[(page - 1) * count: page * count]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_recommend(bangumiid: int,
|
||||
page: int = 1,
|
||||
count: int = 20,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询Bangumi推荐
|
||||
"""
|
||||
infos = BangumiChain().bangumi_recommend(bangumiid)
|
||||
if not infos:
|
||||
return []
|
||||
medias = [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return [media.to_dict() for media in medias]
|
||||
medias = BangumiChain().bangumi_recommend(bangumiid)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def bangumi_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return BangumiChain().person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def bangumi_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = BangumiChain().person_credits(person_id=person_id)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * 20: page * 20]]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -5,10 +6,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.dashboard import DashboardChain
|
||||
from app.core.config import settings
|
||||
from app.core.security import verify_token, verify_uri_token
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
@@ -47,7 +48,8 @@ def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询存储空间信息
|
||||
"""
|
||||
total_storage, free_storage = SystemUtils.space_usage(settings.LIBRARY_PATHS)
|
||||
library_dirs = DirectoryHelper().get_library_dirs()
|
||||
total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path])
|
||||
return schemas.Storage(
|
||||
total_storage=total_storage,
|
||||
used_storage=total_storage - free_storage
|
||||
@@ -75,6 +77,10 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询下载器信息
|
||||
"""
|
||||
# 下载目录空间
|
||||
download_dirs = DirectoryHelper().get_download_dirs()
|
||||
_, free_space = SystemUtils.space_usage([Path(d.path) for d in download_dirs if d.path])
|
||||
# 下载器信息
|
||||
downloader_info = schemas.DownloaderInfo()
|
||||
transfer_infos = DashboardChain().downloader_info()
|
||||
if transfer_infos:
|
||||
@@ -83,7 +89,7 @@ def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
downloader_info.upload_speed += transfer_info.upload_speed
|
||||
downloader_info.download_size += transfer_info.download_size
|
||||
downloader_info.upload_size += transfer_info.upload_size
|
||||
downloader_info.free_space = SystemUtils.free_space(settings.SAVE_PATH)
|
||||
downloader_info.free_space = free_space
|
||||
return downloader_info
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,28 @@ def douban_img(imgurl: str) -> Any:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def douban_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
return DoubanChain().person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def douban_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
medias = DoubanChain().person_credits(person_id=person_id, page=page)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
def movie_showing(page: int = 1,
|
||||
count: int = 30,
|
||||
@@ -36,10 +58,9 @@ def movie_showing(page: int = 1,
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
return [media.to_dict() for media in medias]
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -53,13 +74,9 @@ def douban_movies(sort: str = "R",
|
||||
"""
|
||||
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
return [media.to_dict() for media in medias
|
||||
if media.poster_path
|
||||
and "movie_large.jpg" not in media.poster_path
|
||||
and "tv_normal.png" not in media.poster_path]
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -73,14 +90,9 @@ def douban_tvs(sort: str = "R",
|
||||
"""
|
||||
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
if not tvs:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=tv) for tv in tvs]
|
||||
return [media.to_dict() for media in medias
|
||||
if media.poster_path
|
||||
and "movie_large.jpg" not in media.poster_path
|
||||
and "tv_normal.jpg" not in media.poster_path
|
||||
and "tv_large.jpg" not in media.poster_path]
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
@@ -90,8 +102,10 @@ def movie_top250(page: int = 1,
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
movies = DoubanChain().movie_top250(page=page, count=count) or []
|
||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
@@ -101,8 +115,10 @@ def tv_weekly_chinese(page: int = 1,
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count) or []
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
@@ -112,8 +128,10 @@ def tv_weekly_global(page: int = 1,
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count) or []
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -123,8 +141,10 @@ def tv_animation(page: int = 1,
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count) or []
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -134,8 +154,10 @@ def movie_hot(page: int = 1,
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
movies = DoubanChain().movie_hot(page=page, count=count) or []
|
||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||
if movies:
|
||||
return [media.to_dict() for media in movies]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
|
||||
@@ -145,29 +167,26 @@ def tv_hot(page: int = 1,
|
||||
"""
|
||||
热门电视剧
|
||||
"""
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count) or []
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
if tvs:
|
||||
return [media.to_dict() for media in tvs]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.DoubanPerson])
|
||||
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def douban_credits(doubanid: str,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
根据豆瓣ID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
doubaninfos = DoubanChain().movie_credits(doubanid=doubanid, page=page)
|
||||
return DoubanChain().movie_credits(doubanid=doubanid)
|
||||
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]
|
||||
return DoubanChain().tv_credits(doubanid=doubanid)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
@@ -179,15 +198,14 @@ def douban_recommend(doubanid: str,
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
doubaninfos = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||
medias = DoubanChain().movie_recommend(doubanid=doubanid)
|
||||
elif mediatype == MediaType.TV:
|
||||
doubaninfos = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||
medias = DoubanChain().tv_recommend(doubanid=doubanid)
|
||||
else:
|
||||
return []
|
||||
if not doubaninfos:
|
||||
return []
|
||||
else:
|
||||
return [MediaInfo(douban_info=doubaninfo).to_dict() for doubaninfo in doubaninfos]
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
|
||||
@@ -23,13 +23,13 @@ def read(
|
||||
return DownloadChain().downloading()
|
||||
|
||||
|
||||
@router.post("/", summary="添加下载", response_model=schemas.Response)
|
||||
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
|
||||
def download(
|
||||
media_in: schemas.MediaInfo,
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
添加下载任务
|
||||
添加下载任务(含媒体信息)
|
||||
"""
|
||||
# 元数据
|
||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
@@ -51,12 +51,12 @@ def download(
|
||||
})
|
||||
|
||||
|
||||
@router.post("/add", summary="添加下载", response_model=schemas.Response)
|
||||
@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response)
|
||||
def add(
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
current_user: User = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
添加下载任
|
||||
添加下载任务(不含媒体信息)
|
||||
"""
|
||||
# 元数据
|
||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
|
||||
@@ -9,8 +9,10 @@ from app.chain.transfer import TransferChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models import User
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.userauth import get_current_active_superuser
|
||||
from app.schemas.types import EventType
|
||||
|
||||
router = APIRouter()
|
||||
@@ -103,3 +105,13 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
# 删除记录
|
||||
TransferHistory.delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/empty/transfer", summary="清空转移历史记录", response_model=schemas.Response)
|
||||
def delete_transfer_history(db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
清空转移历史记录
|
||||
"""
|
||||
TransferHistory.truncate(db)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Any
|
||||
from typing import List, Any, Union
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -63,18 +63,38 @@ def recognize_file2(path: str,
|
||||
return recognize_file(path)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体信息", response_model=List[schemas.MediaInfo])
|
||||
def search_by_title(title: str,
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||
def search(title: str,
|
||||
type: str = "media",
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
模糊搜索媒体信息列表
|
||||
模糊搜索媒体/人物信息列表 media:媒体信息,person:人物信息
|
||||
"""
|
||||
_, medias = MediaChain().search(title=title)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
def __get_source(obj: Union[dict, schemas.MediaPerson]):
|
||||
"""
|
||||
获取对象属性
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("source")
|
||||
return obj.source
|
||||
|
||||
result = []
|
||||
if type == "media":
|
||||
_, medias = MediaChain().search(title=title)
|
||||
if medias:
|
||||
result = [media.to_dict() for media in medias]
|
||||
else:
|
||||
result = MediaChain().search_persons(name=title)
|
||||
if result:
|
||||
# 按设置的顺序对结果进行排序
|
||||
setting_order = settings.SEARCH_SOURCE.split(',') or []
|
||||
sort_order = {}
|
||||
for index, source in enumerate(setting_order):
|
||||
sort_order[source] = index
|
||||
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
|
||||
return result[(page - 1) * count:page * count]
|
||||
|
||||
|
||||
@router.get("/scrape", summary="刮削媒体信息", response_model=schemas.Response)
|
||||
@@ -99,6 +119,14 @@ def scrape(path: str,
|
||||
return schemas.Response(success=True, message="刮削完成")
|
||||
|
||||
|
||||
@router.get("/category", summary="查询自动分类配置", response_model=dict)
|
||||
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询自动分类配置
|
||||
"""
|
||||
return MediaChain().media_category() or {}
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def media_info(mediaid: str, type_name: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Dict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends
|
||||
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.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
@@ -37,14 +37,14 @@ def play_item(itemid: str) -> schemas.Response:
|
||||
})
|
||||
|
||||
|
||||
@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:
|
||||
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
|
||||
def exists_local(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:
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
@@ -61,35 +61,35 @@ def exists(title: str = None,
|
||||
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])
|
||||
@router.post("/exists_remote", summary="查询已存在的剧集信息(媒体服务器)", response_model=Dict[int, list])
|
||||
def exists(media_in: schemas.MediaInfo,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据媒体信息查询媒体库已存在的剧集信息
|
||||
"""
|
||||
# 转化为媒体信息对象
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media_in.dict())
|
||||
existsinfo: schemas.ExistMediaInfo = MediaServerChain().media_exists(mediainfo=mediainfo)
|
||||
if not existsinfo:
|
||||
return []
|
||||
if media_in.season:
|
||||
return {
|
||||
media_in.season: existsinfo.seasons.get(media_in.season) or []
|
||||
}
|
||||
return existsinfo.seasons
|
||||
|
||||
|
||||
@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)
|
||||
@@ -101,18 +101,13 @@ def not_exists(media_in: schemas.MediaInfo,
|
||||
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
|
||||
# 转化为媒体信息对象
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media_in.dict())
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=meta, mediainfo=mediainfo)
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影已存在时返回空列表,存在时返回空对像列表
|
||||
# 电影已存在时返回空列表,不存在时返回空对像列表
|
||||
return [] if exist_flag else [NotExistMediaInfo()]
|
||||
elif no_exists and no_exists.get(mediakey):
|
||||
# 电视剧返回缺失的剧集
|
||||
|
||||
@@ -134,6 +134,11 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
else:
|
||||
for switch in switchs:
|
||||
return_list.append(NotificationSwitch(**switch))
|
||||
for noti in NotificationType:
|
||||
if not any([x.mtype == noti.value for x in return_list]):
|
||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||
telegram=True, slack=True,
|
||||
synologychat=True, vocechat=True))
|
||||
return return_list
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
|
||||
from app import schemas
|
||||
from app.core.plugin import PluginManager
|
||||
@@ -13,6 +13,29 @@ from app.schemas.types import SystemConfigKey
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def register_plugin_api(plugin_id: str = None):
|
||||
"""
|
||||
注册插件API(先删除后新增)
|
||||
"""
|
||||
for api in PluginManager().get_plugin_apis(plugin_id):
|
||||
for r in router.routes:
|
||||
if r.path == api.get("path"):
|
||||
router.routes.remove(r)
|
||||
break
|
||||
router.add_api_route(**api)
|
||||
|
||||
|
||||
def remove_plugin_api(plugin_id: str):
|
||||
"""
|
||||
移除插件API
|
||||
"""
|
||||
for api in PluginManager().get_plugin_apis(plugin_id):
|
||||
for r in router.routes:
|
||||
if r.path == api.get("path"):
|
||||
router.routes.remove(r)
|
||||
break
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> List[schemas.Plugin]:
|
||||
"""
|
||||
@@ -101,6 +124,8 @@ def install(plugin_id: str,
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -125,6 +150,32 @@ def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token))
|
||||
return PluginManager().get_plugin_page(plugin_id)
|
||||
|
||||
|
||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||
def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
|
||||
"""
|
||||
获取所有插件仪表板元信息
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard_meta()
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, key=None, user_agent=user_agent)
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置", response_model=schemas.Response)
|
||||
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -133,9 +184,14 @@ def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)
|
||||
# 删除配置
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().init_plugin(plugin_id, {})
|
||||
PluginManager().init_plugin(plugin_id, {
|
||||
"enabled": False,
|
||||
"enable": False
|
||||
})
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -159,6 +215,8 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
PluginManager().init_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -180,9 +238,10 @@ def uninstall_plugin(plugin_id: str,
|
||||
PluginManager().remove_plugin(plugin_id)
|
||||
# 移除插件服务
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
# 移除插件API
|
||||
remove_plugin_api(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
# 注册插件API
|
||||
for api in PluginManager().get_plugin_apis():
|
||||
router.add_api_route(**api)
|
||||
# 注册全部插件API
|
||||
register_plugin_api()
|
||||
|
||||
@@ -10,10 +10,12 @@ from app.chain.torrents import TorrentsChain
|
||||
from app.core.event import EventManager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models import User
|
||||
from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.models.sitestatistic import SiteStatistic
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.userauth import get_current_active_superuser
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
@@ -92,24 +94,6 @@ def update_site(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||
def delete_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)
|
||||
) -> Any:
|
||||
"""
|
||||
删除站点
|
||||
"""
|
||||
Site.delete(db, site_id)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": site_id
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
|
||||
def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@@ -122,7 +106,7 @@ def cookie_cloud_sync(background_tasks: BackgroundTasks,
|
||||
|
||||
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
|
||||
def reset(db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
清空所有站点数据并重新同步CookieCloud站点信息
|
||||
"""
|
||||
@@ -139,6 +123,21 @@ def reset(db: Session = Depends(get_db),
|
||||
return schemas.Response(success=True, message="站点已重置!")
|
||||
|
||||
|
||||
@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response)
|
||||
def update_sites_priority(
|
||||
priorities: List[dict],
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
批量更新站点优先级
|
||||
"""
|
||||
for priority in priorities:
|
||||
site = Site.get(db, priority.get("id"))
|
||||
if site:
|
||||
site.update(db, {"pri": priority.get("pri")})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=schemas.Response)
|
||||
def update_cookie(
|
||||
site_id: int,
|
||||
@@ -291,3 +290,21 @@ def read_site(
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
return site
|
||||
|
||||
|
||||
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
|
||||
def delete_site(
|
||||
site_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_active_superuser)
|
||||
) -> Any:
|
||||
"""
|
||||
删除站点
|
||||
"""
|
||||
Site.delete(db, site_id)
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": site_id
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import json
|
||||
from typing import List, Any
|
||||
|
||||
import cn2an
|
||||
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token, verify_uri_token
|
||||
from app.db import get_db
|
||||
@@ -14,6 +16,7 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.models.subscribehistory import SubscribeHistory
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_user
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -39,7 +42,12 @@ def read_subscribes(
|
||||
subscribes = Subscribe.list(db)
|
||||
for subscribe in subscribes:
|
||||
if subscribe.sites:
|
||||
subscribe.sites = json.loads(subscribe.sites)
|
||||
try:
|
||||
subscribe.sites = json.loads(str(subscribe.sites))
|
||||
except json.JSONDecodeError:
|
||||
subscribe.sites = []
|
||||
else:
|
||||
subscribe.sites = []
|
||||
return subscribes
|
||||
|
||||
|
||||
@@ -163,7 +171,10 @@ def subscribe_mediaid(
|
||||
meta.begin_season = season
|
||||
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
|
||||
if result and result.sites:
|
||||
result.sites = json.loads(result.sites)
|
||||
try:
|
||||
result.sites = json.loads(result.sites)
|
||||
except json.JSONDecodeError:
|
||||
result.sites = []
|
||||
|
||||
return result if result else Subscribe()
|
||||
|
||||
@@ -317,7 +328,10 @@ def read_subscribe(
|
||||
historys = SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
|
||||
for history in historys:
|
||||
if history and history.sites:
|
||||
history.sites = json.loads(history.sites)
|
||||
try:
|
||||
history.sites = json.loads(history.sites)
|
||||
except json.JSONDecodeError:
|
||||
history.sites = []
|
||||
return historys
|
||||
|
||||
|
||||
@@ -334,6 +348,51 @@ def delete_subscribe(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo])
|
||||
def popular_subscribes(
|
||||
stype: str,
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
min_sub: int = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询热门订阅
|
||||
"""
|
||||
subscribes = SubscribeHelper().get_statistic(stype=stype, page=page, count=count)
|
||||
if subscribes:
|
||||
ret_medias = []
|
||||
for sub in subscribes:
|
||||
# 订阅人数
|
||||
count = sub.get("count")
|
||||
if min_sub and count < min_sub:
|
||||
continue
|
||||
media = MediaInfo()
|
||||
media.type = MediaType(sub.get("type"))
|
||||
media.tmdb_id = sub.get("tmdbid")
|
||||
# 处理标题
|
||||
title = sub.get("name")
|
||||
season = sub.get("season")
|
||||
if season and int(season) > 1 and media.tmdb_id:
|
||||
# 小写数据转大写
|
||||
season_str = cn2an.an2cn(season, "low")
|
||||
title = f"{title} 第{season_str}季"
|
||||
media.title = title
|
||||
media.year = sub.get("year")
|
||||
media.douban_id = sub.get("doubanid")
|
||||
media.bangumi_id = sub.get("bangumiid")
|
||||
media.tvdb_id = sub.get("tvdbid")
|
||||
media.imdb_id = sub.get("imdbid")
|
||||
media.season = sub.get("season")
|
||||
media.overview = sub.get("description")
|
||||
media.vote_average = sub.get("vote")
|
||||
media.poster_path = sub.get("poster")
|
||||
media.backdrop_path = sub.get("backdrop")
|
||||
media.popularity = count
|
||||
ret_medias.append(media)
|
||||
return [media.to_dict() for media in ret_medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
|
||||
def read_subscribe(
|
||||
subscribe_id: int,
|
||||
@@ -346,7 +405,10 @@ def read_subscribe(
|
||||
return Subscribe()
|
||||
subscribe = Subscribe.get(db, subscribe_id)
|
||||
if subscribe and subscribe.sites:
|
||||
subscribe.sites = json.loads(subscribe.sites)
|
||||
try:
|
||||
subscribe.sites = json.loads(subscribe.sites)
|
||||
except json.JSONDecodeError:
|
||||
subscribe.sites = []
|
||||
return subscribe
|
||||
|
||||
|
||||
@@ -359,5 +421,12 @@ def delete_subscribe(
|
||||
"""
|
||||
删除订阅信息
|
||||
"""
|
||||
Subscribe.delete(db, subscribe_id)
|
||||
subscribe = Subscribe.get(db, subscribe_id)
|
||||
if subscribe:
|
||||
subscribe.delete(db, subscribe_id)
|
||||
# 统计订阅
|
||||
SubscribeHelper().sub_done_async({
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid
|
||||
})
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -10,10 +10,13 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.chain.system import SystemChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_token
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.userauth import get_current_active_superuser
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
@@ -43,7 +46,7 @@ def get_img(imgurl: str, proxy: bool = False) -> Any:
|
||||
|
||||
|
||||
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
|
||||
def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def get_env_setting(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
查询系统环境变量,包括当前版本号
|
||||
"""
|
||||
@@ -54,6 +57,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"VERSION": APP_VERSION,
|
||||
"AUTH_VERSION": SitesHelper().auth_version,
|
||||
"INDEXER_VERSION": SitesHelper().indexer_version,
|
||||
"FRONTEND_VERSION": SystemChain().get_frontend_version()
|
||||
})
|
||||
return schemas.Response(success=True,
|
||||
data=info)
|
||||
@@ -61,7 +65,7 @@ def get_env_setting(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
|
||||
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
|
||||
def set_env_setting(env: dict,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
更新系统环境变量
|
||||
"""
|
||||
@@ -95,6 +99,8 @@ def get_progress(process_type: str, token: str):
|
||||
|
||||
def event_generator():
|
||||
while True:
|
||||
if global_vars.is_system_stopped():
|
||||
break
|
||||
detail = progress.get(process_type)
|
||||
yield 'data: %s\n\n' % json.dumps(detail)
|
||||
time.sleep(0.2)
|
||||
@@ -104,7 +110,7 @@ def get_progress(process_type: str, token: str):
|
||||
|
||||
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
|
||||
def get_setting(key: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
查询系统设置
|
||||
"""
|
||||
@@ -119,7 +125,7 @@ def get_setting(key: str,
|
||||
|
||||
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
|
||||
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
更新系统设置
|
||||
"""
|
||||
@@ -152,6 +158,8 @@ def get_message(token: str, role: str = "sys"):
|
||||
|
||||
def event_generator():
|
||||
while True:
|
||||
if global_vars.is_system_stopped():
|
||||
break
|
||||
detail = message.get(role)
|
||||
yield 'data: %s\n\n' % (detail or '')
|
||||
time.sleep(3)
|
||||
@@ -180,6 +188,8 @@ def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
|
||||
for line in f.readlines()[-max(length, 50):]:
|
||||
yield 'data: %s\n\n' % line
|
||||
while True:
|
||||
if global_vars.is_system_stopped():
|
||||
break
|
||||
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
|
||||
yield 'data: %s\n\n' % (t or '')
|
||||
time.sleep(1)
|
||||
@@ -276,9 +286,12 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
module_ids = [module.__name__ for module in ModuleManager().get_modules("test")]
|
||||
modules = [{
|
||||
"id": k,
|
||||
"name": v.get_name(),
|
||||
} for k, v in ModuleManager().get_modules().items()]
|
||||
return schemas.Response(success=True, data={
|
||||
"ids": module_ids
|
||||
"modules": modules
|
||||
})
|
||||
|
||||
|
||||
@@ -292,19 +305,21 @@ def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
|
||||
|
||||
|
||||
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
|
||||
def restart_system(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重启系统
|
||||
"""
|
||||
if not SystemUtils.can_restart():
|
||||
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
|
||||
# 标识停止事件
|
||||
global_vars.stop_system()
|
||||
# 执行重启
|
||||
ret, msg = SystemUtils.restart()
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||
def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def reload_module(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重新加载模块
|
||||
"""
|
||||
@@ -315,7 +330,7 @@ def reload_module(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def execute_command(jobid: str,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
执行命令
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
@@ -17,10 +16,9 @@ def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
"""
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
if not seasons_info:
|
||||
return []
|
||||
else:
|
||||
if seasons_info:
|
||||
return seasons_info
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
@@ -32,15 +30,14 @@ def tmdb_similar(tmdbid: int,
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
medias = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
medias = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
return []
|
||||
else:
|
||||
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfos]
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
@@ -52,18 +49,17 @@ def tmdb_recommend(tmdbid: int,
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
medias = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
medias = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
return []
|
||||
else:
|
||||
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfos]
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.TmdbPerson])
|
||||
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
@@ -73,28 +69,21 @@ def tmdb_credits(tmdbid: int,
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
persons = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
persons = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
return []
|
||||
else:
|
||||
return [schemas.TmdbPerson(**tmdbinfo) for tmdbinfo in tmdbinfos]
|
||||
return persons or []
|
||||
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
|
||||
def tmdb_person(person_id: int,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
|
||||
if not tmdbinfo:
|
||||
return schemas.TmdbPerson()
|
||||
else:
|
||||
return schemas.TmdbPerson(**tmdbinfo)
|
||||
return TmdbChain().person_detail(person_id=person_id)
|
||||
|
||||
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
@@ -104,11 +93,10 @@ def tmdb_person_credits(person_id: int,
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
if not tmdbinfo:
|
||||
return []
|
||||
else:
|
||||
return [MediaInfo(tmdb_info=tmdbinfo).to_dict() for tmdbinfo in tmdbinfo]
|
||||
medias = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias]
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
|
||||
@@ -127,7 +115,7 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
page=page)
|
||||
if not movies:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
|
||||
return [movie.to_dict() for movie in movies]
|
||||
|
||||
|
||||
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
|
||||
@@ -146,7 +134,7 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
page=page)
|
||||
if not tvs:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
|
||||
return [tv.to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
@@ -158,7 +146,7 @@ def tmdb_trending(page: int = 1,
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
if not infos:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
|
||||
return [info.to_dict() for info in infos]
|
||||
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
@@ -167,8 +155,4 @@ def tmdb_season_episodes(tmdbid: int, season: int,
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
"""
|
||||
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
if not episodes_info:
|
||||
return []
|
||||
else:
|
||||
return episodes_info
|
||||
return TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
|
||||
@@ -19,6 +19,7 @@ def manual_transfer(path: str = None,
|
||||
logid: int = None,
|
||||
target: str = None,
|
||||
tmdbid: int = None,
|
||||
doubanid: str = None,
|
||||
type_name: str = None,
|
||||
season: int = None,
|
||||
transfer_type: str = None,
|
||||
@@ -27,6 +28,7 @@ def manual_transfer(path: str = None,
|
||||
episode_part: str = None,
|
||||
episode_offset: int = 0,
|
||||
min_filesize: int = 0,
|
||||
scrape: bool = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
@@ -36,6 +38,7 @@ def manual_transfer(path: str = None,
|
||||
:param target: 目标路径
|
||||
:param type_name: 媒体类型、电影/电视剧
|
||||
:param tmdbid: tmdbid
|
||||
:param doubanid: 豆瓣ID
|
||||
:param season: 剧集季号
|
||||
:param transfer_type: 转移类型,move/copy 等
|
||||
:param episode_format: 剧集识别格式
|
||||
@@ -43,6 +46,7 @@ def manual_transfer(path: str = None,
|
||||
:param episode_part: 剧集识别分集信息
|
||||
:param episode_offset: 剧集识别偏移量
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param scrape: 是否刮削元数据
|
||||
:param db: 数据库
|
||||
:param _: Token校验
|
||||
"""
|
||||
@@ -66,10 +70,6 @@ def manual_transfer(path: str = None,
|
||||
if history.dest and str(history.dest) != "None":
|
||||
# 删除旧的已整理文件
|
||||
transfer.delete_files(Path(history.dest))
|
||||
if not target:
|
||||
target = transfer.get_root_path(path=history.dest,
|
||||
type_name=history.type,
|
||||
category=history.category)
|
||||
elif path:
|
||||
in_path = Path(path)
|
||||
else:
|
||||
@@ -91,11 +91,13 @@ def manual_transfer(path: str = None,
|
||||
in_path=in_path,
|
||||
target=target,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
mtype=mtype,
|
||||
season=season,
|
||||
transfer_type=transfer_type,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize,
|
||||
scrape=scrape,
|
||||
force=force
|
||||
)
|
||||
# 失败
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import base64
|
||||
import re
|
||||
from typing import Any, List
|
||||
from typing import Any, List, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -10,6 +10,7 @@ from app.core.security import get_password_hash
|
||||
from app.db import get_db
|
||||
from app.db.models.user import User
|
||||
from app.db.userauth import get_current_active_superuser, get_current_active_user
|
||||
from app.db.userconfig_oper import UserConfigOper
|
||||
from app.utils.otp import OtpUtils
|
||||
|
||||
router = APIRouter()
|
||||
@@ -86,8 +87,8 @@ def read_current_user(
|
||||
|
||||
|
||||
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
|
||||
async def upload_avatar(user_id: int, db: Session = Depends(get_db),
|
||||
file: UploadFile = File(...)):
|
||||
async def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
|
||||
_: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
上传用户头像
|
||||
"""
|
||||
@@ -142,6 +143,28 @@ def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
|
||||
return schemas.Response(success=user.is_otp)
|
||||
|
||||
|
||||
@router.get("/config/{key}", summary="查询用户配置", response_model=schemas.Response)
|
||||
def get_config(key: str,
|
||||
current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
查询用户配置
|
||||
"""
|
||||
value = UserConfigOper().get(username=current_user.name, key=key)
|
||||
return schemas.Response(success=True, data={
|
||||
"value": value
|
||||
})
|
||||
|
||||
|
||||
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
|
||||
def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
current_user: User = Depends(get_current_active_user)):
|
||||
"""
|
||||
更新用户配置
|
||||
"""
|
||||
UserConfigOper().set(username=current_user.name, key=key, value=value)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
|
||||
def delete_user(
|
||||
*,
|
||||
|
||||
@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_uri_apikey
|
||||
from app.db import get_db
|
||||
@@ -121,7 +120,7 @@ def arr_rootfolder(_: str = Depends(verify_uri_apikey)) -> Any:
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"path": "/" if not settings.LIBRARY_PATHS else str(settings.LIBRARY_PATHS[0]),
|
||||
"path": "/",
|
||||
"accessible": True,
|
||||
"freeSpace": 0,
|
||||
"unmappedFolders": []
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.db.message_oper import MessageOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo, TmdbEpisode
|
||||
WebhookEventInfo, TmdbEpisode, MediaPerson
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@@ -92,8 +92,13 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
logger.debug(f"请求模块执行:{method} ...")
|
||||
result = None
|
||||
modules = self.modulemanager.get_modules(method)
|
||||
modules = self.modulemanager.get_running_modules(method)
|
||||
for module in modules:
|
||||
try:
|
||||
module_name = module.get_name()
|
||||
except Exception as err:
|
||||
logger.error(f"获取模块名称出错:{str(err)}")
|
||||
module_name = module.__class__.__name__
|
||||
try:
|
||||
func = getattr(module, method)
|
||||
if is_result_empty(result):
|
||||
@@ -112,7 +117,10 @@ class ChainBase(metaclass=ABCMeta):
|
||||
break
|
||||
except Exception as err:
|
||||
logger.error(
|
||||
f"运行模块 {method} 出错:{module.__class__.__name__} - {str(err)}\n{traceback.format_exc()}")
|
||||
f"运行模块 {module.__class__.__name__}.{method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{module_name}发生了错误",
|
||||
message=str(err),
|
||||
role="system")
|
||||
return result
|
||||
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
@@ -260,6 +268,13 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("search_medias", meta=meta)
|
||||
|
||||
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息
|
||||
:param name: 人物名称
|
||||
"""
|
||||
return self.run_module("search_persons", name=name)
|
||||
|
||||
def search_torrents(self, site: CommentedMap,
|
||||
keywords: List[str],
|
||||
mtype: MediaType = None,
|
||||
@@ -343,7 +358,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
scrape: bool = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -352,12 +368,14 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param transfer_type: 转移模式
|
||||
:param target: 转移目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param scrape: 是否刮削元数据
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target, episodes_info=episodes_info)
|
||||
transfer_type=transfer_type, target=target, episodes_info=episodes_info,
|
||||
scrape=scrape)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
def transfer_completed(self, hashs: str, path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
@@ -491,6 +509,13 @@ class ChainBase(metaclass=ABCMeta):
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, metainfo=metainfo,
|
||||
transfer_type=transfer_type, force_nfo=force_nfo, force_img=force_img)
|
||||
|
||||
def media_category(self) -> Optional[Dict[str, list]]:
|
||||
"""
|
||||
获取媒体分类
|
||||
:return: 获取二级分类配置字典项,需包括电影、电视剧
|
||||
"""
|
||||
return self.run_module("media_category")
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
注册菜单命令
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
@@ -9,13 +11,11 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
||||
Bangumi处理链,单例运行
|
||||
"""
|
||||
|
||||
def calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
"""
|
||||
return self.run_module("bangumi_calendar", page=page, count=count)
|
||||
return self.run_module("bangumi_calendar")
|
||||
|
||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
@@ -25,18 +25,30 @@ class BangumiChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("bangumi_info", bangumiid=bangumiid)
|
||||
|
||||
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
|
||||
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据BangumiID查询电影演职员表
|
||||
:param bangumiid: BangumiID
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
"""
|
||||
return self.run_module("bangumi_credits", bangumiid=bangumiid, page=page, count=count)
|
||||
return self.run_module("bangumi_credits", bangumiid=bangumiid)
|
||||
|
||||
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
|
||||
def bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据BangumiID查询推荐电影
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
return self.run_module("bangumi_recommend", bangumiid=bangumiid)
|
||||
|
||||
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据人物ID查询Bangumi人物详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("bangumi_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("bangumi_person_credits", person_id=person_id)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -11,7 +12,22 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
豆瓣处理链,单例运行
|
||||
"""
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据人物ID查询豆瓣人物详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("douban_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_person_credits", person_id=person_id, page=page)
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取豆瓣电影TOP250
|
||||
:param page: 页码
|
||||
@@ -19,26 +35,26 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("movie_top250", page=page, count=count)
|
||||
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取正在上映的电影
|
||||
"""
|
||||
return self.run_module("movie_showing", page=page, count=count)
|
||||
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周中国剧集榜
|
||||
"""
|
||||
return self.run_module("tv_weekly_chinese", page=page, count=count)
|
||||
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取本周全球剧集榜
|
||||
"""
|
||||
return self.run_module("tv_weekly_global", page=page, count=count)
|
||||
|
||||
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||
page: int = 0, count: int = 30) -> Optional[List[dict]]:
|
||||
page: int = 0, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现豆瓣电影、剧集
|
||||
:param mtype: 媒体类型
|
||||
@@ -51,52 +67,46 @@ class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
|
||||
page=page, count=count)
|
||||
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取动画剧集
|
||||
"""
|
||||
return self.run_module("tv_animation", page=page, count=count)
|
||||
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门电影
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
return None
|
||||
return self.run_module("movie_hot", page=page, count=count)
|
||||
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取热门剧集
|
||||
"""
|
||||
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]:
|
||||
def movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_movie_credits", doubanid=doubanid, page=page)
|
||||
return self.run_module("douban_movie_credits", doubanid=doubanid)
|
||||
|
||||
def tv_credits(self, doubanid: str, page: int = 1) -> List[dict]:
|
||||
def tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员
|
||||
:param doubanid: 豆瓣ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.run_module("douban_tv_credits", doubanid=doubanid, page=page)
|
||||
return self.run_module("douban_tv_credits", doubanid=doubanid)
|
||||
|
||||
def movie_recommend(self, doubanid: str) -> List[dict]:
|
||||
def movie_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.run_module("douban_movie_recommend", doubanid=doubanid)
|
||||
|
||||
def tv_recommend(self, doubanid: str) -> List[dict]:
|
||||
def tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧
|
||||
:param doubanid: 豆瓣ID
|
||||
|
||||
@@ -14,6 +14,8 @@ from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
|
||||
@@ -32,9 +34,12 @@ class DownloadChain(ChainBase):
|
||||
self.torrent = TorrentHelper()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.mediaserver = MediaServerOper()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
|
||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None, userid: str = None, username: str = None):
|
||||
channel: MessageChannel = None, userid: str = None, username: str = None,
|
||||
download_episodes: str = None):
|
||||
"""
|
||||
发送添加下载的消息
|
||||
:param meta: 元数据
|
||||
@@ -43,6 +48,7 @@ class DownloadChain(ChainBase):
|
||||
:param channel: 通知渠道
|
||||
:param userid: 用户ID,指定时精确发送对应用户
|
||||
:param username: 通知显示的下载用户信息
|
||||
:param download_episodes: 下载的集数
|
||||
"""
|
||||
msg_text = ""
|
||||
if username:
|
||||
@@ -80,7 +86,7 @@ class DownloadChain(ChainBase):
|
||||
mtype=NotificationType.Download,
|
||||
userid=userid,
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{meta.season_episode} 开始下载",
|
||||
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image()))
|
||||
|
||||
@@ -207,6 +213,9 @@ class DownloadChain(ChainBase):
|
||||
_torrent = context.torrent_info
|
||||
_media = context.media_info
|
||||
_meta = context.meta_info
|
||||
|
||||
# 实际下载的集数
|
||||
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
|
||||
_folder_name = ""
|
||||
if not torrent_file:
|
||||
# 下载种子文件,得到的可能是文件也可能是磁力链
|
||||
@@ -221,39 +230,35 @@ class DownloadChain(ChainBase):
|
||||
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
|
||||
|
||||
# 下载目录
|
||||
if not save_path:
|
||||
if settings.DOWNLOAD_CATEGORY and _media and _media.category:
|
||||
# 开启下载二级目录
|
||||
if _media.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
download_dir = settings.SAVE_MOVIE_PATH / _media.category
|
||||
else:
|
||||
if _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = settings.SAVE_ANIME_PATH / _media.category
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = settings.SAVE_TV_PATH / _media.category
|
||||
elif _media:
|
||||
# 未开启下载二级目录
|
||||
if _media.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
download_dir = settings.SAVE_MOVIE_PATH
|
||||
else:
|
||||
if _media.genre_ids \
|
||||
and set(_media.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
download_dir = settings.SAVE_ANIME_PATH
|
||||
else:
|
||||
# 电视剧
|
||||
download_dir = settings.SAVE_TV_PATH
|
||||
else:
|
||||
# 未识别
|
||||
download_dir = settings.SAVE_PATH
|
||||
if save_path:
|
||||
# 有自定义下载目录时,尝试匹配目录配置
|
||||
dir_info = self.directoryhelper.get_download_dir(_media, to_path=Path(save_path))
|
||||
else:
|
||||
# 根据媒体信息查询下载目录配置
|
||||
dir_info = self.directoryhelper.get_download_dir(_media)
|
||||
# 拼装子目录
|
||||
if dir_info:
|
||||
# 一级目录
|
||||
if not dir_info.media_type and dir_info.auto_category:
|
||||
# 一级自动分类
|
||||
download_dir = Path(dir_info.path) / _media.type.value
|
||||
else:
|
||||
# 一级不分类
|
||||
download_dir = Path(dir_info.path)
|
||||
|
||||
# 二级目录
|
||||
if not dir_info.category and dir_info.auto_category and _media and _media.category:
|
||||
# 二级自动分类
|
||||
download_dir = download_dir / _media.category
|
||||
elif save_path:
|
||||
# 自定义下载目录
|
||||
download_dir = Path(save_path)
|
||||
else:
|
||||
# 未找到下载目录,且没有自定义下载目录
|
||||
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
|
||||
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
|
||||
title="下载失败", role="system")
|
||||
return None
|
||||
|
||||
# 添加下载
|
||||
result: Optional[tuple] = self.download(content=content,
|
||||
@@ -284,7 +289,7 @@ class DownloadChain(ChainBase):
|
||||
tvdbid=_media.tvdb_id,
|
||||
doubanid=_media.douban_id,
|
||||
seasons=_meta.season,
|
||||
episodes=_meta.episode,
|
||||
episodes=download_episodes or _meta.episode,
|
||||
image=_media.get_backdrop_image(),
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
@@ -321,7 +326,8 @@ class DownloadChain(ChainBase):
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
|
||||
# 发送消息(群发,不带channel和userid)
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent, username=username)
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
||||
username=username, download_episodes=download_episodes)
|
||||
# 下载成功后处理
|
||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||
# 广播事件
|
||||
@@ -429,12 +435,15 @@ class DownloadChain(ChainBase):
|
||||
# 如果是电影,直接下载
|
||||
for context in contexts:
|
||||
if context.media_info.type == MediaType.MOVIE:
|
||||
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
|
||||
if self.download_single(context, save_path=save_path, channel=channel,
|
||||
userid=userid, username=username):
|
||||
# 下载成功
|
||||
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
|
||||
# 电视剧整季匹配
|
||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||
if no_exists:
|
||||
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
|
||||
need_seasons: Dict[int, list] = {}
|
||||
@@ -447,6 +456,7 @@ class DownloadChain(ChainBase):
|
||||
if not need_seasons.get(need_mid):
|
||||
need_seasons[need_mid] = []
|
||||
need_seasons[need_mid].append(tv.season or 1)
|
||||
logger.info(f"缺失整季:{need_seasons}")
|
||||
# 查找整季包含的种子,只处理整季没集的种子或者是集数超过季的种子
|
||||
for need_mid, need_season in need_seasons.items():
|
||||
# 循环种子
|
||||
@@ -462,23 +472,31 @@ class DownloadChain(ChainBase):
|
||||
continue
|
||||
# 种子的季清单
|
||||
torrent_season = meta.season_list
|
||||
# 没有季的默认为第1季
|
||||
if not torrent_season:
|
||||
torrent_season = [1]
|
||||
# 种子有集的不要
|
||||
if meta.episode_list:
|
||||
continue
|
||||
# 匹配TMDBID
|
||||
if need_mid == media.tmdb_id or need_mid == media.douban_id:
|
||||
# 不重复添加
|
||||
if context in downloaded_list:
|
||||
continue
|
||||
# 种子季是需要季或者子集
|
||||
if set(torrent_season).issubset(set(need_season)):
|
||||
if len(torrent_season) == 1:
|
||||
# 只有一季的可能是命名错误,需要打开种子鉴别,只有实际集数大于等于总集数才下载
|
||||
logger.info(f"开始下载种子 {torrent.title} ...")
|
||||
content, _, torrent_files = self.download_torrent(torrent)
|
||||
if not content:
|
||||
logger.warn(f"{torrent.title} 种子下载失败!")
|
||||
continue
|
||||
if isinstance(content, str):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
|
||||
continue
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{meta.org_string} 解析文件集数为 {torrent_episodes}")
|
||||
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
|
||||
if not torrent_episodes:
|
||||
continue
|
||||
# 更新集数范围
|
||||
@@ -489,10 +507,11 @@ class DownloadChain(ChainBase):
|
||||
need_total = __get_season_episodes(need_mid, torrent_season[0])
|
||||
if len(torrent_episodes) < need_total:
|
||||
logger.info(
|
||||
f"{meta.org_string} 解析文件集数发现不是完整合集")
|
||||
f"{meta.org_string} 解析文件集数发现不是完整合集,先放弃这个种子")
|
||||
continue
|
||||
else:
|
||||
# 下载
|
||||
logger.info(f"开始下载 {torrent.title} ...")
|
||||
download_id = self.download_single(
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
@@ -503,21 +522,25 @@ class DownloadChain(ChainBase):
|
||||
)
|
||||
else:
|
||||
# 下载
|
||||
logger.info(f"开始下载 {torrent.title} ...")
|
||||
download_id = self.download_single(context,
|
||||
save_path=save_path, channel=channel,
|
||||
userid=userid, username=username)
|
||||
|
||||
if download_id:
|
||||
# 下载成功
|
||||
logger.info(f"{torrent.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需季集
|
||||
need_season = __update_seasons(_mid=need_mid,
|
||||
_need=need_season,
|
||||
_current=torrent_season)
|
||||
logger.info(f"{need_mid} 剩余需要季:{need_season}")
|
||||
if not need_season:
|
||||
# 全部下载完成
|
||||
break
|
||||
# 电视剧季内的集匹配
|
||||
logger.info(f"开始电视剧完整集匹配:{no_exists}")
|
||||
if no_exists:
|
||||
# TMDBID列表
|
||||
need_tv_list = list(no_exists)
|
||||
@@ -567,19 +590,23 @@ class DownloadChain(ChainBase):
|
||||
# 为需要集的子集则下载
|
||||
if torrent_episodes.issubset(set(need_episodes)):
|
||||
# 下载
|
||||
logger.info(f"开始下载 {meta.title} ...")
|
||||
download_id = self.download_single(context,
|
||||
save_path=save_path, channel=channel,
|
||||
userid=userid, username=username)
|
||||
if download_id:
|
||||
# 下载成功
|
||||
logger.info(f"{meta.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新仍需集数
|
||||
need_episodes = __update_episodes(_mid=need_mid,
|
||||
_need=need_episodes,
|
||||
_sea=need_season,
|
||||
_current=torrent_episodes)
|
||||
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||
|
||||
# 仍然缺失的剧集,从整季中选择需要的集数文件下载,仅支持QB和TR
|
||||
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
|
||||
if no_exists:
|
||||
# TMDBID列表
|
||||
no_exists_list = list(no_exists)
|
||||
@@ -625,15 +652,17 @@ class DownloadChain(ChainBase):
|
||||
and len(meta.season_list) == 1 \
|
||||
and meta.season_list[0] == need_season:
|
||||
# 检查种子看是否有需要的集
|
||||
logger.info(f"开始下载种子 {torrent.title} ...")
|
||||
content, _, torrent_files = self.download_torrent(torrent)
|
||||
if not content:
|
||||
logger.info(f"{torrent.title} 种子下载失败!")
|
||||
continue
|
||||
if isinstance(content, str):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
|
||||
continue
|
||||
# 种子全部集
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{torrent.site_name} - {meta.org_string} 解析文件集数:{torrent_episodes}")
|
||||
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
|
||||
# 选中的集
|
||||
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
||||
if not selected_episodes:
|
||||
@@ -641,6 +670,7 @@ class DownloadChain(ChainBase):
|
||||
continue
|
||||
logger.info(f"{torrent.site_name} - {torrent.title} 选中集数:{selected_episodes}")
|
||||
# 添加下载
|
||||
logger.info(f"开始下载 {torrent.title} ...")
|
||||
download_id = self.download_single(
|
||||
context=context,
|
||||
torrent_file=content if isinstance(content, Path) else None,
|
||||
@@ -653,6 +683,7 @@ class DownloadChain(ChainBase):
|
||||
if not download_id:
|
||||
continue
|
||||
# 下载成功
|
||||
logger.info(f"{torrent.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
# 更新种子集数范围
|
||||
begin_ep = min(torrent_episodes)
|
||||
@@ -663,8 +694,10 @@ class DownloadChain(ChainBase):
|
||||
_need=need_episodes,
|
||||
_sea=need_season,
|
||||
_current=selected_episodes)
|
||||
logger.info(f"季 {need_season} 剩余需要集:{need_episodes}")
|
||||
|
||||
# 返回下载的资源,剩下没下完的
|
||||
logger.info(f"成功下载种子数:{len(downloaded_list)},剩余未下载的剧集:{no_exists}")
|
||||
return downloaded_list, no_exists
|
||||
|
||||
def get_no_exists_info(self, meta: MetaBase,
|
||||
|
||||
@@ -34,7 +34,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
||||
if not mediainfo:
|
||||
# 偿试使用辅助识别,如果有注册响应事件的话
|
||||
# 尝试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(EventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{title} ...')
|
||||
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
||||
@@ -143,7 +143,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=file_meta)
|
||||
if not mediainfo:
|
||||
# 偿试使用辅助识别,如果有注册响应事件的话
|
||||
# 尝试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(EventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
|
||||
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
|
||||
@@ -156,9 +156,9 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 返回上下文
|
||||
return Context(meta_info=file_meta, media_info=mediainfo)
|
||||
|
||||
def search(self, title: str) -> Tuple[MetaBase, List[MediaInfo]]:
|
||||
def search(self, title: str) -> Tuple[Optional[MetaBase], List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
搜索媒体/人物信息
|
||||
:param title: 搜索内容
|
||||
:return: 识别元数据,媒体信息列表
|
||||
"""
|
||||
|
||||
@@ -102,12 +102,23 @@ class SearchChain(ChainBase):
|
||||
:param filter_rule: 过滤规则,为空是使用默认过滤规则
|
||||
:param area: 搜索范围,title or imdbid
|
||||
"""
|
||||
|
||||
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
|
||||
"""
|
||||
执行优先级过滤
|
||||
"""
|
||||
return self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=torrent_list,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo) or []
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
mediainfo.season = meta.begin_season
|
||||
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
|
||||
|
||||
# 补充媒体信息
|
||||
if not mediainfo.names:
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
@@ -116,17 +127,19 @@ class SearchChain(ChainBase):
|
||||
if not mediainfo:
|
||||
logger.error(f'媒体信息识别失败!')
|
||||
return []
|
||||
|
||||
# 缺失的季集
|
||||
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
|
||||
if no_exists and no_exists.get(mediakey):
|
||||
# 过滤剧集
|
||||
season_episodes = {sea: info.episodes
|
||||
for sea, info in no_exists[mediainfo.tmdb_id].items()}
|
||||
for sea, info in no_exists[mediakey].items()}
|
||||
elif mediainfo.season:
|
||||
# 豆瓣只搜索当前季
|
||||
season_episodes = {mediainfo.season: []}
|
||||
else:
|
||||
season_episodes = None
|
||||
|
||||
# 搜索关键词
|
||||
if keyword:
|
||||
keywords = [keyword]
|
||||
@@ -147,9 +160,11 @@ class SearchChain(ChainBase):
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
|
||||
# 开始新进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
# 匹配的资源
|
||||
|
||||
# 开始匹配
|
||||
_match_torrents = []
|
||||
# 总数
|
||||
_total = len(torrents)
|
||||
@@ -177,17 +192,6 @@ class SearchChain(ChainBase):
|
||||
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
if torrent.title != torrent_meta.org_string:
|
||||
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
|
||||
# 比对词条指定的tmdbid
|
||||
if torrent_meta.tmdbid or torrent_meta.doubanid:
|
||||
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
|
||||
logger.info(f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
|
||||
logger.info(f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
_match_torrents.append(torrent)
|
||||
continue
|
||||
|
||||
# 比对种子
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
@@ -202,25 +206,12 @@ class SearchChain(ChainBase):
|
||||
key=ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = torrents
|
||||
|
||||
# 开始过滤
|
||||
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
# 过滤种子
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始优先级规则/剧集过滤,当前规则:{priority_rule} ...')
|
||||
result: List[TorrentInfo] = self.filter_torrents(rule_string=priority_rule,
|
||||
torrent_list=_match_torrents,
|
||||
season_episodes=season_episodes,
|
||||
mediainfo=mediainfo)
|
||||
if result is not None:
|
||||
_match_torrents = result
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用过滤规则再次过滤
|
||||
|
||||
# 开始过滤规则过滤
|
||||
if _match_torrents:
|
||||
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
|
||||
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
|
||||
@@ -229,22 +220,43 @@ class SearchChain(ChainBase):
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
logger.info(f"过滤规则过滤完成,剩余 {len(_match_torrents)} 个资源")
|
||||
|
||||
# 开始优先级规则/剧集过滤
|
||||
if priority_rule is None:
|
||||
# 取搜索优先级规则
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
|
||||
if priority_rule:
|
||||
logger.info(f'开始优先级规则/剧集过滤,当前规则:{priority_rule} ...')
|
||||
_match_torrents = __do_filter(_match_torrents)
|
||||
if not _match_torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
logger.info(f"优先级规则/剧集过滤完成,剩余 {len(_match_torrents)} 个资源")
|
||||
|
||||
# 去掉mediainfo中多余的数据
|
||||
mediainfo.clear()
|
||||
|
||||
# 组装上下文
|
||||
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
media_info=mediainfo,
|
||||
torrent_info=torrent) for torrent in _match_torrents]
|
||||
|
||||
logger.info(f"过滤完成,剩余 {len(contexts)} 个资源")
|
||||
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
|
||||
|
||||
# 排序
|
||||
self.progress.update(value=100,
|
||||
self.progress.update(value=99,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
|
||||
# 结束进度
|
||||
self.progress.update(value=100,
|
||||
text=f'搜索完成,共 {len(contexts)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
|
||||
self.progress.end(ProgressKey.Search)
|
||||
|
||||
# 返回
|
||||
return contexts
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.db.models.site import Site
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.siteicon_oper import SiteIconOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.sytestatistic_oper import SiteStatisticOper
|
||||
from app.db.sitestatistic_oper import SiteStatisticOper
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookie import CookieHelper
|
||||
@@ -52,7 +52,10 @@ class SiteChain(ChainBase):
|
||||
"zhuque.in": self.__zhuque_test,
|
||||
"m-team.io": self.__mteam_test,
|
||||
"m-team.cc": self.__mteam_test,
|
||||
"ptlsp.com": self.__ptlsp_test,
|
||||
"ptlsp.com": self.__indexphp_test,
|
||||
"1ptba.com": self.__indexphp_test,
|
||||
"star-space.net": self.__indexphp_test,
|
||||
"yemapt.org": self.__yema_test,
|
||||
}
|
||||
|
||||
def is_special_site(self, domain: str) -> bool:
|
||||
@@ -73,7 +76,7 @@ class SiteChain(ChainBase):
|
||||
ua=user_agent,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=site.url)
|
||||
if res and res.status_code == 200:
|
||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||
@@ -90,7 +93,7 @@ class SiteChain(ChainBase):
|
||||
},
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=f"{site.url}api/user/getInfo")
|
||||
if user_res and user_res.status_code == 200:
|
||||
user_info = user_res.json()
|
||||
@@ -105,19 +108,23 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
user_agent = site.ua or settings.USER_AGENT
|
||||
url = f"{site.url}api/member/profile"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": user_agent,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Authorization": site.token
|
||||
}
|
||||
res = RequestUtils(
|
||||
ua=user_agent,
|
||||
cookies=site.cookie,
|
||||
headers=headers,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=15
|
||||
timeout=site.timeout or 15
|
||||
).post_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("data"):
|
||||
# 更新最后访问时间
|
||||
res = RequestUtils(cookies=site.cookie,
|
||||
ua=user_agent,
|
||||
timeout=60,
|
||||
res = RequestUtils(headers=headers,
|
||||
timeout=site.timeout or 15,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
referer=f"{site.url}index"
|
||||
).post_res(url=urljoin(url, "api/member/updateLastBrowse"))
|
||||
@@ -125,11 +132,35 @@ class SiteChain(ChainBase):
|
||||
return True, "连接成功"
|
||||
else:
|
||||
return True, f"连接成功,但更新状态失败"
|
||||
return False, "Cookie已失效"
|
||||
return False, "鉴权已过期或无效"
|
||||
|
||||
def __ptlsp_test(self, site: Site) -> Tuple[bool, str]:
|
||||
@staticmethod
|
||||
def __yema_test(site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:ptlsp
|
||||
判断站点是否已经登陆:yemapt
|
||||
"""
|
||||
user_agent = site.ua or settings.USER_AGENT
|
||||
url = f"{site.url}api/consumer/fetchSelfDetail"
|
||||
headers = {
|
||||
"User-Agent": user_agent,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
res = RequestUtils(
|
||||
headers=headers,
|
||||
cookies=site.cookie,
|
||||
proxies=settings.PROXY if site.proxy else None,
|
||||
timeout=site.timeout or 15
|
||||
).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
user_info = res.json()
|
||||
if user_info and user_info.get("success"):
|
||||
return True, "连接成功"
|
||||
return False, "Cookie已过期"
|
||||
|
||||
def __indexphp_test(self, site: Site) -> Tuple[bool, str]:
|
||||
"""
|
||||
判断站点是否已经登陆:ptlsp/1ptba
|
||||
"""
|
||||
site.url = f"{site.url}index.php"
|
||||
return self.__test(site)
|
||||
@@ -144,7 +175,7 @@ class SiteChain(ChainBase):
|
||||
:return:
|
||||
"""
|
||||
favicon_url = urljoin(url, "favicon.ico")
|
||||
res = RequestUtils(cookies=cookie, timeout=60, ua=ua).get_res(url=url)
|
||||
res = RequestUtils(cookies=cookie, timeout=30, ua=ua).get_res(url=url)
|
||||
if res:
|
||||
html_text = res.text
|
||||
else:
|
||||
@@ -156,7 +187,7 @@ class SiteChain(ChainBase):
|
||||
if fav_link:
|
||||
favicon_url = urljoin(url, fav_link[0])
|
||||
|
||||
res = RequestUtils(cookies=cookie, timeout=20, ua=ua).get_res(url=favicon_url)
|
||||
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
|
||||
if res:
|
||||
return favicon_url, base64.b64encode(res.content).decode()
|
||||
else:
|
||||
@@ -184,7 +215,7 @@ class SiteChain(ChainBase):
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步失败: {msg}")
|
||||
self.message.put(msg, title="CookieCloud同步失败", role="system")
|
||||
return False, msg
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
@@ -272,7 +303,7 @@ class SiteChain(ChainBase):
|
||||
if _fail_count > 0:
|
||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||
if manual:
|
||||
self.message.put(f"CookieCloud同步成功, {ret_msg}")
|
||||
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
return True, ret_msg
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import random
|
||||
import time
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
from typing import Dict, List, Optional, Union, Tuple
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -15,10 +16,12 @@ from app.core.event import eventmanager, Event, EventManager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.subscribehistory_oper import SubscribeHistoryOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import NotExistMediaInfo, Notification
|
||||
@@ -36,11 +39,13 @@ class SubscribeChain(ChainBase):
|
||||
self.searchchain = SearchChain()
|
||||
self.subscribeoper = SubscribeOper()
|
||||
self.subscribehistoryoper = SubscribeHistoryOper()
|
||||
self.subscribehelper = SubscribeHelper()
|
||||
self.torrentschain = TorrentsChain()
|
||||
self.mediachain = MediaChain()
|
||||
self.message = MessageHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
self.siteoper = SiteOper()
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -122,6 +127,9 @@ class SubscribeChain(ChainBase):
|
||||
kwargs.update({
|
||||
'lack_episode': kwargs.get('total_episode')
|
||||
})
|
||||
else:
|
||||
# 避免season为0的问题
|
||||
season = None
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 合并信息
|
||||
@@ -136,7 +144,7 @@ class SubscribeChain(ChainBase):
|
||||
'effect': self.__get_default_subscribe_config(mediainfo.type, "effect"),
|
||||
'include': self.__get_default_subscribe_config(mediainfo.type, "include"),
|
||||
'exclude': self.__get_default_subscribe_config(mediainfo.type, "exclude"),
|
||||
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version"),
|
||||
'best_version': self.__get_default_subscribe_config(mediainfo.type, "best_version") if not kwargs.get("best_version") else kwargs.get("best_version"),
|
||||
'search_imdbid': self.__get_default_subscribe_config(mediainfo.type, "search_imdbid"),
|
||||
'sites': self.__get_default_subscribe_config(mediainfo.type, "sites") or None,
|
||||
'save_path': self.__get_default_subscribe_config(mediainfo.type, "save_path"),
|
||||
@@ -153,6 +161,7 @@ class SubscribeChain(ChainBase):
|
||||
text=f"{err_msg}",
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
return None, err_msg
|
||||
elif message:
|
||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
||||
if username:
|
||||
@@ -164,12 +173,28 @@ class SubscribeChain(ChainBase):
|
||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||
text=text,
|
||||
image=mediainfo.get_message_image()))
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeAdded, {
|
||||
"subscribe_id": sid,
|
||||
"username": username,
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeAdded, {
|
||||
"subscribe_id": sid,
|
||||
"username": username,
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 统计订阅
|
||||
self.subscribehelper.sub_reg_async({
|
||||
"name": title,
|
||||
"year": year,
|
||||
"type": metainfo.type.value,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
"tvdbid": mediainfo.tvdb_id,
|
||||
"doubanid": mediainfo.douban_id,
|
||||
"bangumiid": mediainfo.bangumi_id,
|
||||
"season": metainfo.begin_season,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
"vote": mediainfo.vote_average,
|
||||
"description": mediainfo.overview
|
||||
})
|
||||
# 返回结果
|
||||
return sid, ""
|
||||
|
||||
@@ -356,9 +381,9 @@ class SubscribeChain(ChainBase):
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if sid:
|
||||
self.message.put(f'订阅 {subscribes[0].name} 搜索完成!')
|
||||
self.message.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
|
||||
else:
|
||||
self.message.put('所有订阅搜索完成!')
|
||||
self.message.put('所有订阅搜索完成!', title="订阅搜索", role="system")
|
||||
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaInfo,
|
||||
mediainfo: MediaInfo, downloads: List[Context]):
|
||||
@@ -372,20 +397,8 @@ class SubscribeChain(ChainBase):
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
if priority == 100:
|
||||
logger.info(f'{mediainfo.title_year} 洗版完成,删除订阅')
|
||||
# 新增订阅历史
|
||||
self.subscribehistoryoper.add(**subscribe.to_dict())
|
||||
# 删除订阅
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已洗版完成',
|
||||
image=mediainfo.get_message_image()))
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 洗版完成
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo, bestversion=True)
|
||||
else:
|
||||
# 正在洗版,更新资源优先级
|
||||
logger.info(f'{mediainfo.title_year} 正在洗版,更新资源优先级为 {priority}')
|
||||
@@ -409,21 +422,8 @@ class SubscribeChain(ChainBase):
|
||||
if ((no_lefts and meta.type == MediaType.TV)
|
||||
or (downloads and meta.type == MediaType.MOVIE)
|
||||
or force):
|
||||
# 全部下载完成
|
||||
logger.info(f'{mediainfo.title_year} 完成订阅')
|
||||
# 新增订阅历史
|
||||
self.subscribehistoryoper.add(**subscribe.to_dict())
|
||||
# 删除订阅
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 完成订阅
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
elif downloads and meta.type == MediaType.TV:
|
||||
# 电视剧更新已下载集数
|
||||
self.__update_subscribe_note(subscribe=subscribe, downloads=downloads)
|
||||
@@ -462,7 +462,10 @@ class SubscribeChain(ChainBase):
|
||||
获取订阅中涉及的站点清单
|
||||
"""
|
||||
if subscribe.sites:
|
||||
return json.loads(subscribe.sites)
|
||||
try:
|
||||
return json.loads(subscribe.sites)
|
||||
except JSONDecodeError:
|
||||
return []
|
||||
# 默认站点
|
||||
return self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
|
||||
@@ -501,6 +504,7 @@ class SubscribeChain(ChainBase):
|
||||
"tv_size": default_rule.get("tv_size"),
|
||||
"movie_size": default_rule.get("movie_size"),
|
||||
"min_seeders": default_rule.get("min_seeders"),
|
||||
"min_seeders_time": default_rule.get("min_seeders_time"),
|
||||
}
|
||||
|
||||
def match(self, torrents: Dict[str, List[Context]]):
|
||||
@@ -510,6 +514,8 @@ class SubscribeChain(ChainBase):
|
||||
if not torrents:
|
||||
logger.warn('没有缓存资源,无法匹配订阅')
|
||||
return
|
||||
# 记录重新识别过的种子
|
||||
_recognize_cached = []
|
||||
# 所有订阅
|
||||
subscribes = self.subscribeoper.list('R')
|
||||
# 遍历订阅
|
||||
@@ -521,6 +527,15 @@ class SubscribeChain(ChainBase):
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season or None
|
||||
meta.type = MediaType(subscribe.type)
|
||||
# 订阅的站点域名列表
|
||||
domains = []
|
||||
if subscribe.sites:
|
||||
try:
|
||||
siteids = json.loads(subscribe.sites)
|
||||
if siteids:
|
||||
domains = self.siteoper.get_domains_by_ids(siteids)
|
||||
except JSONDecodeError:
|
||||
pass
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
@@ -589,15 +604,38 @@ class SubscribeChain(ChainBase):
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
logger.info(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
if domains and domain not in domains:
|
||||
continue
|
||||
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
for context in contexts:
|
||||
# 检查是否匹配
|
||||
torrent_meta = context.meta_info
|
||||
torrent_mediainfo = context.media_info
|
||||
torrent_info = context.torrent_info
|
||||
# 如果识别了媒体信息,则比对TMDBID和类型
|
||||
if torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id:
|
||||
# 直接比对媒体信息
|
||||
|
||||
# 先判断是否有没识别的种子
|
||||
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
_cache_key = f"{torrent_info.title}_{torrent_info.description}"
|
||||
if _cache_key not in _recognize_cached:
|
||||
_recognize_cached.append(_cache_key)
|
||||
logger.info(f'{torrent_info.site_name} - {torrent_info.title} 订阅缓存为未识别状态,尝试重新识别...')
|
||||
# 重新识别(不使用缓存)
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta, cache=False)
|
||||
if not torrent_mediainfo:
|
||||
logger.warn(f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 通过标题匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
# 更新缓存
|
||||
torrent_mediainfo = mediainfo
|
||||
context.media_info = mediainfo
|
||||
else:
|
||||
continue
|
||||
|
||||
# 直接比对媒体信息
|
||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||
if torrent_mediainfo.type != mediainfo.type:
|
||||
continue
|
||||
if torrent_mediainfo.tmdb_id \
|
||||
@@ -609,22 +647,8 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过媒体信ID匹配到资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
else:
|
||||
# 没有torrent_mediainfo媒体信息,按标题匹配
|
||||
manual_match = False
|
||||
# 比对词条指定的tmdbid
|
||||
if torrent_meta.tmdbid or torrent_meta.doubanid:
|
||||
if torrent_meta.tmdbid and torrent_meta.tmdbid != mediainfo.tmdb_id:
|
||||
continue
|
||||
if torrent_meta.doubanid and torrent_meta.doubanid != mediainfo.douban_id:
|
||||
continue
|
||||
manual_match = True
|
||||
if not manual_match:
|
||||
# 没有指定tmdbid,按标题匹配
|
||||
if not self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info,
|
||||
logerror=False):
|
||||
continue
|
||||
continue
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
@@ -636,28 +660,28 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo=torrent_mediainfo)
|
||||
if result is not None and not result:
|
||||
# 不符合过滤规则
|
||||
logger.info(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||
logger.debug(f"{torrent_info.title} 不匹配当前过滤规则")
|
||||
continue
|
||||
|
||||
# 不在订阅站点范围的不处理
|
||||
sub_sites = self.get_sub_sites(subscribe)
|
||||
if sub_sites and torrent_info.site not in sub_sites:
|
||||
logger.info(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||
logger.debug(f"{torrent_info.site_name} - {torrent_info.title} 不符合订阅站点要求")
|
||||
continue
|
||||
|
||||
# 如果是电视剧
|
||||
if torrent_mediainfo.type == MediaType.TV:
|
||||
# 有多季的不要
|
||||
if len(torrent_meta.season_list) > 1:
|
||||
logger.info(f'{torrent_info.title} 有多季,不处理')
|
||||
logger.debug(f'{torrent_info.title} 有多季,不处理')
|
||||
continue
|
||||
# 比对季
|
||||
if torrent_meta.begin_season:
|
||||
if meta.begin_season != torrent_meta.begin_season:
|
||||
logger.info(f'{torrent_info.title} 季不匹配')
|
||||
logger.debug(f'{torrent_info.title} 季不匹配')
|
||||
continue
|
||||
elif meta.begin_season != 1:
|
||||
logger.info(f'{torrent_info.title} 季不匹配')
|
||||
logger.debug(f'{torrent_info.title} 季不匹配')
|
||||
continue
|
||||
# 非洗版
|
||||
if not subscribe.best_version:
|
||||
@@ -672,7 +696,7 @@ class SubscribeChain(ChainBase):
|
||||
not set(no_exists_info.episodes).intersection(
|
||||
set(torrent_meta.episode_list)
|
||||
):
|
||||
logger.info(
|
||||
logger.debug(
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
|
||||
)
|
||||
continue
|
||||
@@ -684,7 +708,7 @@ class SubscribeChain(ChainBase):
|
||||
# 洗版时,非整季不要
|
||||
if meta.type == MediaType.TV:
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
logger.debug(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
|
||||
# 过滤规则
|
||||
@@ -782,7 +806,10 @@ class SubscribeChain(ChainBase):
|
||||
return
|
||||
note = []
|
||||
if subscribe.note:
|
||||
note = json.loads(subscribe.note)
|
||||
try:
|
||||
note = json.loads(subscribe.note)
|
||||
except JSONDecodeError:
|
||||
note = []
|
||||
for context in downloads:
|
||||
meta = context.meta_info
|
||||
mediainfo = context.media_info
|
||||
@@ -813,7 +840,10 @@ class SubscribeChain(ChainBase):
|
||||
return False
|
||||
if not episodes:
|
||||
return False
|
||||
note = json.loads(subscribe.note)
|
||||
try:
|
||||
note = json.loads(subscribe.note)
|
||||
except JSONDecodeError:
|
||||
return False
|
||||
if set(episodes).issubset(set(note)):
|
||||
return True
|
||||
return False
|
||||
@@ -850,6 +880,36 @@ class SubscribeChain(ChainBase):
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
|
||||
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo,
|
||||
meta: MetaBase, bestversion: bool = False):
|
||||
"""
|
||||
完成订阅
|
||||
"""
|
||||
# 完成订阅
|
||||
msgstr = "订阅"
|
||||
if bestversion:
|
||||
msgstr = "洗版"
|
||||
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
|
||||
# 新增订阅历史
|
||||
self.subscribehistoryoper.add(**subscribe.to_dict())
|
||||
# 删除订阅
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||
image=mediainfo.get_message_image()))
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
"subscribe_info": subscribe.to_dict(),
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 统计订阅
|
||||
self.subscribehelper.sub_done_async({
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"doubanid": mediainfo.douban_id
|
||||
})
|
||||
|
||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
查询订阅并发送消息
|
||||
@@ -898,6 +958,11 @@ class SubscribeChain(ChainBase):
|
||||
return
|
||||
# 删除订阅
|
||||
self.subscribeoper.delete(subscribe_id)
|
||||
# 统计订阅
|
||||
self.subscribehelper.sub_done_async({
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid
|
||||
})
|
||||
# 重新发送消息
|
||||
self.remote_list(channel, userid)
|
||||
|
||||
@@ -990,7 +1055,10 @@ class SubscribeChain(ChainBase):
|
||||
for subscribe in self.subscribeoper.list():
|
||||
if not subscribe.sites:
|
||||
continue
|
||||
sites = json.loads(subscribe.sites) or []
|
||||
try:
|
||||
sites = json.loads(subscribe.sites)
|
||||
except JSONDecodeError:
|
||||
sites = []
|
||||
if site_id not in sites:
|
||||
continue
|
||||
sites.remove(site_id)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
@@ -40,19 +41,31 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
}, self._restart_file)
|
||||
SystemUtils.restart()
|
||||
|
||||
def __get_version_message(self) -> str:
|
||||
"""
|
||||
获取版本信息文本
|
||||
"""
|
||||
server_release_version = self.__get_server_release_version()
|
||||
front_release_version = self.__get_front_release_version()
|
||||
server_local_version = self.get_server_local_version()
|
||||
front_local_version = self.get_frontend_version()
|
||||
if server_release_version == server_local_version:
|
||||
title = f"当前后端版本:{server_local_version},已是最新版本\n"
|
||||
else:
|
||||
title = f"当前后端版本:{server_local_version},远程版本:{server_release_version}\n"
|
||||
if front_release_version == front_local_version:
|
||||
title += f"当前前端版本:{front_local_version},已是最新版本"
|
||||
else:
|
||||
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
|
||||
return title
|
||||
|
||||
def version(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
查看当前版本、远程版本
|
||||
"""
|
||||
release_version = self.__get_release_version()
|
||||
local_version = self.get_local_version()
|
||||
if release_version == local_version:
|
||||
title = f"当前版本:{local_version},已是最新版本"
|
||||
else:
|
||||
title = f"当前版本:{local_version},远程版本:{release_version}"
|
||||
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=title, userid=userid))
|
||||
title=self.__get_version_message(),
|
||||
userid=userid))
|
||||
|
||||
def restart_finish(self):
|
||||
"""
|
||||
@@ -71,33 +84,50 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
userid = restart_channel.get('userid')
|
||||
|
||||
# 版本号
|
||||
release_version = self.__get_release_version()
|
||||
local_version = self.get_local_version()
|
||||
if release_version == local_version:
|
||||
title = f"当前版本:{local_version}"
|
||||
else:
|
||||
title = f"当前版本:{local_version},远程版本:{release_version}"
|
||||
title = self.__get_version_message()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"系统已重启完成!{title}",
|
||||
title=f"系统已重启完成!\n{title}",
|
||||
userid=userid))
|
||||
self.remove_cache(self._restart_file)
|
||||
|
||||
@staticmethod
|
||||
def __get_release_version():
|
||||
def __get_server_release_version():
|
||||
"""
|
||||
获取最新版本
|
||||
获取后端最新版本
|
||||
"""
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
version = f"{ver_json['tag_name']}"
|
||||
return version
|
||||
else:
|
||||
try:
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
version = f"{ver_json['tag_name']}"
|
||||
return version
|
||||
else:
|
||||
return None
|
||||
except Exception as err:
|
||||
logger.error(f"获取后端最新版本失败:{str(err)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_local_version():
|
||||
def __get_front_release_version():
|
||||
"""
|
||||
获取前端最新版本
|
||||
"""
|
||||
try:
|
||||
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
|
||||
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest")
|
||||
if version_res:
|
||||
ver_json = version_res.json()
|
||||
version = f"{ver_json['tag_name']}"
|
||||
return version
|
||||
else:
|
||||
return None
|
||||
except Exception as err:
|
||||
logger.error(f"获取前端最新版本失败:{str(err)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_server_local_version():
|
||||
"""
|
||||
查看当前版本
|
||||
"""
|
||||
@@ -117,3 +147,20 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
return None
|
||||
except Exception as err:
|
||||
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
|
||||
@staticmethod
|
||||
def get_frontend_version():
|
||||
"""
|
||||
获取前端版本
|
||||
"""
|
||||
version_file = Path(settings.FRONTEND_PATH) / "version.txt"
|
||||
if version_file.exists():
|
||||
try:
|
||||
with open(version_file, 'r') as f:
|
||||
version = str(f.read()).strip()
|
||||
return version
|
||||
except Exception as err:
|
||||
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
|
||||
else:
|
||||
logger.warn("未找到前端版本文件,请正确设置 FRONTEND_PATH")
|
||||
return None
|
||||
|
||||
@@ -6,6 +6,7 @@ from cachetools import cached, TTLCache
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -16,7 +17,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
||||
with_original_language: str, page: int = 1) -> Optional[List[dict]]:
|
||||
with_original_language: str, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
:param mtype: 媒体类型
|
||||
:param sort_by: 排序方式
|
||||
@@ -25,21 +26,17 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
:param page: 页码
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
return self.run_module("tmdb_discover", mtype=mtype,
|
||||
sort_by=sort_by, with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
|
||||
def tmdb_trending(self, page: int = 1) -> Optional[List[dict]]:
|
||||
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
:param page: 第几页
|
||||
:return: TMDB信息列表
|
||||
"""
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
return None
|
||||
return self.run_module("tmdb_trending", page=page)
|
||||
|
||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
@@ -57,35 +54,35 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season)
|
||||
|
||||
def movie_similar(self, tmdbid: int) -> List[dict]:
|
||||
def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询类似电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tmdb_movie_similar", tmdbid=tmdbid)
|
||||
|
||||
def tv_similar(self, tmdbid: int) -> List[dict]:
|
||||
def tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tmdb_tv_similar", tmdbid=tmdbid)
|
||||
|
||||
def movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||
def movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tmdb_movie_recommend", tmdbid=tmdbid)
|
||||
|
||||
def tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||
def tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
|
||||
|
||||
def movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
def movie_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电影演职人员
|
||||
:param tmdbid: TMDBID
|
||||
@@ -93,7 +90,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
def tv_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职人员
|
||||
:param tmdbid: TMDBID
|
||||
@@ -101,14 +98,14 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_tv_credits", tmdbid=tmdbid, page=page)
|
||||
|
||||
def person_detail(self, person_id: int) -> dict:
|
||||
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询演职员详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.run_module("tmdb_person_detail", person_id=person_id)
|
||||
|
||||
def person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||
def person_credits(self, person_id: int, page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
@@ -117,7 +114,7 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
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):
|
||||
def get_random_wallpager(self) -> Optional[str]:
|
||||
"""
|
||||
获取随机壁纸,缓存1个小时
|
||||
"""
|
||||
@@ -126,6 +123,6 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
# 随机一个电影
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
|
||||
if info and info.backdrop_path:
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.backdrop_path}"
|
||||
return None
|
||||
|
||||
@@ -153,12 +153,15 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
# 需要刷新的站点domain
|
||||
domains = []
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
# 未开启的站点不刷新
|
||||
if sites and indexer.get("id") not in sites:
|
||||
continue
|
||||
domain = StringUtils.get_url_domain(indexer.get("domain"))
|
||||
domains.append(domain)
|
||||
if stype == "spider":
|
||||
# 刷新首页种子
|
||||
torrents: List[TorrentInfo] = self.browse(domain=domain)
|
||||
@@ -219,7 +222,9 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
self.save_cache(torrents_cache, self._rss_file)
|
||||
|
||||
# 返回
|
||||
# 去除不在站点范围内的缓存种子
|
||||
if sites and torrents_cache:
|
||||
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
|
||||
return torrents_cache
|
||||
|
||||
def __renew_rss_url(self, domain: str, site: dict):
|
||||
|
||||
@@ -16,7 +16,9 @@ from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.format import FormatParser
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat
|
||||
@@ -41,6 +43,8 @@ class TransferChain(ChainBase):
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
|
||||
def process(self) -> bool:
|
||||
"""
|
||||
@@ -86,7 +90,8 @@ class TransferChain(ChainBase):
|
||||
mediainfo: MediaInfo = None, download_hash: str = None,
|
||||
target: Path = None, transfer_type: str = None,
|
||||
season: int = None, epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0, force: bool = False) -> Tuple[bool, str]:
|
||||
min_filesize: int = 0, scrape: bool = None,
|
||||
force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
执行一个复杂目录的转移操作
|
||||
:param path: 待转移目录或文件
|
||||
@@ -98,6 +103,7 @@ class TransferChain(ChainBase):
|
||||
:param season: 季
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param scrape: 是否刮削元数据
|
||||
:param force: 是否强制转移
|
||||
返回:成功标识,错误信息
|
||||
"""
|
||||
@@ -300,7 +306,8 @@ class TransferChain(ChainBase):
|
||||
path=file_path,
|
||||
transfer_type=transfer_type,
|
||||
target=target,
|
||||
episodes_info=episodes_info)
|
||||
episodes_info=episodes_info,
|
||||
scrape=scrape)
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return False, "文件转移模块运行失败"
|
||||
@@ -347,20 +354,32 @@ class TransferChain(ChainBase):
|
||||
transfers[mkey].file_list_new.extend(transferinfo.file_list_new)
|
||||
transfers[mkey].fail_list.extend(transferinfo.fail_list)
|
||||
|
||||
# 硬链接检查
|
||||
temp_transfer_type = transfer_type
|
||||
if transfer_type == "link":
|
||||
if not SystemUtils.is_same_disk(file_path, transferinfo.target_path):
|
||||
logger.warn(
|
||||
f"{file_path} 与 {transferinfo.target_path} 不在同一磁盘/存储空间/映射目录,未能硬链接,请检查存储空间占用和整理耗时,确认是否为复制")
|
||||
self.messagehelper.put(
|
||||
f"{file_path} 与 {transferinfo.target_path} 不在同一磁盘/存储空间/映射目录,疑似硬链接失败,请检查是否为复制",
|
||||
title="硬链接失败",
|
||||
role="system")
|
||||
temp_transfer_type = "copy"
|
||||
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=transfer_type,
|
||||
mode=temp_transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
# 刮削单个文件
|
||||
if settings.SCRAP_METADATA:
|
||||
if transferinfo.need_scrape:
|
||||
self.scrape_metadata(path=transferinfo.target_path,
|
||||
mediainfo=file_mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
transfer_type=temp_transfer_type,
|
||||
metainfo=file_meta)
|
||||
# 更新进度
|
||||
processed_num += 1
|
||||
@@ -487,24 +506,6 @@ class TransferChain(ChainBase):
|
||||
text=errmsg, userid=userid))
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def get_root_path(path: str, type_name: str, category: str) -> Optional[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]:
|
||||
"""
|
||||
@@ -522,7 +523,6 @@ class TransferChain(ChainBase):
|
||||
src_path = Path(history.src)
|
||||
if not src_path.exists():
|
||||
return False, f"源目录不存在:{src_path}"
|
||||
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,
|
||||
@@ -545,7 +545,6 @@ class TransferChain(ChainBase):
|
||||
state, errmsg = self.do_transfer(path=src_path,
|
||||
mediainfo=mediainfo,
|
||||
download_hash=history.download_hash,
|
||||
target=dest_path,
|
||||
force=True)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -555,32 +554,36 @@ class TransferChain(ChainBase):
|
||||
def manual_transfer(self, in_path: Path,
|
||||
target: Path = None,
|
||||
tmdbid: int = None,
|
||||
doubanid: str = None,
|
||||
mtype: MediaType = None,
|
||||
season: int = None,
|
||||
transfer_type: str = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0,
|
||||
scrape: bool = None,
|
||||
force: bool = False) -> Tuple[bool, Union[str, list]]:
|
||||
"""
|
||||
手动转移,支持复杂条件,带进度显示
|
||||
:param in_path: 源文件路径
|
||||
:param target: 目标路径
|
||||
:param tmdbid: TMDB ID
|
||||
:param doubanid: 豆瓣ID
|
||||
:param mtype: 媒体类型
|
||||
:param season: 季度
|
||||
:param transfer_type: 转移类型
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param scrape: 是否刮削元数据
|
||||
:param force: 是否强制转移
|
||||
"""
|
||||
logger.info(f"手动转移:{in_path} ...")
|
||||
|
||||
if tmdbid:
|
||||
if tmdbid or doubanid:
|
||||
# 有输入TMDBID时单个识别
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, mtype=mtype)
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
|
||||
if not mediainfo:
|
||||
return False, f"媒体信息识别失败,tmdbid: {tmdbid}, type: {mtype.value}"
|
||||
return False, f"媒体信息识别失败,tmdbid:{tmdbid},doubanid:{doubanid},type: {mtype.value}"
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
self.progress.update(value=0,
|
||||
@@ -595,6 +598,7 @@ class TransferChain(ChainBase):
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize,
|
||||
scrape=scrape,
|
||||
force=force
|
||||
)
|
||||
if not state:
|
||||
@@ -637,8 +641,7 @@ class TransferChain(ChainBase):
|
||||
mtype=NotificationType.Organize,
|
||||
title=msg_title, text=msg_str, image=mediainfo.get_message_image()))
|
||||
|
||||
@staticmethod
|
||||
def delete_files(path: Path) -> Tuple[bool, str]:
|
||||
def delete_files(self, path: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
删除转移后的文件以及空目录
|
||||
:param path: 文件路径
|
||||
@@ -668,26 +671,31 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级
|
||||
if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT):
|
||||
# 媒体库二级分类根路径
|
||||
library_root_names = [
|
||||
settings.LIBRARY_MOVIE_NAME or '电影',
|
||||
settings.LIBRARY_TV_NAME or '电视剧',
|
||||
settings.LIBRARY_ANIME_NAME or '动漫',
|
||||
]
|
||||
|
||||
# 所有媒体库根目录的名称
|
||||
library_roots = self.directoryhelper.get_library_dirs()
|
||||
library_root_names = [Path(library_root.path).name for library_root in library_roots if library_root.path]
|
||||
# 所有二级分类的名称
|
||||
category_names = []
|
||||
category_conf = self.media_category()
|
||||
if category_conf:
|
||||
category_names += list(category_conf.keys())
|
||||
for cats in category_conf.values():
|
||||
category_names += cats
|
||||
# 判断父目录是否为空, 为空则删除
|
||||
for parent_path in path.parents:
|
||||
# 遍历父目录到媒体库二级分类根路径
|
||||
if str(parent_path.name) in library_root_names:
|
||||
if parent_path.name in library_root_names:
|
||||
break
|
||||
if parent_path.name in category_names:
|
||||
continue
|
||||
if str(parent_path.parent) != str(path.root):
|
||||
# 父目录非根目录,才删除父目录
|
||||
if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT):
|
||||
# 当前路径下没有媒体文件则删除
|
||||
try:
|
||||
shutil.rmtree(parent_path)
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
except Exception as e:
|
||||
logger.error(f"删除目录 {parent_path} 失败:{str(e)}")
|
||||
return False, f"删除目录 {parent_path} 失败:{str(e)}"
|
||||
logger.warn(f"目录 {parent_path} 已删除")
|
||||
return True, ""
|
||||
|
||||
@@ -10,9 +10,11 @@ from app.chain.site import SiteChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.system import SystemChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event as ManagerEvent
|
||||
from app.core.event import eventmanager, EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
@@ -50,6 +52,8 @@ class Command(metaclass=Singleton):
|
||||
self.chain = CommandChian()
|
||||
# 定时服务管理
|
||||
self.scheduler = Scheduler()
|
||||
# 消息管理器
|
||||
self.messagehelper = MessageHelper()
|
||||
# 线程管理器
|
||||
self.threader = ThreadHelper()
|
||||
# 内置命令
|
||||
@@ -165,7 +169,8 @@ class Command(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
# 广播注册命令菜单
|
||||
self.chain.register_commands(commands=self.get_commands())
|
||||
if not settings.DEV:
|
||||
self.chain.register_commands(commands=self.get_commands())
|
||||
# 消息处理线程
|
||||
self._thread = Thread(target=self.__run)
|
||||
# 启动事件处理线程
|
||||
@@ -182,9 +187,9 @@ class Command(metaclass=Singleton):
|
||||
if event:
|
||||
logger.info(f"处理事件:{event.event_type} - {handlers}")
|
||||
for handler in handlers:
|
||||
names = handler.__qualname__.split(".")
|
||||
[class_name, method_name] = names
|
||||
try:
|
||||
names = handler.__qualname__.split(".")
|
||||
[class_name, method_name] = names
|
||||
if class_name in self.pluginmanager.get_plugin_ids():
|
||||
# 插件事件
|
||||
self.threader.submit(
|
||||
@@ -196,10 +201,15 @@ class Command(metaclass=Singleton):
|
||||
# 检查全局变量中是否存在
|
||||
if class_name not in globals():
|
||||
# 导入模块,除了插件和Command本身,只有chain能响应事件
|
||||
module = importlib.import_module(
|
||||
f"app.chain.{class_name[:-5].lower()}"
|
||||
)
|
||||
class_obj = getattr(module, class_name)()
|
||||
try:
|
||||
module = importlib.import_module(
|
||||
f"app.chain.{class_name[:-5].lower()}"
|
||||
)
|
||||
class_obj = getattr(module, class_name)()
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
continue
|
||||
|
||||
else:
|
||||
# 通过类名创建类实例
|
||||
class_obj = globals()[class_name]()
|
||||
@@ -211,6 +221,9 @@ class Command(metaclass=Singleton):
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{event.event_type} 事件处理出错",
|
||||
message=f"{class_name}.{method_name}:{str(e)}",
|
||||
role="system")
|
||||
|
||||
def __run_command(self, command: Dict[str, any],
|
||||
data_str: str = "",
|
||||
@@ -266,9 +279,11 @@ class Command(metaclass=Singleton):
|
||||
"""
|
||||
停止事件处理线程
|
||||
"""
|
||||
logger.info("正在停止事件处理...")
|
||||
self._event.set()
|
||||
try:
|
||||
self._thread.join()
|
||||
logger.info("事件处理停止完成")
|
||||
except Exception as e:
|
||||
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
@@ -319,6 +334,9 @@ class Command(metaclass=Singleton):
|
||||
logger.info(f"{command.get('description')} 执行完成")
|
||||
except Exception as err:
|
||||
logger.error(f"执行命令 {cmd} 出错:{str(err)} - {traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"执行命令 {cmd} 出错",
|
||||
message=str(err),
|
||||
role="system")
|
||||
|
||||
@staticmethod
|
||||
def send_plugin_event(etype: EventType, data: dict) -> None:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseSettings, validator
|
||||
|
||||
@@ -9,10 +10,15 @@ from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
系统配置类
|
||||
"""
|
||||
# 项目名称
|
||||
PROJECT_NAME = "MoviePilot"
|
||||
# API路径
|
||||
API_V1_STR: str = "/api/v1"
|
||||
# 前端资源路径
|
||||
FRONTEND_PATH: str = "/public"
|
||||
# 密钥
|
||||
SECRET_KEY: str = secrets.token_urlsafe(32)
|
||||
# 允许的域名
|
||||
@@ -31,6 +37,8 @@ class Settings(BaseSettings):
|
||||
DEBUG: bool = False
|
||||
# 是否开发模式
|
||||
DEV: bool = False
|
||||
# 是否开启插件热加载
|
||||
PLUGIN_AUTO_RELOAD: bool = False
|
||||
# 配置文件目录
|
||||
CONFIG_DIR: Optional[str] = None
|
||||
# 超级管理员
|
||||
@@ -41,12 +49,12 @@ class Settings(BaseSettings):
|
||||
WALLPAPER: str = "tmdb"
|
||||
# 网络代理 IP:PORT
|
||||
PROXY_HOST: Optional[str] = None
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||
# 媒体识别来源 themoviedb/douban
|
||||
RECOGNIZE_SOURCE: str = "themoviedb"
|
||||
# 刮削来源 themoviedb/douban
|
||||
SCRAP_SOURCE: str = "themoviedb"
|
||||
# 刮削入库的媒体文件
|
||||
SCRAP_METADATA: bool = True
|
||||
# 新增已入库媒体是否跟随TMDB信息变化
|
||||
SCRAP_FOLLOW_TMDB: bool = True
|
||||
# TMDB图片地址
|
||||
@@ -149,16 +157,6 @@ class Settings(BaseSettings):
|
||||
TR_PASSWORD: Optional[str] = None
|
||||
# 种子标签
|
||||
TORRENT_TAG: str = "MOVIEPILOT"
|
||||
# 下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_PATH: Optional[str] = None
|
||||
# 电影下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_MOVIE_PATH: Optional[str] = None
|
||||
# 电视剧下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_TV_PATH: Optional[str] = None
|
||||
# 动漫下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_ANIME_PATH: Optional[str] = None
|
||||
# 下载目录二级分类
|
||||
DOWNLOAD_CATEGORY: bool = False
|
||||
# 下载站点字幕
|
||||
DOWNLOAD_SUBTITLE: bool = True
|
||||
# 媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
@@ -187,6 +185,8 @@ class Settings(BaseSettings):
|
||||
PLEX_TOKEN: Optional[str] = None
|
||||
# 转移方式 link/copy/move/softlink
|
||||
TRANSFER_TYPE: str = "copy"
|
||||
# 是否同盘优先
|
||||
TRANSFER_SAME_DISK: bool = True
|
||||
# CookieCloud是否启动本地服务
|
||||
COOKIECLOUD_ENABLE_LOCAL: Optional[bool] = False
|
||||
# CookieCloud服务器地址
|
||||
@@ -201,16 +201,6 @@ class Settings(BaseSettings):
|
||||
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: Optional[str] = None
|
||||
# 电影媒体库目录名
|
||||
LIBRARY_MOVIE_NAME: str = "电影"
|
||||
# 电视剧媒体库目录名
|
||||
LIBRARY_TV_NAME: str = "电视剧"
|
||||
# 动漫媒体库目录名,不设置时使用电视剧目录
|
||||
LIBRARY_ANIME_NAME: Optional[str] = None
|
||||
# 二级分类
|
||||
LIBRARY_CATEGORY: bool = True
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS = [16]
|
||||
# 电影重命名格式
|
||||
@@ -227,7 +217,7 @@ class Settings(BaseSettings):
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 插件市场仓库地址,多个地址使用,分隔,地址以/结尾
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
PLUGIN_MARKET: str = "https://github.com/jxxghp/MoviePilot-Plugins,https://github.com/thsrite/MoviePilot-Plugins,https://github.com/honue/MoviePilot-Plugins,https://github.com/InfinityPacer/MoviePilot-Plugins"
|
||||
# Github token,提高请求api限流阈值 ghp_****
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
@@ -238,6 +228,35 @@ class Settings(BaseSettings):
|
||||
DOH_ENABLE: bool = True
|
||||
# 搜索多个名称
|
||||
SEARCH_MULTIPLE_NAME: bool = False
|
||||
# 订阅数据共享
|
||||
SUBSCRIBE_STATISTIC_SHARE: bool = True
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
# 服务器地址,对应 https://github.com/jxxghp/MoviePilot-Server 项目
|
||||
MP_SERVER_HOST: str = "https://movie-pilot.org"
|
||||
|
||||
# 【已弃用】刮削入库的媒体文件
|
||||
SCRAP_METADATA: bool = True
|
||||
# 【已弃用】下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_PATH: Optional[str] = None
|
||||
# 【已弃用】电影下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_MOVIE_PATH: Optional[str] = None
|
||||
# 【已弃用】电视剧下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_TV_PATH: Optional[str] = None
|
||||
# 【已弃用】动漫下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_ANIME_PATH: Optional[str] = None
|
||||
# 【已弃用】下载目录二级分类
|
||||
DOWNLOAD_CATEGORY: bool = False
|
||||
# 【已弃用】媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH: Optional[str] = None
|
||||
# 【已弃用】电影媒体库目录名
|
||||
LIBRARY_MOVIE_NAME: str = "电影"
|
||||
# 【已弃用】电视剧媒体库目录名
|
||||
LIBRARY_TV_NAME: str = "电视剧"
|
||||
# 【已弃用】动漫媒体库目录名,不设置时使用电视剧目录
|
||||
LIBRARY_ANIME_NAME: Optional[str] = None
|
||||
# 【已弃用】二级分类
|
||||
LIBRARY_CATEGORY: bool = True
|
||||
|
||||
@validator("SUBSCRIBE_RSS_INTERVAL",
|
||||
"COOKIECLOUD_INTERVAL",
|
||||
@@ -322,48 +341,6 @@ class Settings(BaseSettings):
|
||||
"server": self.PROXY_HOST
|
||||
}
|
||||
|
||||
@property
|
||||
def LIBRARY_PATHS(self) -> List[Path]:
|
||||
if self.LIBRARY_PATH:
|
||||
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
|
||||
return [self.CONFIG_PATH / "library"]
|
||||
|
||||
@property
|
||||
def SAVE_PATH(self) -> Path:
|
||||
"""
|
||||
获取下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_PATH:
|
||||
return Path(self.DOWNLOAD_PATH)
|
||||
return self.CONFIG_PATH / "downloads"
|
||||
|
||||
@property
|
||||
def SAVE_MOVIE_PATH(self) -> Path:
|
||||
"""
|
||||
获取电影下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_MOVIE_PATH:
|
||||
return Path(self.DOWNLOAD_MOVIE_PATH)
|
||||
return self.SAVE_PATH
|
||||
|
||||
@property
|
||||
def SAVE_TV_PATH(self) -> Path:
|
||||
"""
|
||||
获取电视剧下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_TV_PATH:
|
||||
return Path(self.DOWNLOAD_TV_PATH)
|
||||
return self.SAVE_PATH
|
||||
|
||||
@property
|
||||
def SAVE_ANIME_PATH(self) -> Path:
|
||||
"""
|
||||
获取动漫下载保存目录
|
||||
"""
|
||||
if self.DOWNLOAD_ANIME_PATH:
|
||||
return Path(self.DOWNLOAD_ANIME_PATH)
|
||||
return self.SAVE_TV_PATH
|
||||
|
||||
@property
|
||||
def GITHUB_HEADERS(self):
|
||||
"""
|
||||
@@ -415,7 +392,31 @@ class Settings(BaseSettings):
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
class GlobalVar(object):
|
||||
"""
|
||||
全局标识
|
||||
"""
|
||||
# 系统停止事件
|
||||
STOP_EVENT: threading.Event = threading.Event()
|
||||
|
||||
def stop_system(self):
|
||||
"""
|
||||
停止系统
|
||||
"""
|
||||
self.STOP_EVENT.set()
|
||||
|
||||
def is_system_stopped(self):
|
||||
"""
|
||||
是否停止
|
||||
"""
|
||||
return self.STOP_EVENT.is_set()
|
||||
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings(
|
||||
_env_file=Settings().CONFIG_PATH / "app.env",
|
||||
_env_file_encoding="utf-8"
|
||||
)
|
||||
|
||||
# 全局标识
|
||||
global_vars = GlobalVar()
|
||||
|
||||
@@ -133,6 +133,8 @@ class TorrentInfo:
|
||||
|
||||
@dataclass
|
||||
class MediaInfo:
|
||||
# 来源:themoviedb、douban、bangumi
|
||||
source: str = None
|
||||
# 类型 电影、电视剧
|
||||
type: MediaType = None
|
||||
# 媒体标题
|
||||
@@ -355,6 +357,8 @@ class MediaInfo:
|
||||
|
||||
if not info:
|
||||
return
|
||||
# 来源
|
||||
self.source = "themoviedb"
|
||||
# 本体
|
||||
self.tmdb_info = info
|
||||
# 类型
|
||||
@@ -440,6 +444,8 @@ class MediaInfo:
|
||||
"""
|
||||
if not info:
|
||||
return
|
||||
# 来源
|
||||
self.source = "douban"
|
||||
# 本体
|
||||
self.douban_info = info
|
||||
# 豆瓣ID
|
||||
@@ -448,10 +454,16 @@ class MediaInfo:
|
||||
if not self.type:
|
||||
if isinstance(info.get('media_type'), MediaType):
|
||||
self.type = info.get('media_type')
|
||||
elif info.get("type"):
|
||||
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||
elif info.get("subtype"):
|
||||
self.type = MediaType.MOVIE if info.get("subtype") == "movie" else MediaType.TV
|
||||
elif info.get("target_type"):
|
||||
self.type = MediaType.MOVIE if info.get("target_type") == "movie" else MediaType.TV
|
||||
elif info.get("type_name"):
|
||||
self.type = MediaType(info.get("type_name"))
|
||||
elif info.get("uri"):
|
||||
self.type = MediaType.MOVIE if "/movie/" in info.get("uri") else MediaType.TV
|
||||
elif info.get("type") and info.get("type") in ["movie", "tv"]:
|
||||
self.type = MediaType.MOVIE if info.get("type") == "movie" else MediaType.TV
|
||||
# 标题
|
||||
if not self.title:
|
||||
self.title = info.get("title")
|
||||
@@ -464,6 +476,8 @@ class MediaInfo:
|
||||
# 年份
|
||||
if not self.year:
|
||||
self.year = info.get("year")[:4] if info.get("year") else None
|
||||
if not self.year and info.get("extra"):
|
||||
self.year = info.get("extra").get("year")
|
||||
# 识别标题中的季
|
||||
meta = MetaInfo(info.get("title"))
|
||||
# 季
|
||||
@@ -496,12 +510,21 @@ class MediaInfo:
|
||||
if info.get("pic"):
|
||||
self.poster_path = info.get("pic", {}).get("large")
|
||||
if not self.poster_path and info.get("cover_url"):
|
||||
self.poster_path = info.get("cover_url")
|
||||
# imageView2/0/q/80/w/9999/h/120/format/webp -> imageView2/1/w/500/h/750/format/webp
|
||||
self.poster_path = re.sub(r'imageView2/\d/q/\d+/w/\d+/h/\d+/format/webp', 'imageView2/1/w/500/h/750/format/webp', info.get("cover_url"))
|
||||
if not self.poster_path and info.get("cover"):
|
||||
self.poster_path = info.get("cover").get("url")
|
||||
if info.get("cover").get("url"):
|
||||
self.poster_path = info.get("cover").get("url")
|
||||
else:
|
||||
self.poster_path = info.get("cover").get("large", {}).get("url")
|
||||
# 简介
|
||||
if not self.overview:
|
||||
self.overview = info.get("intro") or info.get("card_subtitle") or ""
|
||||
if not self.overview:
|
||||
if info.get("extra", {}).get("info"):
|
||||
extra_info = info.get("extra").get("info")
|
||||
if extra_info:
|
||||
self.overview = ",".join([":".join(item) for item in extra_info])
|
||||
# 从简介中提取年份
|
||||
if self.overview and not self.year:
|
||||
match = re.search(r'\d{4}', self.overview)
|
||||
@@ -553,6 +576,8 @@ class MediaInfo:
|
||||
"""
|
||||
if not info:
|
||||
return
|
||||
# 来源
|
||||
self.source = "bangumi"
|
||||
# 本体
|
||||
self.bangumi_info = info
|
||||
# 豆瓣ID
|
||||
@@ -589,6 +614,8 @@ class MediaInfo:
|
||||
if not self.poster_path:
|
||||
if info.get("images"):
|
||||
self.poster_path = info.get("images", {}).get("large")
|
||||
if not self.poster_path and info.get("image"):
|
||||
self.poster_path = info.get("image")
|
||||
# 简介
|
||||
if not self.overview:
|
||||
self.overview = info.get("summary")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from Pinyin2Hanzi import is_pinyin
|
||||
@@ -27,7 +26,7 @@ class MetaVideo(MetaBase):
|
||||
_source = ""
|
||||
_effect = []
|
||||
# 正则式区
|
||||
_season_re = r"S(\d{2})|^S(\d{1,2})$|S(\d{1,2})E"
|
||||
_season_re = r"S(\d{3})|^S(\d{1,3})$|S(\d{1,3})E"
|
||||
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
|
||||
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||
@@ -57,7 +56,7 @@ class MetaVideo(MetaBase):
|
||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||
"""
|
||||
初始化
|
||||
:param title: 标题
|
||||
:param title: 标题,文件为去掉了后缀
|
||||
:param subtitle: 副标题
|
||||
:param isfile: 是否是文件名
|
||||
"""
|
||||
@@ -68,11 +67,10 @@ class MetaVideo(MetaBase):
|
||||
self._source = ""
|
||||
self._effect = []
|
||||
# 判断是否纯数字命名
|
||||
title_path = Path(title)
|
||||
if title_path.suffix.lower() in settings.RMT_MEDIAEXT \
|
||||
and title_path.stem.isdigit() \
|
||||
and len(title_path.stem) < 5:
|
||||
self.begin_episode = int(title_path.stem)
|
||||
if isfile \
|
||||
and title.isdigit() \
|
||||
and len(title) < 5:
|
||||
self.begin_episode = int(title)
|
||||
self.type = MediaType.TV
|
||||
return
|
||||
# 去掉名称中第1个[]的内容
|
||||
@@ -139,7 +137,7 @@ class MetaVideo(MetaBase):
|
||||
# 处理part
|
||||
if self.part and self.part.upper() == "PART":
|
||||
self.part = None
|
||||
# 没有中文标题时,偿试中描述中获取中文名
|
||||
# 没有中文标题时,尝试中描述中获取中文名
|
||||
if not self.cn_name and self.en_name and self.subtitle:
|
||||
if self.__is_pinyin(self.en_name):
|
||||
# 英文名是拼音
|
||||
|
||||
@@ -6,6 +6,7 @@ import regex as re
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaAnime, MetaVideo, MetaBase
|
||||
from app.core.meta.words import WordsMatcher
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -25,6 +26,8 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
# 判断是否处理文件
|
||||
if title and Path(title).suffix.lower() in settings.RMT_MEDIAEXT:
|
||||
isfile = True
|
||||
# 去掉后缀
|
||||
title = Path(title).stem
|
||||
else:
|
||||
isfile = False
|
||||
# 识别
|
||||
@@ -35,9 +38,12 @@ def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
|
||||
meta.apply_words = apply_words or []
|
||||
# 修正媒体信息
|
||||
if metainfo.get('tmdbid'):
|
||||
meta.tmdbid = metainfo['tmdbid']
|
||||
try:
|
||||
meta.tmdbid = int(metainfo['tmdbid'])
|
||||
except ValueError as _:
|
||||
logger.warn("tmdbid 必须是数字")
|
||||
if metainfo.get('doubanid'):
|
||||
meta.tmdbid = metainfo['doubanid']
|
||||
meta.doubanid = metainfo['doubanid']
|
||||
if metainfo.get('type'):
|
||||
meta.type = metainfo['type']
|
||||
if metainfo.get('begin_season'):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Generator, Optional, Tuple
|
||||
import traceback
|
||||
from typing import Generator, Optional, Tuple, Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.module import ModuleHelper
|
||||
@@ -34,22 +35,31 @@ class ModuleManager(metaclass=Singleton):
|
||||
for module in modules:
|
||||
module_id = module.__name__
|
||||
self._modules[module_id] = module
|
||||
# 生成实例
|
||||
_module = module()
|
||||
# 初始化模块
|
||||
if self.check_setting(_module.init_setting()):
|
||||
# 通过模板开关控制加载
|
||||
_module.init_module()
|
||||
self._running_modules[module_id] = _module
|
||||
logger.info(f"Moudle Loaded:{module_id}")
|
||||
try:
|
||||
# 生成实例
|
||||
_module = module()
|
||||
# 初始化模块
|
||||
if self.check_setting(_module.init_setting()):
|
||||
# 通过模板开关控制加载
|
||||
_module.init_module()
|
||||
self._running_modules[module_id] = _module
|
||||
logger.info(f"Moudle Loaded:{module_id}")
|
||||
except Exception as err:
|
||||
logger.error(f"Load Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
停止所有模块
|
||||
"""
|
||||
for _, module in self._running_modules.items():
|
||||
logger.info("正在停止所有模块...")
|
||||
for module_id, module in self._running_modules.items():
|
||||
if hasattr(module, "stop"):
|
||||
module.stop()
|
||||
try:
|
||||
module.stop()
|
||||
logger.info(f"Moudle Stoped:{module_id}")
|
||||
except Exception as err:
|
||||
logger.error(f"Stop Moudle Error:{module_id},{str(err)} - {traceback.format_exc()}", exc_info=True)
|
||||
logger.info("模块停止完成")
|
||||
|
||||
def reload(self):
|
||||
"""
|
||||
@@ -87,7 +97,17 @@ class ModuleManager(metaclass=Singleton):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_modules(self, method: str) -> Generator:
|
||||
def get_running_module(self, module_id: str) -> Any:
|
||||
"""
|
||||
根据模块id获取模块运行实例
|
||||
"""
|
||||
if not module_id:
|
||||
return None
|
||||
if not self._running_modules:
|
||||
return None
|
||||
return self._running_modules.get(module_id)
|
||||
|
||||
def get_running_modules(self, method: str) -> Generator:
|
||||
"""
|
||||
获取实现了同一方法的模块列表
|
||||
"""
|
||||
@@ -97,3 +117,19 @@ class ModuleManager(metaclass=Singleton):
|
||||
if hasattr(module, method) \
|
||||
and ObjectUtils.check_method(getattr(module, method)):
|
||||
yield module
|
||||
|
||||
def get_module(self, module_id: str) -> Any:
|
||||
"""
|
||||
根据模块id获取模块
|
||||
"""
|
||||
if not module_id:
|
||||
return None
|
||||
if not self._modules:
|
||||
return None
|
||||
return self._modules.get(module_id)
|
||||
|
||||
def get_modules(self) -> dict:
|
||||
"""
|
||||
获取模块列表
|
||||
"""
|
||||
return self._modules
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import concurrent
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import List, Any, Dict, Tuple, Optional
|
||||
from typing import List, Any, Dict, Tuple, Optional, Callable
|
||||
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
@@ -18,6 +25,59 @@ from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class PluginMonitorHandler(FileSystemEventHandler):
|
||||
|
||||
# 计时器
|
||||
__reload_timer = None
|
||||
# 防抖时间间隔
|
||||
__debounce_interval = 0.5
|
||||
# 最近一次修改时间
|
||||
__last_modified = 0
|
||||
# 修改间隔
|
||||
__timeout = 2
|
||||
|
||||
def on_modified(self, event):
|
||||
"""
|
||||
插件文件修改后重载
|
||||
"""
|
||||
if event.is_directory:
|
||||
return
|
||||
current_time = time.time()
|
||||
if current_time - self.__last_modified < self.__timeout:
|
||||
return
|
||||
self.__last_modified = current_time
|
||||
# 读取插件根目录下的__init__.py文件,读取class XXXX(_PluginBase)的类名
|
||||
try:
|
||||
# 使用os.path和pathlib处理跨平台的路径问题
|
||||
plugin_dir = event.src_path.split("plugins" + os.sep)[1].split(os.sep)[0]
|
||||
init_file = settings.ROOT_PATH / "app" / "plugins" / plugin_dir / "__init__.py"
|
||||
with open(init_file, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
pid = None
|
||||
for line in lines:
|
||||
if line.startswith("class") and "(_PluginBase)" in line:
|
||||
pid = line.split("class ")[1].split("(_PluginBase)")[0]
|
||||
if pid:
|
||||
# 防抖处理,通过计时器延迟加载
|
||||
if self.__reload_timer:
|
||||
self.__reload_timer.cancel()
|
||||
self.__reload_timer = threading.Timer(self.__debounce_interval, self.__reload_plugin, [pid])
|
||||
self.__reload_timer.start()
|
||||
except Exception as e:
|
||||
logger.error(f"插件文件修改后重载出错:{str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def __reload_plugin(pid):
|
||||
"""
|
||||
重新加载插件
|
||||
"""
|
||||
try:
|
||||
logger.info(f"插件 {pid} 文件修改,重新加载...")
|
||||
PluginManager().reload_plugin(pid)
|
||||
except Exception as e:
|
||||
logger.error(f"插件文件修改后重载出错:{str(e)}")
|
||||
|
||||
|
||||
class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
插件管理器
|
||||
@@ -30,11 +90,16 @@ class PluginManager(metaclass=Singleton):
|
||||
_running_plugins: dict = {}
|
||||
# 配置Key
|
||||
_config_key: str = "plugin.%s"
|
||||
# 监听器
|
||||
_observer: Observer = None
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.pluginhelper = PluginHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
# 开发者模式监测插件修改
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
self.__start_monitor()
|
||||
|
||||
def init_config(self):
|
||||
# 停止已有插件
|
||||
@@ -47,11 +112,28 @@ class PluginManager(metaclass=Singleton):
|
||||
启动加载插件
|
||||
:param pid: 插件ID,为空加载所有插件
|
||||
"""
|
||||
|
||||
def check_module(module: Any):
|
||||
"""
|
||||
检查模块
|
||||
"""
|
||||
if not hasattr(module, 'init_plugin') or not hasattr(module, "plugin_name"):
|
||||
return False
|
||||
return True
|
||||
|
||||
# 扫描插件目录
|
||||
plugins = ModuleHelper.load(
|
||||
"app.plugins",
|
||||
filter_func=lambda _, obj: hasattr(obj, 'init_plugin')
|
||||
)
|
||||
if pid:
|
||||
# 加载指定插件
|
||||
plugins = ModuleHelper.load_with_pre_filter(
|
||||
"app.plugins",
|
||||
filter_func=lambda name, obj: check_module(obj) and name == pid
|
||||
)
|
||||
else:
|
||||
# 加载所有插件
|
||||
plugins = ModuleHelper.load(
|
||||
"app.plugins",
|
||||
filter_func=lambda _, obj: check_module(obj)
|
||||
)
|
||||
# 已安装插件
|
||||
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 排序
|
||||
@@ -105,6 +187,10 @@ class PluginManager(metaclass=Singleton):
|
||||
:param pid: 插件ID,为空停止所有插件
|
||||
"""
|
||||
# 停止插件
|
||||
if pid:
|
||||
logger.info(f"正在停止插件 {pid}...")
|
||||
else:
|
||||
logger.info("正在停止所有插件...")
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
if pid and plugin_id != pid:
|
||||
continue
|
||||
@@ -120,6 +206,28 @@ class PluginManager(metaclass=Singleton):
|
||||
# 清空
|
||||
self._plugins = {}
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
def __start_monitor(self):
|
||||
"""
|
||||
开发者模式下监测插件文件修改
|
||||
"""
|
||||
logger.info("开发者模式下开始监测插件文件修改...")
|
||||
monitor_handler = PluginMonitorHandler()
|
||||
self._observer = Observer()
|
||||
self._observer.schedule(monitor_handler, str(settings.ROOT_PATH / "app" / "plugins"), recursive=True)
|
||||
self._observer.start()
|
||||
|
||||
def stop_monitor(self):
|
||||
"""
|
||||
停止监测插件修改
|
||||
"""
|
||||
# 停止监测
|
||||
if self._observer:
|
||||
logger.info("正在停止插件文件修改监测...")
|
||||
self._observer.stop()
|
||||
self._observer.join()
|
||||
logger.info("插件文件修改监测停止完成")
|
||||
|
||||
@staticmethod
|
||||
def __stop_plugin(plugin: Any):
|
||||
@@ -217,10 +325,11 @@ class PluginManager(metaclass=Singleton):
|
||||
获取插件表单
|
||||
:param pid: 插件ID
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return [], {}
|
||||
if hasattr(self._running_plugins[pid], "get_form"):
|
||||
return self._running_plugins[pid].get_form() or ([], {})
|
||||
if hasattr(plugin, "get_form"):
|
||||
return plugin.get_form() or ([], {})
|
||||
return [], {}
|
||||
|
||||
def get_plugin_page(self, pid: str) -> List[dict]:
|
||||
@@ -228,12 +337,50 @@ class PluginManager(metaclass=Singleton):
|
||||
获取插件页面
|
||||
:param pid: 插件ID
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return []
|
||||
if hasattr(self._running_plugins[pid], "get_page"):
|
||||
return self._running_plugins[pid].get_page() or []
|
||||
if hasattr(plugin, "get_page"):
|
||||
return plugin.get_page() or []
|
||||
return []
|
||||
|
||||
def get_plugin_dashboard(self, pid: str, key: str, **kwargs) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
获取插件仪表盘
|
||||
:param pid: 插件ID
|
||||
:param key: 仪表盘key
|
||||
"""
|
||||
def __get_params_count(func: Callable):
|
||||
"""
|
||||
获取函数的参数信息
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
return len(signature.parameters)
|
||||
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return None
|
||||
if hasattr(plugin, "get_dashboard"):
|
||||
# 检查方法的参数个数
|
||||
params_count = __get_params_count(plugin.get_dashboard)
|
||||
if params_count > 1:
|
||||
dashboard: Tuple = plugin.get_dashboard(key=key, **kwargs)
|
||||
elif params_count > 0:
|
||||
dashboard: Tuple = plugin.get_dashboard(**kwargs)
|
||||
else:
|
||||
dashboard: Tuple = plugin.get_dashboard()
|
||||
if dashboard:
|
||||
cols, attrs, elements = dashboard
|
||||
return schemas.PluginDashboard(
|
||||
id=pid,
|
||||
name=plugin.plugin_name,
|
||||
key=key or "",
|
||||
cols=cols or {},
|
||||
elements=elements,
|
||||
attrs=attrs or {}
|
||||
)
|
||||
return None
|
||||
|
||||
def get_plugin_commands(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件命令
|
||||
@@ -254,7 +401,7 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.error(f"获取插件命令出错:{str(e)}")
|
||||
return ret_commands
|
||||
|
||||
def get_plugin_apis(self) -> List[Dict[str, Any]]:
|
||||
def get_plugin_apis(self, plugin_id: str = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件API
|
||||
[{
|
||||
@@ -267,6 +414,8 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
ret_apis = []
|
||||
for pid, plugin in self._running_plugins.items():
|
||||
if plugin_id and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_api") \
|
||||
and ObjectUtils.check_method(plugin.get_api):
|
||||
try:
|
||||
@@ -301,17 +450,48 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.error(f"获取插件 {pid} 服务出错:{str(e)}")
|
||||
return ret_services
|
||||
|
||||
def get_plugin_dashboard_meta(self):
|
||||
"""
|
||||
获取所有插件仪表盘元信息
|
||||
"""
|
||||
dashboard_meta = []
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
if not hasattr(plugin, "get_dashboard") or not ObjectUtils.check_method(plugin.get_dashboard):
|
||||
continue
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
# 如果是多仪表盘实现
|
||||
if hasattr(plugin, "get_dashboard_meta") and ObjectUtils.check_method(plugin.get_dashboard_meta):
|
||||
meta = plugin.get_dashboard_meta()
|
||||
if meta:
|
||||
dashboard_meta.extend([{
|
||||
"id": plugin_id,
|
||||
"name": m.get("name"),
|
||||
"key": m.get("key"),
|
||||
} for m in meta if m])
|
||||
else:
|
||||
dashboard_meta.append({
|
||||
"id": plugin_id,
|
||||
"name": plugin.plugin_name,
|
||||
"key": "",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
||||
return dashboard_meta
|
||||
|
||||
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||
"""
|
||||
获取插件属性
|
||||
:param pid: 插件ID
|
||||
:param attr: 属性名
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return None
|
||||
if not hasattr(self._running_plugins[pid], attr):
|
||||
if not hasattr(plugin, attr):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], attr)
|
||||
return getattr(plugin, attr)
|
||||
|
||||
def run_plugin_method(self, pid: str, method: str, *args, **kwargs) -> Any:
|
||||
"""
|
||||
@@ -321,11 +501,12 @@ class PluginManager(metaclass=Singleton):
|
||||
:param args: 参数
|
||||
:param kwargs: 关键字参数
|
||||
"""
|
||||
if not self._running_plugins.get(pid):
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return None
|
||||
if not hasattr(self._running_plugins[pid], method):
|
||||
if not hasattr(plugin, method):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
|
||||
return getattr(plugin, method)(*args, **kwargs)
|
||||
|
||||
def get_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
@@ -353,6 +534,7 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.warn(f"获取插件库失败:{market}")
|
||||
return
|
||||
ret_plugins = []
|
||||
add_time = len(online_plugins)
|
||||
for pid, plugin_info in online_plugins.items():
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
@@ -406,6 +588,9 @@ class PluginManager(metaclass=Singleton):
|
||||
# 图标
|
||||
if plugin_info.get("icon"):
|
||||
plugin.plugin_icon = plugin_info.get("icon")
|
||||
# 标签
|
||||
if plugin_info.get("labels"):
|
||||
plugin.plugin_label = plugin_info.get("labels")
|
||||
# 作者
|
||||
if plugin_info.get("author"):
|
||||
plugin.plugin_author = plugin_info.get("author")
|
||||
@@ -416,8 +601,11 @@ class PluginManager(metaclass=Singleton):
|
||||
plugin.repo_url = market
|
||||
# 本地标志
|
||||
plugin.is_local = False
|
||||
# 添加顺序
|
||||
plugin.add_time = add_time
|
||||
# 汇总
|
||||
ret_plugins.append(plugin)
|
||||
add_time -= 1
|
||||
|
||||
return ret_plugins
|
||||
|
||||
@@ -510,12 +698,17 @@ class PluginManager(metaclass=Singleton):
|
||||
# 作者链接
|
||||
if hasattr(plugin_class, "author_url"):
|
||||
plugin.author_url = plugin_class.author_url
|
||||
# 加载顺序
|
||||
if hasattr(plugin_class, "plugin_order"):
|
||||
plugin.plugin_order = plugin_class.plugin_order
|
||||
# 是否需要更新
|
||||
plugin.has_update = False
|
||||
# 本地标志
|
||||
plugin.is_local = True
|
||||
# 汇总
|
||||
plugins.append(plugin)
|
||||
# 根据加载排序重新排序
|
||||
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -7,3 +7,4 @@ from .subscribe import Subscribe
|
||||
from .systemconfig import SystemConfig
|
||||
from .transferhistory import TransferHistory
|
||||
from .user import User
|
||||
from .userconfig import UserConfig
|
||||
|
||||
@@ -25,6 +25,10 @@ class Site(Base):
|
||||
cookie = Column(String)
|
||||
# User-Agent
|
||||
ua = Column(String)
|
||||
# ApiKey
|
||||
apikey = Column(String)
|
||||
# Token
|
||||
token = Column(String)
|
||||
# 是否使用代理 0-否,1-是
|
||||
proxy = Column(Integer)
|
||||
# 过滤规则
|
||||
@@ -41,6 +45,8 @@ class Site(Base):
|
||||
limit_count = Column(Integer, default=0)
|
||||
# 流控间隔
|
||||
limit_seconds = Column(Integer, default=0)
|
||||
# 超时时间
|
||||
timeout = Column(Integer, default=0)
|
||||
# 是否启用
|
||||
is_active = Column(Boolean(), default=True)
|
||||
# 创建时间
|
||||
@@ -63,6 +69,12 @@ class Site(Base):
|
||||
result = db.query(Site).order_by(Site.pri).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_domains_by_ids(db: Session, ids: list):
|
||||
result = db.query(Site.domain).filter(Site.id.in_(ids)).all()
|
||||
return [r[0] for r in result]
|
||||
|
||||
@staticmethod
|
||||
@db_update
|
||||
def reset(db: Session):
|
||||
|
||||
@@ -135,6 +135,25 @@ class Subscribe(Base):
|
||||
subscribe.delete(db, subscribe.id)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_username(db: Session, username: str, state: str = None, mtype: str = None):
|
||||
if mtype:
|
||||
if state:
|
||||
result = db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
else:
|
||||
result = db.query(Subscribe).filter(Subscribe.username == username,
|
||||
Subscribe.type == mtype).all()
|
||||
else:
|
||||
if state:
|
||||
result = db.query(Subscribe).filter(Subscribe.state == state,
|
||||
Subscribe.username == username).all()
|
||||
else:
|
||||
result = db.query(Subscribe).filter(Subscribe.username == username).all()
|
||||
return list(result)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_type(db: Session, mtype: str, days: int):
|
||||
|
||||
@@ -212,3 +212,11 @@ class TransferHistory(Base):
|
||||
"download_hash": download_hash
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def list_by_date(db: Session, date: str):
|
||||
"""
|
||||
查询某时间之后的转移历史
|
||||
"""
|
||||
return db.query(TransferHistory).filter(TransferHistory.date > date).order_by(TransferHistory.id.desc()).all()
|
||||
|
||||
38
app/db/models/userconfig.py
Normal file
38
app/db/models/userconfig.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import Column, Integer, String, Sequence, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
|
||||
|
||||
class UserConfig(Base):
|
||||
"""
|
||||
用户配置表
|
||||
"""
|
||||
id = Column(Integer, Sequence('id'), primary_key=True, index=True)
|
||||
# 用户名
|
||||
username = Column(String, index=True)
|
||||
# 配置键
|
||||
key = Column(String)
|
||||
# 值
|
||||
value = Column(String, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# 用户名和配置键联合唯一
|
||||
UniqueConstraint('username', 'key'),
|
||||
Index('ix_userconfig_username_key', 'username', 'key'),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_key(db: Session, username: str, key: str):
|
||||
return db.query(UserConfig) \
|
||||
.filter(UserConfig.username == username) \
|
||||
.filter(UserConfig.key == key) \
|
||||
.first()
|
||||
|
||||
@db_update
|
||||
def delete_by_key(self, db: Session, username: str, key: str):
|
||||
userconfig = self.get_by_key(db=db, username=username, key=key)
|
||||
if userconfig:
|
||||
userconfig.delete(db=db, rid=userconfig.id)
|
||||
return True
|
||||
@@ -63,6 +63,12 @@ class SiteOper(DbOper):
|
||||
"""
|
||||
return Site.get_by_domain(self._db, domain)
|
||||
|
||||
def get_domains_by_ids(self, ids: List[int]) -> List[str]:
|
||||
"""
|
||||
按ID获取站点域名
|
||||
"""
|
||||
return Site.get_domains_by_ids(self._db, ids)
|
||||
|
||||
def exists(self, domain: str) -> bool:
|
||||
"""
|
||||
判断站点是否存在
|
||||
|
||||
@@ -89,6 +89,18 @@ class SubscribeOper(DbOper):
|
||||
subscribe.update(self._db, payload)
|
||||
return subscribe
|
||||
|
||||
def list_by_tmdbid(self, tmdbid: int, season: int = None) -> List[Subscribe]:
|
||||
"""
|
||||
获取指定tmdb_id的订阅
|
||||
"""
|
||||
return Subscribe.get_by_tmdbid(self._db, tmdbid=tmdbid, season=season)
|
||||
|
||||
def list_by_username(self, username: str, state: str = None, mtype: str = None) -> List[Subscribe]:
|
||||
"""
|
||||
获取指定用户的订阅
|
||||
"""
|
||||
return Subscribe.list_by_username(self._db, username=username, state=state, mtype=mtype)
|
||||
|
||||
def list_by_type(self, mtype: str, days: int = 7) -> Subscribe:
|
||||
"""
|
||||
获取指定类型的订阅
|
||||
|
||||
@@ -17,6 +17,9 @@ class SubscribeHistoryOper(DbOper):
|
||||
kwargs = {k: v for k, v in kwargs.items() if hasattr(SubscribeHistory, k)}
|
||||
# 更新完成订阅时间
|
||||
kwargs.update({"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())})
|
||||
# 去掉主键
|
||||
if "id" in kwargs:
|
||||
kwargs.pop("id")
|
||||
subscribe = SubscribeHistory(**kwargs)
|
||||
subscribe.create(self._db)
|
||||
|
||||
|
||||
@@ -177,3 +177,10 @@ class TransferHistoryOper(DbOper):
|
||||
errmsg="未识别到媒体信息"
|
||||
)
|
||||
return his
|
||||
|
||||
def list_by_date(self, date: str) -> List[TransferHistory]:
|
||||
"""
|
||||
查询某时间之后的转移历史
|
||||
:param date: 日期
|
||||
"""
|
||||
return TransferHistory.list_by_date(self._db, date)
|
||||
|
||||
96
app/db/userconfig_oper.py
Normal file
96
app/db/userconfig_oper.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import json
|
||||
from typing import Any, Union, Dict, Optional
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.userconfig import UserConfig
|
||||
from app.schemas.types import UserConfigKey
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
# 配置缓存
|
||||
__USERCONF: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
加载配置到内存
|
||||
"""
|
||||
super().__init__()
|
||||
for item in UserConfig.list(self._db):
|
||||
value = json.loads(item.value) if ObjectUtils.is_obj(item.value) else item.value
|
||||
self.__set_config_cache(username=item.username, key=item.key, value=value)
|
||||
|
||||
def set(self, username: str, key: Union[str, UserConfigKey], value: Any):
|
||||
"""
|
||||
设置用户配置
|
||||
"""
|
||||
if isinstance(key, UserConfigKey):
|
||||
key = key.value
|
||||
# 更新内存
|
||||
self.__set_config_cache(username=username, key=key, value=value)
|
||||
# 写入数据库
|
||||
if ObjectUtils.is_obj(value):
|
||||
value = json.dumps(value)
|
||||
elif value is None:
|
||||
value = ''
|
||||
conf = UserConfig.get_by_key(db=self._db, username=username, key=key)
|
||||
if conf:
|
||||
if value:
|
||||
conf.update(self._db, {"value": value})
|
||||
else:
|
||||
conf.delete(self._db, conf.id)
|
||||
else:
|
||||
conf = UserConfig(username=username, key=key, value=value)
|
||||
conf.create(self._db)
|
||||
|
||||
def get(self, username: str, key: Union[str, UserConfigKey] = None) -> Any:
|
||||
"""
|
||||
获取用户配置
|
||||
"""
|
||||
if not username:
|
||||
return self.__USERCONF
|
||||
if isinstance(key, UserConfigKey):
|
||||
key = key.value
|
||||
if not key:
|
||||
return self.__get_config_caches(username=username)
|
||||
return self.__get_config_cache(username=username, key=key)
|
||||
|
||||
def __del__(self):
|
||||
if self._db:
|
||||
self._db.close()
|
||||
|
||||
def __set_config_cache(self, username: str, key: str, value: Any):
|
||||
"""
|
||||
设置配置缓存
|
||||
"""
|
||||
if not username or not key:
|
||||
return
|
||||
cache = self.__USERCONF
|
||||
if not cache:
|
||||
cache = {}
|
||||
user_cache = cache.get(username)
|
||||
if not user_cache:
|
||||
user_cache = {}
|
||||
cache[username] = user_cache
|
||||
user_cache[key] = value
|
||||
self.__USERCONF = cache
|
||||
|
||||
def __get_config_caches(self, username: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取配置缓存
|
||||
"""
|
||||
if not username or not self.__USERCONF:
|
||||
return None
|
||||
return self.__USERCONF.get(username)
|
||||
|
||||
def __get_config_cache(self, username: str, key: str) -> Any:
|
||||
"""
|
||||
获取配置缓存
|
||||
"""
|
||||
if not username or not key or not self.__USERCONF:
|
||||
return None
|
||||
user_cache = self.__get_config_caches(username)
|
||||
if not user_cache:
|
||||
return None
|
||||
return user_cache.get(key)
|
||||
152
app/helper/directory.py
Normal file
152
app/helper/directory.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.types import SystemConfigKey, MediaType
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DirectoryHelper:
|
||||
"""
|
||||
下载目录/媒体库目录帮助类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def get_download_dirs(self) -> List[schemas.MediaDirectory]:
|
||||
"""
|
||||
获取下载目录
|
||||
"""
|
||||
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.DownloadDirectories)
|
||||
if not dir_conf:
|
||||
return []
|
||||
return [schemas.MediaDirectory(**d) for d in dir_conf]
|
||||
|
||||
def get_library_dirs(self) -> List[schemas.MediaDirectory]:
|
||||
"""
|
||||
获取媒体库目录
|
||||
"""
|
||||
dir_conf: List[dict] = self.systemconfig.get(SystemConfigKey.LibraryDirectories)
|
||||
if not dir_conf:
|
||||
return []
|
||||
return [schemas.MediaDirectory(**d) for d in dir_conf]
|
||||
|
||||
def get_download_dir(self, media: MediaInfo = None, to_path: Path = None) -> Optional[schemas.MediaDirectory]:
|
||||
"""
|
||||
根据媒体信息获取下载目录
|
||||
:param media: 媒体信息
|
||||
:param to_path: 目标目录
|
||||
"""
|
||||
# 处理类型
|
||||
if media:
|
||||
media_type = media.type.value
|
||||
else:
|
||||
media_type = MediaType.UNKNOWN.value
|
||||
download_dirs = self.get_download_dirs()
|
||||
# 按照配置顺序查找(保存后的数据已经排序)
|
||||
for download_dir in download_dirs:
|
||||
if not download_dir.path:
|
||||
continue
|
||||
download_path = Path(download_dir.path)
|
||||
# 有目标目录,但目标目录与当前目录不相等时不要
|
||||
if to_path and download_path != to_path:
|
||||
continue
|
||||
# 不存在目录则创建
|
||||
if not download_path.exists():
|
||||
download_path.mkdir(parents=True, exist_ok=True)
|
||||
# 目录类型为全部的,符合条件
|
||||
if not download_dir.media_type:
|
||||
return download_dir
|
||||
# 目录类型相等,目录类别为全部,符合条件
|
||||
if download_dir.media_type == media_type and not download_dir.category:
|
||||
return download_dir
|
||||
# 目录类型相等,目录类别相等,符合条件
|
||||
if download_dir.media_type == media_type and download_dir.category == media.category:
|
||||
return download_dir
|
||||
|
||||
return None
|
||||
|
||||
def get_library_dir(self, media: MediaInfo = None, in_path: Path = None,
|
||||
to_path: Path = None) -> Optional[schemas.MediaDirectory]:
|
||||
"""
|
||||
根据媒体信息获取媒体库目录,需判断是否同盘优先
|
||||
:param media: 媒体信息
|
||||
:param in_path: 源目录
|
||||
:param to_path: 目标目录
|
||||
"""
|
||||
# 处理类型
|
||||
if media:
|
||||
media_type = media.type.value
|
||||
else:
|
||||
media_type = MediaType.UNKNOWN.value
|
||||
|
||||
# 匹配的目录
|
||||
matched_dirs = []
|
||||
library_dirs = self.get_library_dirs()
|
||||
# 按照配置顺序查找(保存后的数据已经排序)
|
||||
for library_dir in library_dirs:
|
||||
if not library_dir.path:
|
||||
continue
|
||||
# 有目标目录,但目标目录与当前目录不相等时不要
|
||||
if to_path and Path(library_dir.path) != to_path:
|
||||
continue
|
||||
# 目录类型为全部的,符合条件
|
||||
if not library_dir.media_type:
|
||||
matched_dirs.append(library_dir)
|
||||
# 目录类型相等,目录类别为全部,符合条件
|
||||
if library_dir.media_type == media_type and not library_dir.category:
|
||||
matched_dirs.append(library_dir)
|
||||
# 目录类型相等,目录类别相等,符合条件
|
||||
if library_dir.media_type == media_type and library_dir.category == media.category:
|
||||
matched_dirs.append(library_dir)
|
||||
|
||||
# 未匹配到
|
||||
if not matched_dirs:
|
||||
return None
|
||||
|
||||
# 没有目录则创建
|
||||
for matched_dir in matched_dirs:
|
||||
matched_path = Path(matched_dir.path)
|
||||
if not matched_path.exists():
|
||||
matched_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 只匹配到一项
|
||||
if len(matched_dirs) == 1:
|
||||
return matched_dirs[0]
|
||||
|
||||
# 有源路径,且开启同盘/同目录优先时
|
||||
if in_path and settings.TRANSFER_SAME_DISK:
|
||||
# 优先同根路径
|
||||
max_length = 0
|
||||
target_dir = None
|
||||
for matched_dir in matched_dirs:
|
||||
try:
|
||||
# 计算in_path和path的公共字符串长度
|
||||
relative = StringUtils.find_common_prefix(str(in_path), matched_dir.path)
|
||||
if len(str(matched_dir.path)) == len(relative):
|
||||
# 目录完整匹配的,直接返回
|
||||
return matched_dir
|
||||
if len(relative) > max_length:
|
||||
# 更新最大长度
|
||||
max_length = len(relative)
|
||||
target_dir = matched_dir
|
||||
except Exception as e:
|
||||
logger.debug(f"计算目标路径时出错:{str(e)}")
|
||||
continue
|
||||
if target_dir:
|
||||
return target_dir
|
||||
|
||||
# 优先同盘
|
||||
for matched_dir in matched_dirs:
|
||||
matched_path = Path(matched_dir.path)
|
||||
if SystemUtils.is_same_disk(matched_path, in_path):
|
||||
return matched_dir
|
||||
|
||||
# 返回最优先的匹配
|
||||
return matched_dirs[0]
|
||||
@@ -10,26 +10,46 @@ class MessageHelper(metaclass=Singleton):
|
||||
"""
|
||||
消息队列管理器,包括系统消息和用户消息
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.sys_queue = queue.Queue()
|
||||
self.user_queue = queue.Queue()
|
||||
|
||||
def put(self, message: Any, role: str = "sys", note: Union[list, dict] = None):
|
||||
def put(self, message: Any, role: str = "plugin", title: str = None, note: Union[list, dict] = None):
|
||||
"""
|
||||
存消息
|
||||
:param message: 消息
|
||||
:param role: 消息通道 sys/user
|
||||
:param role: 消息通道 systm:系统消息,plugin:插件消息,user:用户消息
|
||||
:param title: 标题
|
||||
:param note: 附件json
|
||||
"""
|
||||
if role == "sys":
|
||||
self.sys_queue.put(message)
|
||||
if role in ["system", "plugin"]:
|
||||
# 没有标题时获取插件名称
|
||||
if role == "plugin" and not title:
|
||||
title = "插件通知"
|
||||
# 系统通知,默认
|
||||
self.sys_queue.put(json.dumps({
|
||||
"type": role,
|
||||
"title": title,
|
||||
"text": message,
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"note": note
|
||||
}))
|
||||
else:
|
||||
if isinstance(message, str):
|
||||
self.user_queue.put(message)
|
||||
# 非系统的文本通知
|
||||
self.user_queue.put(json.dumps({
|
||||
"title": title,
|
||||
"text": message,
|
||||
"date": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"note": note
|
||||
}))
|
||||
elif hasattr(message, "to_dict"):
|
||||
# 非系统的复杂结构通知,如媒体信息/种子列表等。
|
||||
content = message.to_dict()
|
||||
content['title'] = title
|
||||
content['date'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
content['note'] = json.dumps(note) if note else None
|
||||
content['note'] = note
|
||||
self.user_queue.put(json.dumps(content))
|
||||
|
||||
def get(self, role: str = "sys") -> Optional[str]:
|
||||
|
||||
@@ -13,9 +13,9 @@ class ModuleHelper:
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def load(cls, package_path, filter_func=lambda name, obj: True):
|
||||
def load(cls, package_path: str, filter_func=lambda name, obj: True):
|
||||
"""
|
||||
导入子模块
|
||||
导入模块
|
||||
:param package_path: 父包名
|
||||
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
||||
:return:
|
||||
@@ -40,6 +40,38 @@ class ModuleHelper:
|
||||
|
||||
return submodules
|
||||
|
||||
@classmethod
|
||||
def load_with_pre_filter(cls, package_path: str, filter_func=lambda name, obj: True):
|
||||
"""
|
||||
导入子模块
|
||||
:param package_path: 父包名
|
||||
:param filter_func: 子模块过滤函数,入参为模块名和模块对象,返回True则导入,否则不导入
|
||||
:return:
|
||||
"""
|
||||
|
||||
submodules: list = []
|
||||
packages = importlib.import_module(package_path)
|
||||
for importer, package_name, _ in pkgutil.iter_modules(packages.__path__):
|
||||
try:
|
||||
if package_name.startswith('_'):
|
||||
continue
|
||||
full_package_name = f'{package_path}.{package_name}'
|
||||
module = importlib.import_module(full_package_name)
|
||||
# 预检查模块中的对象
|
||||
candidates = [(name, obj) for name, obj in module.__dict__.items() if
|
||||
not name.startswith('_') and isinstance(obj, type)]
|
||||
# 确定是否需要重新加载
|
||||
if any(filter_func(name, obj) for name, obj in candidates):
|
||||
importlib.reload(module)
|
||||
# reload后,对象已经发生变更,重新过滤已经重新加载后的模块中的对象
|
||||
for name, obj in module.__dict__.items():
|
||||
if not name.startswith('_') and isinstance(obj, type) and filter_func(name, obj):
|
||||
submodules.append(obj)
|
||||
except Exception as err:
|
||||
logger.debug(f'加载模块 {package_name} 失败:{str(err)} - {traceback.format_exc()}')
|
||||
|
||||
return submodules
|
||||
|
||||
@staticmethod
|
||||
def dynamic_import_all_modules(base_path: Path, package_name: str):
|
||||
"""
|
||||
|
||||
@@ -22,17 +22,18 @@ class PluginHelper(metaclass=Singleton):
|
||||
|
||||
_base_url = "https://raw.githubusercontent.com/%s/%s/main/"
|
||||
|
||||
_install_reg = "https://movie-pilot.org/plugin/install/%s"
|
||||
_install_reg = f"{settings.MP_SERVER_HOST}/plugin/install/%s"
|
||||
|
||||
_install_report = "https://movie-pilot.org/plugin/install"
|
||||
_install_report = f"{settings.MP_SERVER_HOST}/plugin/install"
|
||||
|
||||
_install_statistic = "https://movie-pilot.org/plugin/statistic"
|
||||
_install_statistic = f"{settings.MP_SERVER_HOST}/plugin/statistic"
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):
|
||||
if self.install_report():
|
||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||
if settings.PLUGIN_STATISTIC_SHARE:
|
||||
if not self.systemconfig.get(SystemConfigKey.PluginInstallReport):
|
||||
if self.install_report():
|
||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1000, ttl=1800))
|
||||
def get_plugins(self, repo_url: str) -> Dict[str, dict]:
|
||||
@@ -80,6 +81,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
获取插件安装统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return {}
|
||||
res = RequestUtils(timeout=10).get_res(self._install_statistic)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
@@ -89,6 +92,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
安装插件统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return False
|
||||
if not pid:
|
||||
return False
|
||||
res = RequestUtils(timeout=5).get_res(self._install_reg % pid)
|
||||
@@ -100,6 +105,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
"""
|
||||
上报存量插件安装统计
|
||||
"""
|
||||
if not settings.PLUGIN_STATISTIC_SHARE:
|
||||
return False
|
||||
plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins)
|
||||
if not plugins:
|
||||
return False
|
||||
@@ -135,7 +142,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
if r is None:
|
||||
return None, "连接仓库失败"
|
||||
elif r.status_code != 200:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
return None, f"连接仓库失败:{r.status_code} - " \
|
||||
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if r.status_code == 403 else r.reason}"
|
||||
ret = r.json()
|
||||
if ret and ret[0].get("message") == "Not Found":
|
||||
return None, "插件在仓库中不存在"
|
||||
@@ -155,7 +163,8 @@ class PluginHelper(metaclass=Singleton):
|
||||
if not res:
|
||||
return False, f"文件 {item.get('name')} 下载失败!"
|
||||
elif res.status_code != 200:
|
||||
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}"
|
||||
return False, f"下载文件 {item.get('name')} 失败:{res.status_code} - " \
|
||||
f"{'超出速率限制,请配置GITHUB_TOKEN环境变量或稍后重试' if res.status_code == 403 else res.reason}"
|
||||
# 创建插件文件夹
|
||||
file_path = Path(settings.ROOT_PATH) / "app" / item.get("path")
|
||||
if not file_path.parent.exists():
|
||||
|
||||
122
app/helper/subscribe.py
Normal file
122
app/helper/subscribe.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from threading import Thread
|
||||
from typing import List
|
||||
|
||||
from cachetools import TTLCache, cached
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class SubscribeHelper(metaclass=Singleton):
|
||||
"""
|
||||
订阅数据统计
|
||||
"""
|
||||
|
||||
_sub_reg = f"{settings.MP_SERVER_HOST}/subscribe/add"
|
||||
|
||||
_sub_done = f"{settings.MP_SERVER_HOST}/subscribe/done"
|
||||
|
||||
_sub_report = f"{settings.MP_SERVER_HOST}/subscribe/report"
|
||||
|
||||
_sub_statistic = f"{settings.MP_SERVER_HOST}/subscribe/statistic"
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
if not self.systemconfig.get(SystemConfigKey.SubscribeReport):
|
||||
if self.sub_report():
|
||||
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
||||
|
||||
@cached(cache=TTLCache(maxsize=20, ttl=1800))
|
||||
def get_statistic(self, stype: str, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取订阅统计数据
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return []
|
||||
res = RequestUtils(timeout=15).get_res(self._sub_statistic, params={
|
||||
"stype": stype,
|
||||
"page": page,
|
||||
"count": count
|
||||
})
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
return []
|
||||
|
||||
def sub_reg(self, sub: dict) -> bool:
|
||||
"""
|
||||
新增订阅统计
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_reg, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sub_done(self, sub: dict) -> bool:
|
||||
"""
|
||||
完成订阅统计
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
res = RequestUtils(timeout=5, headers={
|
||||
"Content-Type": "application/json"
|
||||
}).post_res(self._sub_done, json=sub)
|
||||
if res and res.status_code == 200:
|
||||
return True
|
||||
return False
|
||||
|
||||
def sub_reg_async(self, sub: dict) -> bool:
|
||||
"""
|
||||
异步新增订阅统计
|
||||
"""
|
||||
# 开新线程处理
|
||||
Thread(target=self.sub_reg, args=(sub,)).start()
|
||||
return True
|
||||
|
||||
def sub_done_async(self, sub: dict) -> bool:
|
||||
"""
|
||||
异步完成订阅统计
|
||||
"""
|
||||
# 开新线程处理
|
||||
Thread(target=self.sub_done, args=(sub,)).start()
|
||||
return True
|
||||
|
||||
def sub_report(self) -> bool:
|
||||
"""
|
||||
上报存量订阅统计
|
||||
"""
|
||||
if not settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
return False
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
return True
|
||||
res = RequestUtils(content_type="application/json",
|
||||
timeout=10).post(self._sub_report,
|
||||
json={
|
||||
"subscribes": [
|
||||
{
|
||||
"name": sub.name,
|
||||
"year": sub.year,
|
||||
"type": sub.type,
|
||||
"tmdbid": sub.tmdbid,
|
||||
"imdbid": sub.imdbid,
|
||||
"tvdbid": sub.tvdbid,
|
||||
"doubanid": sub.doubanid,
|
||||
"bangumiid": sub.bangumiid,
|
||||
"season": sub.season,
|
||||
"poster": sub.poster,
|
||||
"backdrop": sub.backdrop,
|
||||
"vote": sub.vote,
|
||||
"description": sub.description
|
||||
} for sub in subscribes
|
||||
]
|
||||
})
|
||||
return True if res else False
|
||||
@@ -322,6 +322,21 @@ class TorrentHelper(metaclass=Singleton):
|
||||
logger.error(f"解析大小范围失败:{str(e)} - {traceback.format_exc()}")
|
||||
return 0, 0
|
||||
|
||||
def __get_pubminutes(pubdate: str) -> float:
|
||||
"""
|
||||
将字符串转换为时间,并计算与当前时间差)(分钟)
|
||||
"""
|
||||
try:
|
||||
if not pubdate:
|
||||
return 0
|
||||
pubdate = pubdate.replace("T", " ").replace("Z", "")
|
||||
pubdate = datetime.datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.datetime.now()
|
||||
return (now - pubdate).total_seconds() // 60
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return 0
|
||||
|
||||
if not filter_rule:
|
||||
return True
|
||||
|
||||
@@ -334,20 +349,29 @@ class TorrentHelper(metaclass=Singleton):
|
||||
# 最少做种人数
|
||||
min_seeders = filter_rule.get("min_seeders")
|
||||
if min_seeders and torrent_info.seeders < int(min_seeders):
|
||||
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
|
||||
return False
|
||||
# 最少做种人数生效发布时间(分钟)(在设置发布时间之外的最少做种人数生效)
|
||||
min_seeders_time = filter_rule.get("min_seeders_time") or 0
|
||||
if min_seeders_time:
|
||||
# 发布时间与当前时间差(分钟)
|
||||
pubdate_minutes = __get_pubminutes(torrent_info.pubdate)
|
||||
if pubdate_minutes > int(min_seeders_time):
|
||||
logger.info(f"{torrent_info.title} 发布时间大于 {min_seeders_time} 分钟,做种人数不足 {min_seeders}")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"{torrent_info.title} 做种人数不足 {min_seeders}")
|
||||
return False
|
||||
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include, content, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
logger.info(f"{content} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude, content, re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
logger.info(f"{content} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
@@ -404,15 +428,23 @@ class TorrentHelper(metaclass=Singleton):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo,
|
||||
torrent: TorrentInfo, logerror: bool = True) -> bool:
|
||||
def match_torrent(mediainfo: MediaInfo, torrent_meta: MetaInfo, torrent: TorrentInfo) -> bool:
|
||||
"""
|
||||
检查种子是否匹配媒体信息
|
||||
:param mediainfo: 需要匹配的媒体信息
|
||||
:param torrent_meta: 种子识别信息
|
||||
:param torrent: 种子信息
|
||||
:param logerror: 是否记录错误日志
|
||||
"""
|
||||
# 比对词条指定的tmdbid
|
||||
if torrent_meta.tmdbid or torrent_meta.doubanid:
|
||||
if torrent_meta.tmdbid and torrent_meta.tmdbid == mediainfo.tmdb_id:
|
||||
logger.info(
|
||||
f'{mediainfo.title} 通过词表指定TMDBID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
return True
|
||||
if torrent_meta.doubanid and torrent_meta.doubanid == mediainfo.douban_id:
|
||||
logger.info(
|
||||
f'{mediainfo.title} 通过词表指定豆瓣ID匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
return True
|
||||
# 要匹配的媒体标题、原标题
|
||||
media_titles = {
|
||||
StringUtils.clear_upper(mediainfo.title),
|
||||
@@ -427,32 +459,28 @@ class TorrentHelper(metaclass=Singleton):
|
||||
} - {""}
|
||||
# 比对种子识别类型
|
||||
if torrent_meta.type == MediaType.TV and mediainfo.type != MediaType.TV:
|
||||
if logerror:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
||||
f'不匹配 {mediainfo.type.value}')
|
||||
logger.debug(f'{torrent.site_name} - {torrent.title} 种子标题类型为 {torrent_meta.type.value},'
|
||||
f'不匹配 {mediainfo.type.value}')
|
||||
return False
|
||||
# 比对种子在站点中的类型
|
||||
if torrent.category == MediaType.TV.value and mediainfo.type != MediaType.TV:
|
||||
if logerror:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
||||
f'不匹配 {mediainfo.type.value}')
|
||||
logger.debug(f'{torrent.site_name} - {torrent.title} 种子在站点中归类为 {torrent.category},'
|
||||
f'不匹配 {mediainfo.type.value}')
|
||||
return False
|
||||
# 比对年份
|
||||
if mediainfo.year:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 剧集年份,每季的年份可能不同
|
||||
# 剧集年份,每季的年份可能不同,没年份时不比较年份(很多剧集种子不带年份)
|
||||
if torrent_meta.year and torrent_meta.year not in [year for year in
|
||||
mediainfo.season_years.values()]:
|
||||
if logerror:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')
|
||||
logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.season_years}')
|
||||
return False
|
||||
else:
|
||||
# 电影年份,上下浮动1年
|
||||
if torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
||||
mediainfo.year,
|
||||
str(int(mediainfo.year) + 1)]:
|
||||
if logerror:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
|
||||
# 电影年份,上下浮动1年,没年份时不通过
|
||||
if not torrent_meta.year or torrent_meta.year not in [str(int(mediainfo.year) - 1),
|
||||
mediainfo.year,
|
||||
str(int(mediainfo.year) + 1)]:
|
||||
logger.debug(f'{torrent.site_name} - {torrent.title} 年份不匹配 {mediainfo.year}')
|
||||
return False
|
||||
# 比对标题和原语种标题
|
||||
if meta_names.intersection(media_titles):
|
||||
@@ -465,21 +493,24 @@ class TorrentHelper(metaclass=Singleton):
|
||||
return True
|
||||
# 标题拆分
|
||||
if torrent_meta.org_string:
|
||||
titles = [StringUtils.clear_upper(t) for t in re.split(r'[\s/【】.\[\]\-]+',
|
||||
torrent_meta.org_string) if t]
|
||||
# 只拆分出标题中的非英文单词进行匹配,英文单词容易误匹配(带空格的多个单词组合除外)
|
||||
titles = [StringUtils.clear_upper(t) for t in re.split(
|
||||
r'[\s/【】.\[\]\-]+',
|
||||
torrent_meta.org_string
|
||||
) if not StringUtils.is_english_word(t)]
|
||||
# 在标题中判断是否存在标题、原语种标题
|
||||
if media_titles.intersection(titles):
|
||||
logger.info(f'{mediainfo.title} 通过标题匹配到资源:{torrent.site_name} - {torrent.title}')
|
||||
return True
|
||||
# 在副标题中判断是否存在标题、原语种标题、别名、译名
|
||||
# 在副标题中(非英文单词)判断是否存在标题、原语种标题、别名、译名
|
||||
if torrent.description:
|
||||
subtitles = {StringUtils.clear_upper(t) for t in re.split(r'[\s/|]+',
|
||||
torrent.description) if t}
|
||||
subtitles = {StringUtils.clear_upper(t) for t in re.split(
|
||||
r'[\s/【】|]+',
|
||||
torrent.description) if not StringUtils.is_english_word(t)}
|
||||
if media_titles.intersection(subtitles) or media_names.intersection(subtitles):
|
||||
logger.info(f'{mediainfo.title} 通过副标题匹配到资源:{torrent.site_name} - {torrent.title},'
|
||||
f'副标题:{torrent.description}')
|
||||
return True
|
||||
# 未匹配
|
||||
if logerror:
|
||||
logger.warn(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
|
||||
logger.debug(f'{torrent.site_name} - {torrent.title} 标题不匹配,识别名称:{meta_names}')
|
||||
return False
|
||||
|
||||
@@ -98,7 +98,6 @@ class LoggerManager:
|
||||
|
||||
# 终端日志
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_formatter = CustomFormatter(f"%(leveltext)s%(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
_logger.addHandler(console_handler)
|
||||
@@ -109,7 +108,6 @@ class LoggerManager:
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_formater = CustomFormatter(f"【%(levelname)s】%(asctime)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
_logger.addHandler(file_handler)
|
||||
@@ -138,6 +136,7 @@ class LoggerManager:
|
||||
if not _logger:
|
||||
_logger = self.__setup_logger(logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
# 调用logger的方法打印日志
|
||||
if hasattr(_logger, method):
|
||||
method = getattr(_logger, method)
|
||||
method(f"{caller_name} - {msg}", *args, **kwargs)
|
||||
|
||||
25
app/main.py
25
app/main.py
@@ -1,7 +1,9 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from types import FrameType
|
||||
|
||||
import uvicorn as uvicorn
|
||||
from PIL import Image
|
||||
@@ -16,7 +18,7 @@ if SystemUtils.is_frozen():
|
||||
sys.stdout = open(os.devnull, 'w')
|
||||
sys.stderr = open(os.devnull, 'w')
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.init import init_db, update_db, init_super_user
|
||||
@@ -149,7 +151,7 @@ def check_auth():
|
||||
"""
|
||||
if SitesHelper().auth_level < 2:
|
||||
err_msg = "用户认证失败,站点相关功能将无法使用!"
|
||||
MessageHelper().put(f"注意:{err_msg}")
|
||||
MessageHelper().put(f"注意:{err_msg}", title="用户认证", role="system")
|
||||
CommandChian().post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
@@ -159,6 +161,22 @@ def check_auth():
|
||||
)
|
||||
|
||||
|
||||
def singal_handle():
|
||||
"""
|
||||
监听停止信号
|
||||
"""
|
||||
def stop_event(signum: int, _: FrameType):
|
||||
"""
|
||||
SIGTERM信号处理
|
||||
"""
|
||||
print(f"接收到停止信号:{signum},正在停止系统...")
|
||||
global_vars.stop_system()
|
||||
|
||||
# 设置信号处理程序
|
||||
signal.signal(signal.SIGTERM, stop_event)
|
||||
signal.signal(signal.SIGINT, stop_event)
|
||||
|
||||
|
||||
@App.on_event("shutdown")
|
||||
def shutdown_server():
|
||||
"""
|
||||
@@ -168,6 +186,7 @@ def shutdown_server():
|
||||
ModuleManager().stop()
|
||||
# 停止插件
|
||||
PluginManager().stop()
|
||||
PluginManager().stop_monitor()
|
||||
# 停止事件消费
|
||||
Command().stop()
|
||||
# 停止虚拟显示
|
||||
@@ -209,6 +228,8 @@ def start_module():
|
||||
start_frontend()
|
||||
# 检查认证状态
|
||||
check_auth()
|
||||
# 监听停止信号
|
||||
singal_handle()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -27,6 +27,14 @@ class _ModuleBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_name() -> str:
|
||||
"""
|
||||
获取模块名称
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.bangumi.bangumi import BangumiApi
|
||||
@@ -20,16 +23,20 @@ class BangumiModule(_ModuleBase):
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
with RequestUtils().get_res("https://api.bgm.tv/") as ret:
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接Bangumi,错误码:{ret.status_code}"
|
||||
ret = RequestUtils().get_res("https://api.bgm.tv/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接Bangumi,错误码:{ret.status_code}"
|
||||
return False, "Bangumi网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Bangumi"
|
||||
|
||||
def recognize_media(self, bangumiid: int = None,
|
||||
**kwargs) -> Optional[MediaInfo]:
|
||||
"""
|
||||
@@ -53,6 +60,23 @@ class BangumiModule(_ModuleBase):
|
||||
|
||||
return None
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
"""
|
||||
if settings.SEARCH_SOURCE and "bangumi" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
if not meta.name:
|
||||
return []
|
||||
infos = self.bangumiapi.search(meta.name)
|
||||
if infos:
|
||||
return [MediaInfo(bangumi_info=info) for info in infos
|
||||
if meta.name.lower() in str(info.get("name")).lower()
|
||||
or meta.name.lower() in str(info.get("name_cn")).lower()]
|
||||
return []
|
||||
|
||||
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
|
||||
"""
|
||||
获取Bangumi信息
|
||||
@@ -64,30 +88,58 @@ class BangumiModule(_ModuleBase):
|
||||
logger.info(f"开始获取Bangumi信息:{bangumiid} ...")
|
||||
return self.bangumiapi.detail(bangumiid)
|
||||
|
||||
def bangumi_calendar(self, page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
def bangumi_calendar(self) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
获取Bangumi每日放送
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
"""
|
||||
return self.bangumiapi.calendar(page, count)
|
||||
infos = self.bangumiapi.calendar()
|
||||
if infos:
|
||||
return [MediaInfo(bangumi_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
def bangumi_credits(self, bangumiid: int, page: int = 1, count: int = 20) -> List[dict]:
|
||||
def bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表
|
||||
:param bangumiid: BangumiID
|
||||
:param page: 页码
|
||||
:param count: 数量
|
||||
"""
|
||||
persons = self.bangumiapi.persons(bangumiid) or []
|
||||
persons = self.bangumiapi.credits(bangumiid)
|
||||
if persons:
|
||||
return persons[(page - 1) * count: page * count]
|
||||
else:
|
||||
return []
|
||||
return [schemas.MediaPerson(source='bangumi', **person) for person in persons]
|
||||
return []
|
||||
|
||||
def bangumi_recommend(self, bangumiid: int) -> List[dict]:
|
||||
def bangumi_recommend(self, bangumiid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据BangumiID查询推荐电影
|
||||
:param bangumiid: BangumiID
|
||||
"""
|
||||
return self.bangumiapi.subjects(bangumiid) or []
|
||||
subjects = self.bangumiapi.subjects(bangumiid)
|
||||
if subjects:
|
||||
return [MediaInfo(bangumi_info=subject) for subject in subjects]
|
||||
return []
|
||||
|
||||
def bangumi_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
"""
|
||||
获取人物详细信息
|
||||
:param person_id: 豆瓣人物ID
|
||||
"""
|
||||
personinfo = self.bangumiapi.person_detail(person_id)
|
||||
if personinfo:
|
||||
return schemas.MediaPerson(source='bangumi', **{
|
||||
"id": personinfo.get("id"),
|
||||
"name": personinfo.get("name"),
|
||||
"images": personinfo.get("images"),
|
||||
"biography": personinfo.get("summary"),
|
||||
"birthday": personinfo.get("birth_day"),
|
||||
"gender": personinfo.get("gender")
|
||||
})
|
||||
return None
|
||||
|
||||
def bangumi_person_credits(self, person_id: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
credits_info = self.bangumiapi.person_credits(person_id=person_id)
|
||||
if credits_info:
|
||||
return [MediaInfo(bangumi_info=credit) for credit in credits_info]
|
||||
return []
|
||||
|
||||
@@ -12,11 +12,14 @@ class BangumiApi(object):
|
||||
"""
|
||||
|
||||
_urls = {
|
||||
"search": "search/subjects/%s?type=2",
|
||||
"calendar": "calendar",
|
||||
"detail": "v0/subjects/%s",
|
||||
"persons": "v0/subjects/%s/persons",
|
||||
"credits": "v0/subjects/%s/persons",
|
||||
"subjects": "v0/subjects/%s/subjects",
|
||||
"characters": "v0/subjects/%s/characters"
|
||||
"characters": "v0/subjects/%s/characters",
|
||||
"person_detail": "v0/persons/%s",
|
||||
"person_credits": "v0/persons/%s/subjects",
|
||||
}
|
||||
_base_url = "https://api.bgm.tv/"
|
||||
_req = RequestUtils(session=requests.Session())
|
||||
@@ -38,7 +41,16 @@ class BangumiApi(object):
|
||||
print(e)
|
||||
return None
|
||||
|
||||
def calendar(self, page: int = 1, count: int = 30):
|
||||
def search(self, name):
|
||||
"""
|
||||
搜索媒体信息
|
||||
"""
|
||||
result = self.__invoke("search/subject/%s" % name)
|
||||
if result:
|
||||
return result.get("list")
|
||||
return []
|
||||
|
||||
def calendar(self):
|
||||
"""
|
||||
获取每日放送,返回items
|
||||
"""
|
||||
@@ -134,7 +146,7 @@ class BangumiApi(object):
|
||||
if result:
|
||||
for item in result:
|
||||
ret_list.extend(item.get("items") or [])
|
||||
return ret_list[(page - 1) * count: page * count]
|
||||
return ret_list
|
||||
|
||||
def detail(self, bid: int):
|
||||
"""
|
||||
@@ -142,7 +154,7 @@ class BangumiApi(object):
|
||||
"""
|
||||
return self.__invoke(self._urls["detail"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
def persons(self, bid: int):
|
||||
def credits(self, bid: int):
|
||||
"""
|
||||
获取番剧人物
|
||||
"""
|
||||
@@ -163,3 +175,20 @@ class BangumiApi(object):
|
||||
获取关联条目信息
|
||||
"""
|
||||
return self.__invoke(self._urls["subjects"] % bid, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
def person_detail(self, person_id: int):
|
||||
"""
|
||||
获取人物详细信息
|
||||
"""
|
||||
return self.__invoke(self._urls["person_detail"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
|
||||
def person_credits(self, person_id: int):
|
||||
"""
|
||||
获取人物参演作品
|
||||
"""
|
||||
ret_list = []
|
||||
result = self.__invoke(self._urls["person_credits"] % person_id, _ts=datetime.strftime(datetime.now(), '%Y%m%d'))
|
||||
if result:
|
||||
for item in result:
|
||||
ret_list.append(item)
|
||||
return ret_list
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List, Optional, Tuple, Union
|
||||
|
||||
import cn2an
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
@@ -13,6 +14,7 @@ from app.modules import _ModuleBase
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.douban_cache import DoubanCache
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas import MediaPerson
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.http import RequestUtils
|
||||
@@ -36,16 +38,20 @@ class DoubanModule(_ModuleBase):
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
with RequestUtils().get_res("https://movie.douban.com/") as ret:
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
return False, "豆瓣网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "豆瓣"
|
||||
|
||||
def recognize_media(self, meta: MetaBase = None,
|
||||
mtype: MediaType = None,
|
||||
doubanid: str = None,
|
||||
@@ -449,7 +455,7 @@ class DoubanModule(_ModuleBase):
|
||||
return __douban_movie() or __douban_tv()
|
||||
|
||||
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
|
||||
page: int = 1, count: int = 30) -> Optional[List[dict]]:
|
||||
page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
发现豆瓣电影、剧集
|
||||
:param mtype: 媒体类型
|
||||
@@ -466,69 +472,75 @@ class DoubanModule(_ModuleBase):
|
||||
else:
|
||||
infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count,
|
||||
sort=sort, tags=tags)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("items") or []
|
||||
if infos and infos.get("items"):
|
||||
medias = [MediaInfo(douban_info=info) for info in infos.get("items")]
|
||||
return [media for media in medias if media.poster_path
|
||||
and "movie_large.jpg" not in media.poster_path
|
||||
and "tv_normal.png" not in media.poster_path
|
||||
and "movie_large.jpg" not in media.poster_path
|
||||
and "tv_normal.jpg" not in media.poster_path
|
||||
and "tv_large.jpg" not in media.poster_path]
|
||||
return []
|
||||
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取正在上映的电影
|
||||
"""
|
||||
infos = self.doubanapi.movie_showing(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos and infos.get("subject_collection_items"):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取豆瓣本周口碑国产剧
|
||||
"""
|
||||
infos = self.doubanapi.tv_chinese_best_weekly(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos:
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取豆瓣本周口碑外国剧
|
||||
"""
|
||||
infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos and infos.get("subject_collection_items"):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取豆瓣动画剧
|
||||
"""
|
||||
infos = self.doubanapi.tv_animation(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos and infos.get("subject_collection_items"):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取豆瓣热门电影
|
||||
"""
|
||||
infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos and infos.get("subject_collection_items"):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取豆瓣热门剧集
|
||||
"""
|
||||
infos = self.doubanapi.tv_hot(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos and infos.get("subject_collection_items"):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
@@ -536,14 +548,12 @@ class DoubanModule(_ModuleBase):
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息
|
||||
"""
|
||||
# 未启用豆瓣搜索时返回None
|
||||
if settings.RECOGNIZE_SOURCE != "douban":
|
||||
if settings.SEARCH_SOURCE and "douban" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
|
||||
if not meta.name:
|
||||
return []
|
||||
result = self.doubanapi.search(meta.name)
|
||||
if not result:
|
||||
if not result or not result.get("items"):
|
||||
return []
|
||||
# 返回数据
|
||||
ret_medias = []
|
||||
@@ -552,6 +562,8 @@ class DoubanModule(_ModuleBase):
|
||||
continue
|
||||
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
||||
continue
|
||||
if meta.name not in item_obj.get("target", {}).get("title"):
|
||||
continue
|
||||
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
|
||||
# 将搜索词中的季写入标题中
|
||||
if ret_medias and meta.begin_season:
|
||||
@@ -563,6 +575,24 @@ class DoubanModule(_ModuleBase):
|
||||
media.season = meta.begin_season
|
||||
return ret_medias
|
||||
|
||||
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息
|
||||
"""
|
||||
if not name:
|
||||
return []
|
||||
result = self.doubanapi.person_search(keyword=name)
|
||||
if result and result.get('items'):
|
||||
return [MediaPerson(source='douban', **{
|
||||
'id': item.get('target_id'),
|
||||
'name': item.get('target', {}).get('title'),
|
||||
'url': item.get('target', {}).get('url'),
|
||||
'images': item.get('target', {}).get('cover', {}),
|
||||
'avatar': (item.get('target', {}).get('cover_img', {}).get('url')
|
||||
or '').replace("/l/public/", "/s/public/"),
|
||||
}) for item in result.get('items') if name in item.get('target', {}).get('title')]
|
||||
return []
|
||||
|
||||
@retry(Exception, 5, 3, 3, logger=logger)
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: MediaType = None, year: str = None, season: int = None) -> dict:
|
||||
@@ -588,7 +618,7 @@ class DoubanModule(_ModuleBase):
|
||||
# 搜索
|
||||
logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...")
|
||||
result = self.doubanapi.search(f"{name} {year or ''}".strip())
|
||||
if not result:
|
||||
if not result or not result.get("items"):
|
||||
logger.warn(f"未找到 {name} 的豆瓣信息")
|
||||
return {}
|
||||
# 触发rate limit
|
||||
@@ -618,15 +648,15 @@ class DoubanModule(_ModuleBase):
|
||||
return item
|
||||
return {}
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]:
|
||||
"""
|
||||
获取豆瓣电影TOP250
|
||||
"""
|
||||
infos = self.doubanapi.movie_top250(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
if infos and infos.get("subject_collection_items"):
|
||||
return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")]
|
||||
return []
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
@@ -767,48 +797,99 @@ class DoubanModule(_ModuleBase):
|
||||
self.cache.clear()
|
||||
logger.info("豆瓣缓存清除完成")
|
||||
|
||||
def douban_movie_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
|
||||
def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据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 []
|
||||
# 更新豆瓣演员信息中的ID,从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id
|
||||
for doubaninfo in ret_list:
|
||||
doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1]
|
||||
return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list]
|
||||
return []
|
||||
|
||||
def douban_tv_credits(self, doubanid: str, page: int = 1, count: int = 20) -> List[dict]:
|
||||
def douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据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 []
|
||||
# 更新豆瓣演员信息中的ID,从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id
|
||||
for doubaninfo in ret_list:
|
||||
doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1]
|
||||
return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list]
|
||||
return []
|
||||
|
||||
def douban_movie_recommend(self, doubanid: str) -> List[dict]:
|
||||
def douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电影
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.doubanapi.movie_recommendations(subject_id=doubanid) or []
|
||||
recommend = self.doubanapi.movie_recommendations(subject_id=doubanid)
|
||||
if recommend:
|
||||
return [MediaInfo(douban_info=info) for info in recommend]
|
||||
return []
|
||||
|
||||
def douban_tv_recommend(self, doubanid: str) -> List[dict]:
|
||||
def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
|
||||
"""
|
||||
根据豆瓣ID查询推荐电视剧
|
||||
:param doubanid: 豆瓣ID
|
||||
"""
|
||||
return self.doubanapi.tv_recommendations(subject_id=doubanid) or []
|
||||
recommend = self.doubanapi.tv_recommendations(subject_id=doubanid)
|
||||
if recommend:
|
||||
return [MediaInfo(douban_info=info) for info in recommend]
|
||||
return []
|
||||
|
||||
def douban_person_detail(self, person_id: int) -> schemas.MediaPerson:
|
||||
"""
|
||||
获取人物详细信息
|
||||
:param person_id: 豆瓣人物ID
|
||||
"""
|
||||
detail = self.doubanapi.person_detail(person_id)
|
||||
if detail:
|
||||
also_known_as = []
|
||||
infos = detail.get("extra", {}).get("info")
|
||||
if infos:
|
||||
also_known_as = [":".join(info) for info in infos]
|
||||
image = detail.get("cover_img", {}).get("url")
|
||||
if image:
|
||||
image = image.replace("/l/public/", "/s/public/")
|
||||
return schemas.MediaPerson(source='douban', **{
|
||||
"id": detail.get("id"),
|
||||
"name": detail.get("title"),
|
||||
"avatar": image,
|
||||
"biography": detail.get("extra", {}).get("short_info"),
|
||||
"also_known_as": also_known_as,
|
||||
})
|
||||
return schemas.MediaPerson(source='douban')
|
||||
|
||||
def douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
# 获取人物参演作品集
|
||||
personinfo = self.doubanapi.person_detail(person_id)
|
||||
if not personinfo:
|
||||
return []
|
||||
collection_id = None
|
||||
for module in personinfo.get("modules"):
|
||||
if module.get("type") == "work_collections":
|
||||
collection_id = module.get("payload", {}).get("id")
|
||||
# 查询作品集内容
|
||||
if collection_id:
|
||||
collections = self.doubanapi.person_work(subject_id=collection_id, start=(page - 1) * 20, count=20)
|
||||
if collections:
|
||||
works = collections.get("works")
|
||||
return [MediaInfo(douban_info=work.get("subject")) for work in works]
|
||||
return []
|
||||
|
||||
@@ -22,6 +22,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
# 聚合搜索
|
||||
"search": "/search/weixin",
|
||||
"search_agg": "/search",
|
||||
"search_subject": "/search/subjects",
|
||||
"imdbid": "/movie/imdb/%s",
|
||||
|
||||
# 电影探索
|
||||
@@ -137,6 +138,10 @@ class DoubanApi(metaclass=Singleton):
|
||||
# doulist
|
||||
"doulist": "/doulist/",
|
||||
"doulist_items": "/doulist/%s/items",
|
||||
|
||||
# person
|
||||
"person_detail": "/elessar/subject/",
|
||||
"person_work": "/elessar/work_collections/%s/works",
|
||||
}
|
||||
|
||||
_user_agents = [
|
||||
@@ -176,7 +181,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
"""
|
||||
req_url = self._base_url + url
|
||||
|
||||
params = {'apiKey': self._api_key}
|
||||
params: dict = {'apiKey': self._api_key}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
|
||||
@@ -194,7 +199,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
ua=choice(self._user_agents),
|
||||
session=self._session
|
||||
).get_res(url=req_url, params=params)
|
||||
if resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
@@ -223,10 +228,17 @@ class DoubanApi(metaclass=Singleton):
|
||||
ua=settings.USER_AGENT,
|
||||
session=self._session,
|
||||
).post_res(url=req_url, data=params)
|
||||
if resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
if resp is not None and resp.status_code == 400 and "rate_limit" in resp.text:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
def imdbid(self, imdbid: str,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
IMDBID搜索
|
||||
"""
|
||||
return self.__post(self._urls["imdbid"] % imdbid, _ts=ts)
|
||||
|
||||
def search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""
|
||||
@@ -235,13 +247,6 @@ class DoubanApi(metaclass=Singleton):
|
||||
return self.__invoke(self._urls["search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def imdbid(self, imdbid: str,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
IMDBID搜索
|
||||
"""
|
||||
return self.__post(self._urls["imdbid"] % imdbid, _ts=ts)
|
||||
|
||||
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
@@ -274,6 +279,14 @@ class DoubanApi(metaclass=Singleton):
|
||||
return self.__invoke(self._urls["group_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def person_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
人物搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["search_subject"], type="person", q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_showing(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
@@ -475,6 +488,30 @@ class DoubanApi(metaclass=Singleton):
|
||||
return self.__invoke(self._urls["tv_photos"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def person_detail(self, subject_id: int):
|
||||
"""
|
||||
用户详情
|
||||
:param subject_id: 人物 id
|
||||
:return:
|
||||
"""
|
||||
return self.__invoke(self._urls["person_detail"] + str(subject_id))
|
||||
|
||||
def person_work(self, subject_id: int, start: int = 0, count: int = 20, sort_by: str = "time",
|
||||
collection_title: str = "影视",
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
用户作品集
|
||||
:param subject_id: work_collection id
|
||||
:param start: 开始页
|
||||
:param count: 数量
|
||||
:param sort_by: collection or time or vote
|
||||
:param collection_title: 影视 or 图书 or 音乐
|
||||
:param ts: 时间戳
|
||||
:return:
|
||||
"""
|
||||
return self.__invoke(self._urls["person_work"] % subject_id, sortby=sort_by, collection_title=collection_title,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
清空LRU缓存
|
||||
|
||||
@@ -193,15 +193,15 @@ class DoubanScraper:
|
||||
url = url.replace("/format/webp", "/format/jpg")
|
||||
file_path.with_suffix(".jpg")
|
||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||
with RequestUtils().get_res(url=url) as r:
|
||||
if r:
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, r.content)
|
||||
else:
|
||||
file_path.write_bytes(r.content)
|
||||
logger.info(f"图片已保存:{file_path}")
|
||||
r = RequestUtils().get_res(url=url)
|
||||
if r:
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, r.content)
|
||||
else:
|
||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||
file_path.write_bytes(r.content)
|
||||
logger.info(f"图片已保存:{file_path}")
|
||||
else:
|
||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||
except Exception as err:
|
||||
logger.error(f"{file_path.stem}图片下载失败:{str(err)}")
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ class EmbyModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.emby = Emby()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Emby"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -56,12 +56,12 @@ class Emby:
|
||||
return []
|
||||
req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json()
|
||||
else:
|
||||
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
|
||||
return []
|
||||
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 []
|
||||
@@ -74,29 +74,29 @@ class Emby:
|
||||
return []
|
||||
req_url = "%semby/Library/VirtualFolders/Query?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
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'))
|
||||
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 []
|
||||
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 []
|
||||
@@ -113,12 +113,12 @@ class Emby:
|
||||
user = self.user
|
||||
req_url = f"{self._host}emby/Users/{user}/Views?api_key={self._apikey}"
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json().get("Items")
|
||||
else:
|
||||
logger.error(f"User/Views 未获取到返回数据")
|
||||
return []
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json().get("Items")
|
||||
else:
|
||||
logger.error(f"User/Views 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接User/Views 出错:" + str(e))
|
||||
return []
|
||||
@@ -164,20 +164,20 @@ class Emby:
|
||||
return None
|
||||
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
users = res.json()
|
||||
# 先查询是否有与当前用户名称匹配的
|
||||
if user_name:
|
||||
for user in users:
|
||||
if user.get("Name") == user_name:
|
||||
return user.get("Id")
|
||||
# 查询管理员
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
users = res.json()
|
||||
# 先查询是否有与当前用户名称匹配的
|
||||
if user_name:
|
||||
for user in users:
|
||||
if user.get("Policy", {}).get("IsAdministrator"):
|
||||
if user.get("Name") == user_name:
|
||||
return user.get("Id")
|
||||
else:
|
||||
logger.error(f"Users 未获取到返回数据")
|
||||
# 查询管理员
|
||||
for user in users:
|
||||
if user.get("Policy", {}).get("IsAdministrator"):
|
||||
return user.get("Id")
|
||||
else:
|
||||
logger.error(f"Users 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users出错:" + str(e))
|
||||
return None
|
||||
@@ -227,11 +227,11 @@ class Emby:
|
||||
return None
|
||||
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json().get("Id")
|
||||
else:
|
||||
logger.error(f"System/Info 未获取到返回数据")
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json().get("Id")
|
||||
else:
|
||||
logger.error(f"System/Info 未获取到返回数据")
|
||||
except Exception as e:
|
||||
|
||||
logger.error(f"连接System/Info出错:" + str(e))
|
||||
@@ -245,12 +245,12 @@ class Emby:
|
||||
return 0
|
||||
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json().get("TotalRecordCount")
|
||||
else:
|
||||
logger.error(f"Users/Query 未获取到返回数据")
|
||||
return 0
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json().get("TotalRecordCount")
|
||||
else:
|
||||
logger.error(f"Users/Query 未获取到返回数据")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Query出错:" + str(e))
|
||||
return 0
|
||||
@@ -264,17 +264,17 @@ class Emby:
|
||||
return schemas.Statistic()
|
||||
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return schemas.Statistic()
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return schemas.Statistic()
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Counts出错:" + str(e))
|
||||
return schemas.Statistic()
|
||||
@@ -299,14 +299,14 @@ class Emby:
|
||||
"&api_key=%s") % (
|
||||
self._host, name, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == name and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
return res_item.get('Id')
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == name and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
return res_item.get('Id')
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
return None
|
||||
@@ -329,36 +329,36 @@ class Emby:
|
||||
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
||||
self._host, title, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
ret_movies = []
|
||||
for res_item in res_items:
|
||||
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=res_item.get("ParentId"),
|
||||
item_id=res_item.get("Id"),
|
||||
item_type=res_item.get("Type"),
|
||||
title=res_item.get("Name"),
|
||||
original_title=res_item.get("OriginalTitle"),
|
||||
year=res_item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=res_item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(mediaserver_item)
|
||||
continue
|
||||
if (mediaserver_item.title == title
|
||||
and (not year or str(mediaserver_item.year) == str(year))):
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
ret_movies = []
|
||||
for res_item in res_items:
|
||||
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=res_item.get("ParentId"),
|
||||
item_id=res_item.get("Id"),
|
||||
item_type=res_item.get("Type"),
|
||||
title=res_item.get("Name"),
|
||||
original_title=res_item.get("OriginalTitle"),
|
||||
year=res_item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=res_item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
continue
|
||||
if (mediaserver_item.title == title
|
||||
and (not year or str(mediaserver_item.year) == str(year))):
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
return None
|
||||
@@ -401,25 +401,25 @@ class Emby:
|
||||
try:
|
||||
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
|
||||
self._host, item_id, season, self._apikey)
|
||||
with RequestUtils().get_res(req_url) as res_json:
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
if season and season != season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in season_episodes:
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
# 返回
|
||||
return item_id, season_episodes
|
||||
res_json = RequestUtils().get_res(req_url)
|
||||
if res_json:
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
if season and season != season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if season_index not in season_episodes:
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
# 返回
|
||||
return item_id, season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None, None
|
||||
@@ -463,13 +463,13 @@ class Emby:
|
||||
|
||||
req_url = "%sItems/%s/Images/%s" % (self._playhost, item_id, image_type)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res and res.status_code != 404:
|
||||
logger.info(f"影片图片链接:{res.url}")
|
||||
return res.url
|
||||
else:
|
||||
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||
return None
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code != 404:
|
||||
logger.info(f"影片图片链接:{res.url}")
|
||||
return res.url
|
||||
else:
|
||||
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Images出错:" + str(e))
|
||||
return None
|
||||
@@ -482,11 +482,11 @@ class Emby:
|
||||
return False
|
||||
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
|
||||
try:
|
||||
with RequestUtils().post_res(req_url) as res:
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
|
||||
res = RequestUtils().post_res(req_url)
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Refresh出错:" + str(e))
|
||||
return False
|
||||
@@ -500,11 +500,11 @@ class Emby:
|
||||
return False
|
||||
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().post_res(req_url) as res:
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
logger.info(f"刷新媒体库失败,无法连接Emby!")
|
||||
res = RequestUtils().post_res(req_url)
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
logger.info(f"刷新媒体库失败,无法连接Emby!")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/Refresh出错:" + str(e))
|
||||
return False
|
||||
@@ -579,23 +579,23 @@ class Emby:
|
||||
return None
|
||||
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res and res.status_code == 200:
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id出错:" + str(e))
|
||||
return None
|
||||
@@ -610,17 +610,17 @@ class Emby:
|
||||
yield None
|
||||
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get("Items") or []
|
||||
for result in results:
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(parent=result.get('Id')):
|
||||
yield item
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get("Items") or []
|
||||
for result in results:
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(parent=result.get('Id')):
|
||||
yield item
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
yield None
|
||||
@@ -1032,52 +1032,52 @@ class Emby:
|
||||
req_url = (f"{self._host}Users/{user}/Items/Resume?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
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")
|
||||
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:
|
||||
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 未获取到返回数据")
|
||||
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 []
|
||||
@@ -1095,35 +1095,35 @@ class Emby:
|
||||
req_url = (f"{self._host}Users/{user}/Items/Latest?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
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 未获取到返回数据")
|
||||
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 []
|
||||
|
||||
@@ -321,16 +321,20 @@ class FanartModule(_ModuleBase):
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
with RequestUtils().get_res("https://webservice.fanart.tv") as ret:
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接fanart,错误码:{ret.status_code}"
|
||||
ret = RequestUtils().get_res("https://webservice.fanart.tv")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接fanart,错误码:{ret.status_code}"
|
||||
return False, "fanart网络连接失败"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "FANART_API_KEY", True
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Fanart"
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
获取图片
|
||||
|
||||
@@ -9,21 +9,34 @@ from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode, MediaDirectory
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
lock = Lock()
|
||||
|
||||
|
||||
class FileTransferModule(_ModuleBase):
|
||||
"""
|
||||
文件整理模块
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
|
||||
def init_module(self) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "文件整理"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
@@ -31,34 +44,40 @@ class FileTransferModule(_ModuleBase):
|
||||
"""
|
||||
测试模块连接性
|
||||
"""
|
||||
if not settings.DOWNLOAD_PATH:
|
||||
return False, "下载目录未设置"
|
||||
directoryhelper = DirectoryHelper()
|
||||
# 检查下载目录
|
||||
download_paths: List[str] = []
|
||||
for path in [settings.DOWNLOAD_PATH,
|
||||
settings.DOWNLOAD_MOVIE_PATH,
|
||||
settings.DOWNLOAD_TV_PATH,
|
||||
settings.DOWNLOAD_ANIME_PATH]:
|
||||
download_paths = directoryhelper.get_download_dirs()
|
||||
if not download_paths:
|
||||
return False, "下载目录未设置"
|
||||
for d_path in download_paths:
|
||||
path = d_path.path
|
||||
if not path:
|
||||
continue
|
||||
return False, f"下载目录 {d_path.name} 对应路径未设置"
|
||||
download_path = Path(path)
|
||||
if not download_path.exists():
|
||||
return False, f"下载目录 {download_path} 不存在"
|
||||
download_paths.append(path)
|
||||
# 下载目录的设备ID
|
||||
download_devids = [Path(path).stat().st_dev for path in download_paths]
|
||||
return False, f"下载目录 {d_path.name} 对应路径 {path} 不存在"
|
||||
# 检查媒体库目录
|
||||
if not settings.LIBRARY_PATH:
|
||||
libaray_paths = directoryhelper.get_library_dirs()
|
||||
if not libaray_paths:
|
||||
return False, "媒体库目录未设置"
|
||||
# 比较媒体库目录的设备ID
|
||||
for path in settings.LIBRARY_PATHS:
|
||||
for l_path in libaray_paths:
|
||||
path = l_path.path
|
||||
if not path:
|
||||
return False, f"媒体库目录 {l_path.name} 对应路径未设置"
|
||||
library_path = Path(path)
|
||||
if not library_path.exists():
|
||||
return False, f"媒体库目录不存在:{library_path}"
|
||||
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
|
||||
if library_path.stat().st_dev not in download_devids:
|
||||
return False, f"媒体库目录 {library_path} " \
|
||||
f"与下载目录 {','.join(download_paths)} 不在同一设备,将无法硬链接"
|
||||
return False, f"媒体库目录{l_path.name} 对应的路径 {path} 不存在"
|
||||
# 检查硬链接条件
|
||||
if settings.DOWNLOADER_MONITOR and settings.TRANSFER_TYPE == "link":
|
||||
for d_path in download_paths:
|
||||
link_ok = False
|
||||
for l_path in libaray_paths:
|
||||
if SystemUtils.is_same_disk(Path(d_path.path), Path(l_path.path)):
|
||||
link_ok = True
|
||||
break
|
||||
if not link_ok:
|
||||
return False, f"媒体库目录中未找到" \
|
||||
f"与下载目录 {d_path} 在同一磁盘/存储空间/映射路径的目录,将无法硬链接"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
@@ -66,7 +85,8 @@ class FileTransferModule(_ModuleBase):
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
scrape: bool = None) -> TransferInfo:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -75,39 +95,49 @@ class FileTransferModule(_ModuleBase):
|
||||
:param transfer_type: 转移方式
|
||||
:param target: 目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param scrape: 是否刮削元数据
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
# 获取目标路径
|
||||
if not target:
|
||||
# 未指定目的目录,根据源目录选择一个媒体库
|
||||
target = self.get_target_path(in_path=path)
|
||||
# 拼装媒体库一、二级子目录
|
||||
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("未找到媒体库目录,无法转移文件")
|
||||
# 目标路径不能是文件
|
||||
if target and target.is_file():
|
||||
logger.error(f"转移目标路径是一个文件 {target} 是一个文件")
|
||||
return TransferInfo(success=False,
|
||||
path=path,
|
||||
message="未找到媒体库目录")
|
||||
message=f"{target} 不是有效目录")
|
||||
# 获取目标路径
|
||||
directoryhelper = DirectoryHelper()
|
||||
if target:
|
||||
dir_info = directoryhelper.get_library_dir(mediainfo, in_path=path, to_path=target)
|
||||
else:
|
||||
logger.info(f"获取转移目标路径:{target}")
|
||||
dir_info = directoryhelper.get_library_dir(mediainfo, in_path=path)
|
||||
if dir_info:
|
||||
# 是否需要刮削
|
||||
if scrape is None:
|
||||
need_scrape = dir_info.scrape
|
||||
else:
|
||||
need_scrape = scrape
|
||||
# 拼装媒体库一、二级子目录
|
||||
target = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dir_info)
|
||||
elif target:
|
||||
# 自定义目标路径
|
||||
need_scrape = scrape or False
|
||||
else:
|
||||
# 未找到有效的媒体库目录
|
||||
logger.error(
|
||||
f"{mediainfo.type.value} {mediainfo.title_year} 未找到有效的媒体库目录,无法转移文件,源路径:{path}")
|
||||
return TransferInfo(success=False,
|
||||
path=path,
|
||||
message="未找到有效的媒体库目录")
|
||||
|
||||
logger.info(f"获取转移目标路径:{target}")
|
||||
# 转移
|
||||
return self.transfer_media(in_path=path,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target_dir=target,
|
||||
episodes_info=episodes_info)
|
||||
episodes_info=episodes_info,
|
||||
need_scrape=need_scrape)
|
||||
|
||||
@staticmethod
|
||||
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
|
||||
@@ -380,43 +410,24 @@ class FileTransferModule(_ModuleBase):
|
||||
over_flag=over_flag)
|
||||
|
||||
@staticmethod
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: Path, typename_dir: bool = True) -> Path:
|
||||
def __get_dest_dir(mediainfo: MediaInfo, target_dir: MediaDirectory) -> Path:
|
||||
"""
|
||||
根据设置并装媒体库目录
|
||||
:param mediainfo: 媒体信息
|
||||
:target_dir: 媒体库根目录
|
||||
:typename_dir: 是否加上类型目录
|
||||
"""
|
||||
if not target_dir:
|
||||
return target_dir
|
||||
if not target_dir.media_type and target_dir.auto_category:
|
||||
# 一级自动分类
|
||||
download_dir = Path(target_dir.path) / mediainfo.type.value
|
||||
else:
|
||||
download_dir = Path(target_dir.path)
|
||||
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
if typename_dir:
|
||||
# 目的目录加上类型和二级分类
|
||||
target_dir = target_dir / settings.LIBRARY_MOVIE_NAME / mediainfo.category
|
||||
else:
|
||||
# 目的目录加上二级分类
|
||||
target_dir = target_dir / mediainfo.category
|
||||
if not target_dir.category and target_dir.auto_category and mediainfo.category:
|
||||
# 二级自动分类
|
||||
download_dir = download_dir / mediainfo.category
|
||||
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
if mediainfo.genre_ids \
|
||||
and set(mediainfo.genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
# 动漫
|
||||
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:
|
||||
# 电视剧
|
||||
if typename_dir:
|
||||
target_dir = target_dir / settings.LIBRARY_TV_NAME / mediainfo.category
|
||||
else:
|
||||
target_dir = target_dir / mediainfo.category
|
||||
|
||||
return target_dir
|
||||
return download_dir
|
||||
|
||||
def transfer_media(self,
|
||||
in_path: Path,
|
||||
@@ -424,7 +435,8 @@ class FileTransferModule(_ModuleBase):
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target_dir: Path,
|
||||
episodes_info: List[TmdbEpisode] = None
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
need_scrape: bool = False
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并转移一个文件或者一个目录下的所有文件
|
||||
@@ -434,6 +446,7 @@ class FileTransferModule(_ModuleBase):
|
||||
:param target_dir: 媒体库根目录
|
||||
:param transfer_type: 文件转移方式
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param need_scrape: 是否需要刮削
|
||||
:return: TransferInfo、错误信息
|
||||
"""
|
||||
# 检查目录路径
|
||||
@@ -486,7 +499,8 @@ class FileTransferModule(_ModuleBase):
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
total_size=file_size,
|
||||
is_bluray=bluray_flag)
|
||||
is_bluray=bluray_flag,
|
||||
need_scrape=need_scrape)
|
||||
else:
|
||||
# 转移单个文件
|
||||
if mediainfo.type == MediaType.TV:
|
||||
@@ -585,7 +599,8 @@ class FileTransferModule(_ModuleBase):
|
||||
total_size=file_size,
|
||||
is_bluray=False,
|
||||
file_list=[str(in_path)],
|
||||
file_list_new=[str(new_file)])
|
||||
file_list_new=[str(new_file)],
|
||||
need_scrape=need_scrape)
|
||||
|
||||
@staticmethod
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
|
||||
@@ -627,7 +642,7 @@ class FileTransferModule(_ModuleBase):
|
||||
# 原语种标题
|
||||
"original_title": __convert_invalid_characters(mediainfo.original_title),
|
||||
# 原文件名
|
||||
"original_name": meta.org_string,
|
||||
"original_name": meta.title,
|
||||
# 识别名称(优先使用中文)
|
||||
"name": meta.name,
|
||||
# 识别的英文名称(可能为空)
|
||||
@@ -685,96 +700,32 @@ class FileTransferModule(_ModuleBase):
|
||||
else:
|
||||
return Path(render_str)
|
||||
|
||||
@staticmethod
|
||||
def get_library_path(path: Path):
|
||||
"""
|
||||
根据文件路径查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return path
|
||||
# 目的路径,多路径以,分隔
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
for libpath in dest_paths:
|
||||
try:
|
||||
if path.is_relative_to(libpath):
|
||||
return libpath
|
||||
except Exception as e:
|
||||
logger.debug(f"计算媒体库路径时出错:{str(e)}")
|
||||
continue
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def get_target_path(in_path: Path = None) -> Optional[Path]:
|
||||
"""
|
||||
计算一个最好的目的目录,有in_path时找与in_path同路径的,没有in_path时,顺序查找1个符合大小要求的,没有in_path和size时,返回第1个
|
||||
:param in_path: 源目录
|
||||
"""
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return None
|
||||
# 目的路径,多路径以,分隔
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
# 只有一个路径,直接返回
|
||||
if len(dest_paths) == 1:
|
||||
return dest_paths[0]
|
||||
# 匹配有最长共同上级路径的目录
|
||||
max_length = 0
|
||||
target_path = None
|
||||
if in_path:
|
||||
for path in dest_paths:
|
||||
try:
|
||||
# 计算in_path和path的公共字符串长度
|
||||
relative = StringUtils.find_common_prefix(str(in_path), str(path))
|
||||
if len(str(path)) == len(relative):
|
||||
# 目录完整匹配的,直接返回
|
||||
return path
|
||||
if len(relative) > max_length:
|
||||
# 更新最大长度
|
||||
max_length = len(relative)
|
||||
target_path = path
|
||||
except Exception as e:
|
||||
logger.debug(f"计算目标路径时出错:{str(e)}")
|
||||
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个
|
||||
return dest_paths[0]
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, **kwargs) -> Optional[ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在于本地文件系统,只支持标准媒体库结构
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if not settings.LIBRARY_PATHS:
|
||||
return None
|
||||
# 目的路径
|
||||
dest_paths = settings.LIBRARY_PATHS
|
||||
dest_paths = DirectoryHelper().get_library_dirs()
|
||||
# 检查每一个媒体库目录
|
||||
for dest_path in dest_paths:
|
||||
# 媒体库路径
|
||||
target_dir = self.get_target_path(dest_path)
|
||||
if not target_dir:
|
||||
continue
|
||||
# 媒体分类路径
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=dest_path)
|
||||
if not target_dir.exists():
|
||||
continue
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 相对路径
|
||||
# 获取相对路径(重命名路径)
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
rel_path = self.get_rename_path(
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
|
||||
# 取相对路径的第1层目录
|
||||
if rel_path.parts:
|
||||
media_path = target_dir / rel_path.parts[0]
|
||||
@@ -818,8 +769,6 @@ class FileTransferModule(_ModuleBase):
|
||||
删除目录下的所有版本文件
|
||||
:param path: 目录路径
|
||||
"""
|
||||
if not path.exists():
|
||||
return False
|
||||
# 识别文件中的季集信息
|
||||
meta = MetaInfoPath(path)
|
||||
season = meta.season
|
||||
@@ -832,7 +781,7 @@ class FileTransferModule(_ModuleBase):
|
||||
return False
|
||||
# 删除文件
|
||||
for media_file in media_files:
|
||||
if media_file == path:
|
||||
if str(media_file) == str(path):
|
||||
continue
|
||||
# 识别文件中的季集信息
|
||||
filemeta = MetaInfoPath(media_file)
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
import threading
|
||||
|
||||
from pyparsing import Forward, Literal, Word, alphas, infixNotation, opAssoc, alphanums, Combine, nums, ParseResults
|
||||
|
||||
|
||||
class RuleParser:
|
||||
|
||||
_lock = threading.Lock()
|
||||
_thread_local = threading.local()
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
定义语法规则
|
||||
"""
|
||||
# 表达式
|
||||
expr: Forward = Forward()
|
||||
# 原子
|
||||
atom: Combine = Combine(Word(alphas, alphanums) | Word(nums) + Word(alphas, alphanums))
|
||||
# 逻辑非操作符
|
||||
operator_not: Literal = Literal('!').setParseAction(lambda: 'not')
|
||||
# 逻辑或操作符
|
||||
operator_or: Literal = Literal('|').setParseAction(lambda: 'or')
|
||||
# 逻辑与操作符
|
||||
operator_and: Literal = Literal('&').setParseAction(lambda: 'and')
|
||||
# 定义表达式的语法规则
|
||||
expr <<= operator_not + expr | operator_or | operator_and | atom | ('(' + expr + ')')
|
||||
with self._lock:
|
||||
if not hasattr(self._thread_local, 'initialized'):
|
||||
# 表达式
|
||||
expr: Forward = Forward()
|
||||
# 原子
|
||||
atom: Combine = Combine(Word(alphas, alphanums) | (Word(nums) + Word(alphas, alphanums)))
|
||||
# 逻辑非操作符
|
||||
operator_not: Literal = Literal('!').setParseAction(lambda t: 'not')
|
||||
# 逻辑或操作符
|
||||
operator_or: Literal = Literal('|').setParseAction(lambda t: 'or')
|
||||
# 逻辑与操作符
|
||||
operator_and: Literal = Literal('&').setParseAction(lambda t: 'and')
|
||||
# 定义表达式的语法规则
|
||||
expr <<= (operator_not + expr) | atom | ('(' + expr + ')')
|
||||
|
||||
# 运算符优先级
|
||||
self.expr = infixNotation(expr,
|
||||
[(operator_not, 1, opAssoc.RIGHT),
|
||||
(operator_and, 2, opAssoc.LEFT),
|
||||
(operator_or, 2, opAssoc.LEFT)])
|
||||
# 运算符优先级
|
||||
self.expr = infixNotation(expr,
|
||||
[(operator_not, 1, opAssoc.RIGHT),
|
||||
(operator_and, 2, opAssoc.LEFT),
|
||||
(operator_or, 2, opAssoc.LEFT)])
|
||||
|
||||
self._thread_local.expr = self.expr
|
||||
self._thread_local.initialized = True
|
||||
else:
|
||||
self.expr = self._thread_local.expr
|
||||
|
||||
def parse(self, expression: str) -> ParseResults:
|
||||
"""
|
||||
@@ -41,7 +53,9 @@ class RuleParser:
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 测试代码
|
||||
expression_str = "!BLU & 4K & CN > !BLU & 1080P & CN > !BLU & 4K > !BLU & 1080P"
|
||||
expression_str = """
|
||||
SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 4K & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & CNVOI & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > CNSUB & 4K & WEBDL & 60FPS & !DOLBY & !SDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & WEBDL & !DOLBY & !3D > SPECSUB & 4K & WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & WEBDL & !DOLBY & !3D > CNSUB & 4K & WEBDL & !DOLBY & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & CNVOI & 4K & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > CNSUB & 4K & !BLU & !WEBDL & !DOLBY & !SDR & !3D > 4K & !BLU & !REMUX & !DOLBY & HDR & !3D > 4K & !BLURAY & !REMUX & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !REMUX & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > CNSUB & 1080P & !BLU & !WEBDL & !DOLBY & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > CNSUB & 1080P & WEBDL & !DOLBY & HDR & !3D > SPECSUB & 1080P & WEBDL & !DOLBY & !3D > CNSUB & 1080P & WEBDL & !DOLBY & !3D > 1080P & !BLU & !REMUX & !DOLBY & HDR & !3D > 1080P & !BLU & !REMUX & !DOLBY & !3D
|
||||
"""
|
||||
for exp in expression_str.split('>'):
|
||||
parsed_expr = RuleParser().parse(exp)
|
||||
print(parsed_expr.as_list())
|
||||
parsed_expr = RuleParser().parse(exp.strip())
|
||||
print(parsed_expr.asList())
|
||||
|
||||
@@ -136,6 +136,10 @@ class FilterModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.parser = RuleParser()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "过滤器"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
@@ -169,6 +173,7 @@ class FilterModule(_ModuleBase):
|
||||
continue
|
||||
# 能命中优先级的才返回
|
||||
if not self.__get_order(torrent, rule_string):
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} {torrent.description} 不匹配优先级规则")
|
||||
continue
|
||||
ret_torrents.append(torrent)
|
||||
|
||||
@@ -191,7 +196,7 @@ class FilterModule(_ModuleBase):
|
||||
torrent_episodes = meta.episode_list
|
||||
if not set(torrent_seasons).issubset(set(seasons)):
|
||||
# 种子季不在过滤季中
|
||||
logger.info(f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {seasons}")
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 包含季 {torrent_seasons} 不是需要的季 {list(seasons)}")
|
||||
return False
|
||||
if not torrent_episodes:
|
||||
# 整季按匹配处理
|
||||
@@ -201,7 +206,7 @@ class FilterModule(_ModuleBase):
|
||||
if need_episodes \
|
||||
and not set(torrent_episodes).intersection(set(need_episodes)):
|
||||
# 单季集没有交集的不要
|
||||
logger.info(f"种子 {torrent.site_name} - {torrent.title} "
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} "
|
||||
f"集 {torrent_episodes} 没有需要的集:{need_episodes}")
|
||||
return False
|
||||
return True
|
||||
@@ -223,7 +228,7 @@ class FilterModule(_ModuleBase):
|
||||
if self.__match_group(torrent, parsed_group.as_list()[0]):
|
||||
# 出现匹配时中断
|
||||
matched = True
|
||||
logger.info(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}")
|
||||
logger.debug(f"种子 {torrent.site_name} - {torrent.title} 优先级为 {100 - res_order + 1}")
|
||||
torrent.pri_order = res_order
|
||||
break
|
||||
# 优先级降低,继续匹配
|
||||
|
||||
@@ -5,7 +5,7 @@ from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import TorrentInfo
|
||||
from app.db.sytestatistic_oper import SiteStatisticOper
|
||||
from app.db.sitestatistic_oper import SiteStatisticOper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
@@ -13,6 +13,7 @@ from app.modules.indexer.mtorrent import MTorrentSpider
|
||||
from app.modules.indexer.spider import TorrentSpider
|
||||
from app.modules.indexer.tnode import TNodeSpider
|
||||
from app.modules.indexer.torrentleech import TorrentLeech
|
||||
from app.modules.indexer.yema import YemaSpider
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -25,6 +26,10 @@ class IndexerModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "站点索引"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
@@ -107,6 +112,12 @@ class IndexerModule(_ModuleBase):
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "Yema":
|
||||
error_flag, result = YemaSpider(site).search(
|
||||
keyword=search_word,
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
else:
|
||||
error_flag, result = self.__spider_search(
|
||||
search_word=search_word,
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.utils.string import StringUtils
|
||||
|
||||
class MTorrentSpider:
|
||||
"""
|
||||
mTorrent API,需要缓存ApiKey
|
||||
mTorrent API
|
||||
"""
|
||||
_indexerid = None
|
||||
_domain = None
|
||||
@@ -27,6 +27,7 @@ class MTorrentSpider:
|
||||
_searchurl = "%sapi/torrent/search"
|
||||
_downloadurl = "%sapi/torrent/genDlToken"
|
||||
_pageurl = "%sdetail/%s"
|
||||
_timeout = 15
|
||||
|
||||
# 电影分类
|
||||
_movie_category = ['401', '419', '420', '421', '439', '405', '404']
|
||||
@@ -34,6 +35,8 @@ class MTorrentSpider:
|
||||
|
||||
# API KEY
|
||||
_apikey = None
|
||||
# JWT Token
|
||||
_token = None
|
||||
|
||||
# 标签
|
||||
_labels = {
|
||||
@@ -58,49 +61,15 @@ class MTorrentSpider:
|
||||
self._proxy = settings.PROXY
|
||||
self._cookie = indexer.get('cookie')
|
||||
self._ua = indexer.get('ua')
|
||||
|
||||
def __get_apikey(self) -> str:
|
||||
"""
|
||||
获取ApiKey
|
||||
"""
|
||||
domain_host = StringUtils.get_url_host(self._domain)
|
||||
self._apikey = self.systemconfig.get(f"site.{domain_host}.apikey")
|
||||
if not self._apikey:
|
||||
try:
|
||||
res = RequestUtils(
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"{self._ua}"
|
||||
},
|
||||
cookies=self._cookie,
|
||||
ua=self._ua,
|
||||
proxies=self._proxy,
|
||||
referer=f"{self._domain}usercp?tab=laboratory",
|
||||
timeout=15
|
||||
).post_res(url=f"{self._domain}api/apikey/getKeyList")
|
||||
if res and res.status_code == 200:
|
||||
api_keys = res.json().get('data')
|
||||
if api_keys:
|
||||
logger.info(f"{self._name} 获取ApiKey成功")
|
||||
# 按lastModifiedDate倒序排序
|
||||
api_keys.sort(key=lambda x: x.get('lastModifiedDate'), reverse=True)
|
||||
self._apikey = api_keys[0].get('apiKey')
|
||||
self.systemconfig.set(f"site.{domain_host}.apikey", self._apikey)
|
||||
else:
|
||||
logger.warn(f"{self._name} 获取ApiKey失败,请先在`控制台`->`实验室`建立存取令牌")
|
||||
else:
|
||||
logger.warn(f"{self._name} 获取ApiKey失败,请检查Cookie是否有效")
|
||||
except Exception as e:
|
||||
logger.error(f"{self._name} 获取ApiKey出错:{e}")
|
||||
return self._apikey
|
||||
self._apikey = indexer.get('apikey')
|
||||
self._token = indexer.get('token')
|
||||
self._timeout = indexer.get('timeout') or 15
|
||||
|
||||
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
搜索
|
||||
"""
|
||||
# 检查ApiKey
|
||||
self.__get_apikey()
|
||||
|
||||
if not self._apikey:
|
||||
return True, []
|
||||
|
||||
@@ -125,7 +94,7 @@ class MTorrentSpider:
|
||||
},
|
||||
proxies=self._proxy,
|
||||
referer=f"{self._domain}browse",
|
||||
timeout=15
|
||||
timeout=self._timeout
|
||||
).post_res(url=self._searchurl, json=params)
|
||||
torrents = []
|
||||
if res and res.status_code == 200:
|
||||
|
||||
@@ -63,8 +63,8 @@ class TorrentSpider:
|
||||
torrents_info: dict = {}
|
||||
# 种子列表
|
||||
torrents_info_array: list = []
|
||||
# 搜索超时, 默认: 30秒
|
||||
_timeout = 30
|
||||
# 搜索超时, 默认: 15秒
|
||||
_timeout = 15
|
||||
|
||||
def __init__(self,
|
||||
indexer: CommentedMap,
|
||||
|
||||
@@ -18,6 +18,7 @@ class TNodeSpider:
|
||||
_ua = None
|
||||
_token = None
|
||||
_size = 100
|
||||
_timeout = 15
|
||||
_searchurl = "%sapi/torrent/advancedSearch"
|
||||
_downloadurl = "%sapi/torrent/download/%s"
|
||||
_pageurl = "%storrent/info/%s"
|
||||
@@ -32,6 +33,7 @@ class TNodeSpider:
|
||||
self._proxy = settings.PROXY
|
||||
self._cookie = indexer.get('cookie')
|
||||
self._ua = indexer.get('ua')
|
||||
self._timeout = indexer.get('timeout') or 15
|
||||
self.init_config()
|
||||
|
||||
def init_config(self):
|
||||
@@ -43,7 +45,7 @@ class TNodeSpider:
|
||||
res = RequestUtils(ua=self._ua,
|
||||
cookies=self._cookie,
|
||||
proxies=self._proxy,
|
||||
timeout=15).get_res(url=self._domain)
|
||||
timeout=self._timeout).get_res(url=self._domain)
|
||||
if res and res.status_code == 200:
|
||||
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
|
||||
if csrf_token:
|
||||
@@ -77,7 +79,7 @@ class TNodeSpider:
|
||||
},
|
||||
cookies=self._cookie,
|
||||
proxies=self._proxy,
|
||||
timeout=15
|
||||
timeout=self._timeout
|
||||
).post_res(url=self._searchurl, json=params)
|
||||
torrents = []
|
||||
if res and res.status_code == 200:
|
||||
|
||||
@@ -17,11 +17,13 @@ class TorrentLeech:
|
||||
_browseurl = "%storrents/browse/list/page/2%s"
|
||||
_downloadurl = "%sdownload/%s/%s"
|
||||
_pageurl = "%storrent/%s"
|
||||
_timeout = 15
|
||||
|
||||
def __init__(self, indexer: CommentedMap):
|
||||
self._indexer = indexer
|
||||
if indexer.get('proxy'):
|
||||
self._proxy = settings.PROXY
|
||||
self._timeout = indexer.get('timeout') or 15
|
||||
|
||||
def search(self, keyword: str, page: int = 0) -> Tuple[bool, List[dict]]:
|
||||
|
||||
@@ -40,7 +42,7 @@ class TorrentLeech:
|
||||
},
|
||||
cookies=self._indexer.get('cookie'),
|
||||
proxies=self._proxy,
|
||||
timeout=15
|
||||
timeout=self._timeout
|
||||
).get_res(url)
|
||||
torrents = []
|
||||
if res and res.status_code == 200:
|
||||
|
||||
148
app/modules/indexer/yema.py
Normal file
148
app/modules/indexer/yema.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from typing import Tuple, List
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class YemaSpider:
|
||||
"""
|
||||
YemaPT API
|
||||
"""
|
||||
_indexerid = None
|
||||
_domain = None
|
||||
_name = ""
|
||||
_proxy = None
|
||||
_cookie = None
|
||||
_ua = None
|
||||
_size = 40
|
||||
_searchurl = "%sapi/torrent/fetchCategoryOpenTorrentList"
|
||||
_downloadurl = "%sapi/torrent/download?id=%s"
|
||||
_pageurl = "%s#/torrent/detail/%s/"
|
||||
_timeout = 15
|
||||
|
||||
# 分类
|
||||
_movie_category = 4
|
||||
_tv_category = 5
|
||||
|
||||
def __init__(self, indexer: CommentedMap):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
if indexer:
|
||||
self._indexerid = indexer.get('id')
|
||||
self._domain = indexer.get('domain')
|
||||
self._searchurl = self._searchurl % self._domain
|
||||
self._name = indexer.get('name')
|
||||
if indexer.get('proxy'):
|
||||
self._proxy = settings.PROXY
|
||||
self._cookie = indexer.get('cookie')
|
||||
self._ua = indexer.get('ua')
|
||||
self._timeout = indexer.get('timeout') or 15
|
||||
|
||||
def search(self, keyword: str, mtype: MediaType = None, page: int = 0) -> Tuple[bool, List[dict]]:
|
||||
"""
|
||||
搜索
|
||||
"""
|
||||
if not mtype:
|
||||
categoryId = self._movie_category
|
||||
elif mtype == MediaType.TV:
|
||||
categoryId = self._tv_category
|
||||
else:
|
||||
categoryId = self._movie_category
|
||||
params = {
|
||||
"categoryId": categoryId,
|
||||
"pageParam": {
|
||||
"current": page + 1,
|
||||
"pageSize": self._size,
|
||||
"total": self._size
|
||||
},
|
||||
"sorter": {}
|
||||
}
|
||||
if keyword:
|
||||
params.update({
|
||||
"keyword": keyword,
|
||||
})
|
||||
res = RequestUtils(
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"{self._ua}",
|
||||
"Accept": "application/json, text/plain, */*"
|
||||
},
|
||||
cookies=self._cookie,
|
||||
proxies=self._proxy,
|
||||
referer=f"{self._domain}",
|
||||
timeout=self._timeout
|
||||
).post_res(url=self._searchurl, json=params)
|
||||
torrents = []
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get('data', []) or []
|
||||
for result in results:
|
||||
category_value = result.get('categoryId')
|
||||
if category_value == self._tv_category:
|
||||
category = MediaType.TV.value
|
||||
elif category_value == self._movie_category:
|
||||
category = MediaType.MOVIE.value
|
||||
else:
|
||||
category = MediaType.UNKNOWN.value
|
||||
torrent = {
|
||||
'title': result.get('showName'),
|
||||
'description': result.get('shortDesc'),
|
||||
'enclosure': self.__get_download_url(result.get('id')),
|
||||
'pubdate': StringUtils.unify_datetime_str(result.get('gmtCreate')),
|
||||
'size': result.get('fileSize'),
|
||||
'seeders': result.get('seedNum'),
|
||||
'peers': result.get('leechNum'),
|
||||
'grabs': result.get('completedNum'),
|
||||
'downloadvolumefactor': self.__get_downloadvolumefactor(result.get('downloadPromotion')),
|
||||
'uploadvolumefactor': self.__get_uploadvolumefactor(result.get('uploadPromotion')),
|
||||
'freedate': StringUtils.unify_datetime_str(result.get('downloadPromotionEndTime')),
|
||||
'page_url': self._pageurl % (self._domain, result.get('id')),
|
||||
'labels': [],
|
||||
'category': category
|
||||
}
|
||||
torrents.append(torrent)
|
||||
elif res is not None:
|
||||
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
|
||||
return True, []
|
||||
else:
|
||||
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
|
||||
return True, []
|
||||
return False, torrents
|
||||
|
||||
@staticmethod
|
||||
def __get_downloadvolumefactor(discount: str) -> float:
|
||||
"""
|
||||
获取下载系数
|
||||
"""
|
||||
discount_dict = {
|
||||
"free": 0,
|
||||
"half": 0.5,
|
||||
"none": 1
|
||||
}
|
||||
if discount:
|
||||
return discount_dict.get(discount, 1)
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def __get_uploadvolumefactor(discount: str) -> float:
|
||||
"""
|
||||
获取上传系数
|
||||
"""
|
||||
discount_dict = {
|
||||
"none": 1,
|
||||
"one_half": 1.5,
|
||||
"double_upload": 2
|
||||
}
|
||||
if discount:
|
||||
return discount_dict.get(discount, 1)
|
||||
return 1
|
||||
|
||||
def __get_download_url(self, torrent_id: str) -> str:
|
||||
"""
|
||||
获取下载链接
|
||||
"""
|
||||
return self._downloadurl % (self._domain, torrent_id)
|
||||
@@ -14,6 +14,10 @@ class JellyfinModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.jellyfin = Jellyfin()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Jellyfin"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "MEDIASERVER", "jellyfin"
|
||||
|
||||
|
||||
@@ -52,12 +52,12 @@ class Jellyfin:
|
||||
return []
|
||||
req_url = "%sLibrary/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json()
|
||||
else:
|
||||
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
|
||||
return []
|
||||
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 []
|
||||
@@ -70,29 +70,29 @@ class Jellyfin:
|
||||
return []
|
||||
req_url = "%sLibrary/VirtualFolders?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
library_items = res.json()
|
||||
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'))
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
library_items = res.json()
|
||||
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 未获取到返回数据")
|
||||
return []
|
||||
if library_name and library_paths:
|
||||
librarys.append({
|
||||
'Name': library_name,
|
||||
'Path': library_paths
|
||||
})
|
||||
return librarys
|
||||
else:
|
||||
logger.error(f"Library/VirtualFolders 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/VirtualFolders 出错:" + str(e))
|
||||
return []
|
||||
@@ -109,12 +109,12 @@ class Jellyfin:
|
||||
user = self.user
|
||||
req_url = f"{self._host}Users/{user}/Views?api_key={self._apikey}"
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json().get("Items")
|
||||
else:
|
||||
logger.error(f"Users/Views 未获取到返回数据")
|
||||
return []
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json().get("Items")
|
||||
else:
|
||||
logger.error(f"Users/Views 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Views 出错:" + str(e))
|
||||
return []
|
||||
@@ -138,10 +138,10 @@ class Jellyfin:
|
||||
case _:
|
||||
continue
|
||||
image = self.__get_local_image_by_id(library.get("Id"))
|
||||
link = f"{self._playhost or self._host}web/index.html#" \
|
||||
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#" \
|
||||
else f"{self._playhost or self._host}web/index.html#!" \
|
||||
f"/tv.html?topParentId={library.get('Id')}"
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
@@ -163,12 +163,12 @@ class Jellyfin:
|
||||
return 0
|
||||
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return len(res.json())
|
||||
else:
|
||||
logger.error(f"Users 未获取到返回数据")
|
||||
return 0
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return len(res.json())
|
||||
else:
|
||||
logger.error(f"Users 未获取到返回数据")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users出错:" + str(e))
|
||||
return 0
|
||||
@@ -181,20 +181,20 @@ class Jellyfin:
|
||||
return None
|
||||
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
users = res.json()
|
||||
# 先查询是否有与当前用户名称匹配的
|
||||
if user_name:
|
||||
for user in users:
|
||||
if user.get("Name") == user_name:
|
||||
return user.get("Id")
|
||||
# 查询管理员
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
users = res.json()
|
||||
# 先查询是否有与当前用户名称匹配的
|
||||
if user_name:
|
||||
for user in users:
|
||||
if user.get("Policy", {}).get("IsAdministrator"):
|
||||
if user.get("Name") == user_name:
|
||||
return user.get("Id")
|
||||
else:
|
||||
logger.error(f"Users 未获取到返回数据")
|
||||
# 查询管理员
|
||||
for user in users:
|
||||
if user.get("Policy", {}).get("IsAdministrator"):
|
||||
return user.get("Id")
|
||||
else:
|
||||
logger.error(f"Users 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users出错:" + str(e))
|
||||
return None
|
||||
@@ -244,11 +244,11 @@ class Jellyfin:
|
||||
return None
|
||||
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json().get("Id")
|
||||
else:
|
||||
logger.error(f"System/Info 未获取到返回数据")
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json().get("Id")
|
||||
else:
|
||||
logger.error(f"System/Info 未获取到返回数据")
|
||||
except Exception as e:
|
||||
logger.error(f"连接System/Info出错:" + str(e))
|
||||
return None
|
||||
@@ -262,17 +262,17 @@ class Jellyfin:
|
||||
return schemas.Statistic()
|
||||
req_url = "%sItems/Counts?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return schemas.Statistic()
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return schemas.Statistic()
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Counts出错:" + str(e))
|
||||
return schemas.Statistic()
|
||||
@@ -287,14 +287,14 @@ class Jellyfin:
|
||||
"api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true") % (
|
||||
self._host, self.user, self._apikey, name)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == name and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
return res_item.get('Id')
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
for res_item in res_items:
|
||||
if res_item.get('Name') == name and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
return res_item.get('Id')
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
return None
|
||||
@@ -317,36 +317,36 @@ class Jellyfin:
|
||||
"api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true") % (
|
||||
self._host, self.user, self._apikey, title)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
ret_movies = []
|
||||
for item in res_items:
|
||||
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(mediaserver_item)
|
||||
continue
|
||||
if mediaserver_item.title == title and (
|
||||
not year or str(mediaserver_item.year) == str(year)):
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
ret_movies = []
|
||||
for item in res_items:
|
||||
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
continue
|
||||
if mediaserver_item.title == title and (
|
||||
not year or str(mediaserver_item.year) == str(year)):
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
return None
|
||||
@@ -387,25 +387,25 @@ class Jellyfin:
|
||||
try:
|
||||
req_url = "%sShows/%s/Episodes?season=%s&&userId=%s&isMissing=false&api_key=%s" % (
|
||||
self._host, item_id, season, self.user, self._apikey)
|
||||
with RequestUtils().get_res(req_url) as res_json:
|
||||
if res_json:
|
||||
tv_info = res_json.json()
|
||||
res_items = tv_info.get("Items")
|
||||
# 返回的季集信息
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
if season and season != season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if not season_episodes.get(season_index):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
return item_id, season_episodes
|
||||
res_json = RequestUtils().get_res(req_url)
|
||||
if res_json:
|
||||
tv_info = res_json.json()
|
||||
res_items = tv_info.get("Items")
|
||||
# 返回的季集信息
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
if not season_index:
|
||||
continue
|
||||
if season and season != season_index:
|
||||
continue
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
if not episode_index:
|
||||
continue
|
||||
if not season_episodes.get(season_index):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
return item_id, season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None, None
|
||||
@@ -458,13 +458,13 @@ class Jellyfin:
|
||||
_host = self._playhost
|
||||
req_url = "%sItems/%s/Images/%s" % (_host, item_id, image_type)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res and res.status_code != 404:
|
||||
logger.info(f"影片图片链接:{res.url}")
|
||||
return res.url
|
||||
else:
|
||||
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||
return None
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code != 404:
|
||||
logger.info(f"影片图片链接:{res.url}")
|
||||
return res.url
|
||||
else:
|
||||
logger.error("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Images出错:" + str(e))
|
||||
return None
|
||||
@@ -479,12 +479,12 @@ class Jellyfin:
|
||||
"""
|
||||
req_url = "%sItems/%s/Ancestors?api_key=%s" % (self._host, item_id, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res:
|
||||
return res.json()[index].get(key)
|
||||
else:
|
||||
logger.error(f"Items/Id/Ancestors 未获取到返回数据")
|
||||
return None
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json()[index].get(key)
|
||||
else:
|
||||
logger.error(f"Items/Id/Ancestors 未获取到返回数据")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id/Ancestors出错:" + str(e))
|
||||
return None
|
||||
@@ -497,11 +497,11 @@ class Jellyfin:
|
||||
return False
|
||||
req_url = "%sLibrary/Refresh?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
with RequestUtils().post_res(req_url) as res:
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
logger.info(f"刷新媒体库失败,无法连接Jellyfin!")
|
||||
res = RequestUtils().post_res(req_url)
|
||||
if res:
|
||||
return True
|
||||
else:
|
||||
logger.info(f"刷新媒体库失败,无法连接Jellyfin!")
|
||||
except Exception as e:
|
||||
logger.error(f"连接Library/Refresh出错:" + str(e))
|
||||
return False
|
||||
@@ -589,6 +589,8 @@ class Jellyfin:
|
||||
eventItem.item_id = message.get('ItemId')
|
||||
eventItem.tmdb_id = message.get('Provider_tmdb')
|
||||
eventItem.overview = message.get('Overview')
|
||||
eventItem.item_favorite = message.get('Favorite')
|
||||
eventItem.save_reason = message.get('SaveReason')
|
||||
eventItem.device_name = message.get('DeviceName')
|
||||
eventItem.user_name = message.get('NotificationUsername')
|
||||
eventItem.client = message.get('ClientName')
|
||||
@@ -611,6 +613,11 @@ class Jellyfin:
|
||||
eventItem.item_name = "%s %s" % (
|
||||
message.get('Name'), "(" + str(message.get('Year')) + ")")
|
||||
|
||||
playback_position_ticks = message.get('PlaybackPositionTicks')
|
||||
runtime_ticks = message.get('RunTimeTicks')
|
||||
if playback_position_ticks is not None and runtime_ticks is not None:
|
||||
eventItem.percentage = playback_position_ticks / runtime_ticks * 100
|
||||
|
||||
# 获取消息图片
|
||||
if eventItem.item_id:
|
||||
# 根据返回的item_id去调用媒体服务器获取
|
||||
@@ -632,23 +639,23 @@ class Jellyfin:
|
||||
req_url = "%sUsers/%s/Items/%s?api_key=%s" % (
|
||||
self._host, self.user, itemid, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res and res.status_code == 200:
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
return None
|
||||
@@ -663,17 +670,17 @@ class Jellyfin:
|
||||
yield None
|
||||
req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get("Items") or []
|
||||
for result in results:
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(result.get("Id")):
|
||||
yield item
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
results = res.json().get("Items") or []
|
||||
for result in results:
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(result.get("Id")):
|
||||
yield item
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
yield None
|
||||
@@ -761,50 +768,50 @@ class Jellyfin:
|
||||
req_url = (f"{self._host}Users/{user}/Items/Resume?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
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"))
|
||||
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
|
||||
with RequestUtils().get_res(image) as image_res:
|
||||
if not image_res or image_res.status_code == 404:
|
||||
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
|
||||
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 未获取到返回数据")
|
||||
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"))
|
||||
# 小部分剧集无[xxx-S01E01-thumb.jpg]图片
|
||||
image_res = RequestUtils().get_res(image)
|
||||
if not image_res or image_res.status_code == 404:
|
||||
image = self.generate_image_link(item.get("Id"), "Backdrop", False)
|
||||
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 []
|
||||
@@ -822,35 +829,35 @@ class Jellyfin:
|
||||
req_url = (f"{self._host}Users/{user}/Items/Latest?"
|
||||
f"Limit=100&MediaTypes=Video&api_key={self._apikey}&Fields=ProductionYear,Path")
|
||||
try:
|
||||
with RequestUtils().get_res(req_url) as res:
|
||||
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 未获取到返回数据")
|
||||
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 []
|
||||
|
||||
@@ -14,6 +14,10 @@ class PlexModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.plex = Plex()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Plex"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
from typing import List, Optional, Dict, Tuple, Generator, Any
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from plexapi import media, utils
|
||||
from plexapi import media
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
from app import schemas
|
||||
@@ -236,16 +236,19 @@ class Plex:
|
||||
if item_id:
|
||||
videos = self._plex.fetchItem(item_id)
|
||||
else:
|
||||
# 兼容年份为空的场景
|
||||
kwargs = {"year": year} if year else {}
|
||||
# 根据标题和年份模糊搜索,该结果不够准确
|
||||
videos = self._plex.library.search(title=title,
|
||||
year=year,
|
||||
libtype="show")
|
||||
libtype="show",
|
||||
**kwargs)
|
||||
if (not videos
|
||||
and original_title
|
||||
and str(original_title) != str(title)):
|
||||
videos = self._plex.library.search(title=original_title,
|
||||
year=year,
|
||||
libtype="show")
|
||||
libtype="show",
|
||||
**kwargs)
|
||||
|
||||
if not videos:
|
||||
return None, {}
|
||||
if isinstance(videos, list):
|
||||
@@ -375,26 +378,34 @@ class Plex:
|
||||
|
||||
@staticmethod
|
||||
def __get_ids(guids: List[Any]) -> dict:
|
||||
def parse_tmdb_id(value: str) -> (bool, int):
|
||||
"""尝试将TMDB ID字符串转换为整数。如果成功,返回(True, int),失败则返回(False, None)。"""
|
||||
try:
|
||||
int_value = int(value)
|
||||
return True, int_value
|
||||
except ValueError:
|
||||
return False, None
|
||||
|
||||
guid_mapping = {
|
||||
"imdb://": "imdb_id",
|
||||
"tmdb://": "tmdb_id",
|
||||
"tvdb://": "tvdb_id"
|
||||
}
|
||||
ids = {}
|
||||
for prefix, varname in guid_mapping.items():
|
||||
ids[varname] = None
|
||||
ids = {varname: None for varname in guid_mapping.values()}
|
||||
for guid in guids:
|
||||
guid_id = guid['id'] if isinstance(guid, dict) else guid.id
|
||||
for prefix, varname in guid_mapping.items():
|
||||
if isinstance(guid, dict):
|
||||
if guid['id'].startswith(prefix):
|
||||
# 找到匹配的ID
|
||||
ids[varname] = guid['id'][len(prefix):]
|
||||
break
|
||||
else:
|
||||
if guid.id.startswith(prefix):
|
||||
# 找到匹配的ID
|
||||
ids[varname] = guid.id[len(prefix):]
|
||||
break
|
||||
if guid_id.startswith(prefix):
|
||||
clean_id = guid_id[len(prefix):]
|
||||
if varname == "tmdb_id":
|
||||
# tmdb_id为int,Plex可能存在脏数据,特别处理tmdb_id
|
||||
success, parsed_id = parse_tmdb_id(clean_id)
|
||||
if success:
|
||||
ids[varname] = parsed_id
|
||||
else:
|
||||
ids[varname] = clean_id
|
||||
break
|
||||
|
||||
return ids
|
||||
|
||||
def get_items(self, parent: str) -> Generator:
|
||||
@@ -409,25 +420,29 @@ class Plex:
|
||||
section = self._plex.library.sectionByID(int(parent))
|
||||
if section:
|
||||
for item in section.all():
|
||||
if not item:
|
||||
try:
|
||||
if not item:
|
||||
continue
|
||||
ids = self.__get_ids(item.guids)
|
||||
path = None
|
||||
if item.locations:
|
||||
path = item.locations[0]
|
||||
yield schemas.MediaServerItem(
|
||||
server="plex",
|
||||
library=item.librarySectionID,
|
||||
item_id=item.key,
|
||||
item_type=item.type,
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids['tmdb_id'],
|
||||
imdbid=ids['imdb_id'],
|
||||
tvdbid=ids['tvdb_id'],
|
||||
path=path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"处理媒体项目时出错:{str(e)}, 跳过此项目。")
|
||||
continue
|
||||
ids = self.__get_ids(item.guids)
|
||||
path = None
|
||||
if item.locations:
|
||||
path = item.locations[0]
|
||||
yield schemas.MediaServerItem(
|
||||
server="plex",
|
||||
library=item.librarySectionID,
|
||||
item_id=item.key,
|
||||
item_type=item.type,
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids['tmdb_id'],
|
||||
imdbid=ids['imdb_id'],
|
||||
tvdbid=ids['tvdb_id'],
|
||||
path=path,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"获取媒体库列表出错:{str(err)}")
|
||||
yield None
|
||||
@@ -616,9 +631,8 @@ class Plex:
|
||||
return []
|
||||
# 媒体库白名单
|
||||
allow_library = ",".join([lib.id for lib in self.get_librarys()])
|
||||
query = {'contentDirectoryID': allow_library}
|
||||
path = '/hubs/continueWatching/items' + utils.joinArgs(query)
|
||||
items = self._plex.fetchItems(path, container_start=0, container_size=num)
|
||||
params = {'contentDirectoryID': allow_library}
|
||||
items = self._plex.fetchItems("/hubs/continueWatching/items", container_start=0, container_size=num, params=params)
|
||||
ret_resume = []
|
||||
for item in items:
|
||||
item_type = MediaType.MOVIE.value if item.TYPE == "movie" else MediaType.TV.value
|
||||
@@ -647,20 +661,65 @@ class Plex:
|
||||
"""
|
||||
if not self._plex:
|
||||
return None
|
||||
items = self._plex.fetchItems('/library/recentlyAdded', container_start=0, container_size=num)
|
||||
# 请求参数(除黑名单)
|
||||
allow_library = ",".join([lib.id for lib in self.get_librarys()])
|
||||
params = {
|
||||
"contentDirectoryID": allow_library,
|
||||
"count": num,
|
||||
"excludeContinueWatching": 1
|
||||
}
|
||||
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
|
||||
))
|
||||
sub_result = []
|
||||
offset = 0
|
||||
while True:
|
||||
if len(ret_resume) >= num:
|
||||
break
|
||||
# 获取所有资料库
|
||||
hubs = self._plex.fetchItems(
|
||||
'/hubs/promoted',
|
||||
container_start=offset,
|
||||
container_size=num,
|
||||
maxresults=num,
|
||||
params=params
|
||||
)
|
||||
if len(hubs) == 0:
|
||||
break
|
||||
|
||||
# 合并排序
|
||||
for hub in hubs:
|
||||
for item in hub.items:
|
||||
sub_result.append(item)
|
||||
sub_result.sort(key=lambda x: x.addedAt, reverse=True)
|
||||
|
||||
for item in sub_result:
|
||||
if len(ret_resume) >= num:
|
||||
break
|
||||
item_type, title, image = "", "", ""
|
||||
if item.TYPE == "movie":
|
||||
item_type = MediaType.MOVIE.value
|
||||
title = item.title
|
||||
image = item.posterUrl
|
||||
elif item.TYPE == "season":
|
||||
item_type = MediaType.TV.value
|
||||
title = "%s 第%s季" % (item.parentTitle, item.index)
|
||||
image = item.posterUrl
|
||||
elif item.TYPE == "episode":
|
||||
item_type = MediaType.TV.value
|
||||
title = "%s 第%s季 第%s集" % (item.grandparentTitle, item.parentIndex, item.index)
|
||||
thumb = (item.parentThumb or item.grandparentThumb or '').lstrip('/')
|
||||
image = (self._host + thumb + f"?X-Plex-Token={self._token}")
|
||||
elif item.TYPE == "show":
|
||||
item_type = MediaType.TV.value
|
||||
title = "%s 共%s季" % (item.title, item.seasonCount)
|
||||
image = item.posterUrl
|
||||
link = self.get_play_url(item.key)
|
||||
ret_resume.append(schemas.MediaServerPlayItem(
|
||||
id=item.key,
|
||||
title=title,
|
||||
subtitle=item.year,
|
||||
type=item_type,
|
||||
image=image,
|
||||
link=link
|
||||
))
|
||||
offset += num
|
||||
return ret_resume[:num]
|
||||
|
||||
@@ -23,6 +23,10 @@ class QbittorrentModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.qbittorrent = Qbittorrent()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Qbittorrent"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
@@ -188,7 +192,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = settings.SAVE_PATH / torrent.get('name')
|
||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
@@ -207,7 +211,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
if content_path:
|
||||
torrent_path = Path(content_path)
|
||||
else:
|
||||
torrent_path = settings.SAVE_PATH / torrent.get('name')
|
||||
torrent_path = torrent.get('save_path') / torrent.get('name')
|
||||
ret_torrents.append(TransferTorrent(
|
||||
title=torrent.get('name'),
|
||||
path=torrent_path,
|
||||
@@ -238,7 +242,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
return None
|
||||
return ret_torrents
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
def transfer_completed(self, hashs: str, path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
|
||||
@@ -16,6 +16,10 @@ class SlackModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.slack = Slack()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Slack"
|
||||
|
||||
def stop(self):
|
||||
self.slack.stop()
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ class SubtitleModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "站点字幕"
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ class SynologyChatModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.synologychat = SynologyChat()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Synology Chat"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ class TelegramModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.telegram = Telegram()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Telegram"
|
||||
|
||||
def stop(self):
|
||||
self.telegram.stop()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Tuple, Union
|
||||
from typing import Optional, List, Tuple, Union, Dict
|
||||
|
||||
import cn2an
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.modules.themoviedb.category import CategoryHelper
|
||||
from app.modules.themoviedb.scraper import TmdbScraper
|
||||
from app.modules.themoviedb.tmdb_cache import TmdbCache
|
||||
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||
from app.schemas import MediaPerson
|
||||
from app.schemas.types import MediaType, MediaImageType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
@@ -38,6 +39,10 @@ class TheMovieDbModule(_ModuleBase):
|
||||
self.category = CategoryHelper()
|
||||
self.scraper = TmdbScraper(self.tmdb)
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "TheMovieDb"
|
||||
|
||||
def stop(self):
|
||||
self.cache.save()
|
||||
self.tmdb.close()
|
||||
@@ -220,16 +225,24 @@ class TheMovieDbModule(_ModuleBase):
|
||||
"""
|
||||
return self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
|
||||
|
||||
def media_category(self) -> Optional[Dict[str, list]]:
|
||||
"""
|
||||
获取媒体分类
|
||||
:return: 获取二级分类配置字典项,需包括电影、电视剧
|
||||
"""
|
||||
return {
|
||||
MediaType.MOVIE.value: list(self.category.movie_categorys),
|
||||
MediaType.TV.value: list(self.category.tv_categorys)
|
||||
}
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
:param meta: 识别的元数据
|
||||
:reutrn: 媒体信息列表
|
||||
"""
|
||||
# 未启用时返回None
|
||||
if settings.RECOGNIZE_SOURCE != "themoviedb":
|
||||
if settings.SEARCH_SOURCE and "themoviedb" not in settings.SEARCH_SOURCE:
|
||||
return None
|
||||
|
||||
if not meta.name:
|
||||
return []
|
||||
if meta.type == MediaType.UNKNOWN and not meta.year:
|
||||
@@ -261,6 +274,17 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return medias
|
||||
return []
|
||||
|
||||
def search_persons(self, name: str) -> Optional[List[MediaPerson]]:
|
||||
"""
|
||||
搜索人物信息
|
||||
"""
|
||||
if not name:
|
||||
return []
|
||||
results = self.tmdb.search_persons(name)
|
||||
if results:
|
||||
return [MediaPerson(source='themoviedb', **person) for person in results]
|
||||
return []
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str,
|
||||
metainfo: MetaBase = None, force_nfo: bool = False, force_img: bool = False) -> None:
|
||||
"""
|
||||
@@ -309,7 +333,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str,
|
||||
page: int = 1) -> Optional[List[dict]]:
|
||||
page: int = 1) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
:param mtype: 媒体类型
|
||||
:param sort_by: 排序方式
|
||||
@@ -319,25 +343,31 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:return: 媒体信息列表
|
||||
"""
|
||||
if mtype == MediaType.MOVIE:
|
||||
return self.tmdb.discover_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
infos = self.tmdb.discover_movies(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
elif mtype == MediaType.TV:
|
||||
return self.tmdb.discover_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
infos = self.tmdb.discover_tvs(sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
else:
|
||||
return None
|
||||
return []
|
||||
if infos:
|
||||
return [MediaInfo(tmdb_info=info) for info in infos]
|
||||
return []
|
||||
|
||||
def tmdb_trending(self, page: int = 1) -> List[dict]:
|
||||
def tmdb_trending(self, page: int = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
TMDB流行趋势
|
||||
:param page: 第几页
|
||||
:return: TMDB信息列表
|
||||
"""
|
||||
return self.tmdb.trending.all_week(page=page)
|
||||
trending = self.tmdb.trending.all_week(page=page)
|
||||
if trending:
|
||||
return [MediaInfo(tmdb_info=info) for info in trending]
|
||||
return []
|
||||
|
||||
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
|
||||
"""
|
||||
@@ -445,64 +475,88 @@ class TheMovieDbModule(_ModuleBase):
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/{image_prefix}{image_path}"
|
||||
return None
|
||||
|
||||
def tmdb_movie_similar(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_movie_similar(self, tmdbid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询类似电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_movie_similar(tmdbid=tmdbid)
|
||||
similar = self.tmdb.get_movie_similar(tmdbid=tmdbid)
|
||||
if similar:
|
||||
return [MediaInfo(tmdb_info=info) for info in similar]
|
||||
return []
|
||||
|
||||
def tmdb_tv_similar(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_tv_similar(self, tmdbid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询类似电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_tv_similar(tmdbid=tmdbid)
|
||||
similar = self.tmdb.get_tv_similar(tmdbid=tmdbid)
|
||||
if similar:
|
||||
return [MediaInfo(tmdb_info=info) for info in similar]
|
||||
return []
|
||||
|
||||
def tmdb_movie_recommend(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_movie_recommend(self, tmdbid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询推荐电影
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_movie_recommend(tmdbid=tmdbid)
|
||||
recommend = self.tmdb.get_movie_recommend(tmdbid=tmdbid)
|
||||
if recommend:
|
||||
return [MediaInfo(tmdb_info=info) for info in recommend]
|
||||
return []
|
||||
|
||||
def tmdb_tv_recommend(self, tmdbid: int) -> List[dict]:
|
||||
def tmdb_tv_recommend(self, tmdbid: int) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询推荐电视剧
|
||||
:param tmdbid: TMDBID
|
||||
"""
|
||||
return self.tmdb.get_tv_recommend(tmdbid=tmdbid)
|
||||
recommend = self.tmdb.get_tv_recommend(tmdbid=tmdbid)
|
||||
if recommend:
|
||||
return [MediaInfo(tmdb_info=info) for info in recommend]
|
||||
return []
|
||||
|
||||
def tmdb_movie_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
def tmdb_movie_credits(self, tmdbid: int, page: int = 1) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询电影演职员表
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)
|
||||
credit_infos = self.tmdb.get_movie_credits(tmdbid=tmdbid, page=page)
|
||||
if credit_infos:
|
||||
return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos]
|
||||
return []
|
||||
|
||||
def tmdb_tv_credits(self, tmdbid: int, page: int = 1) -> List[dict]:
|
||||
def tmdb_tv_credits(self, tmdbid: int, page: int = 1) -> List[schemas.MediaPerson]:
|
||||
"""
|
||||
根据TMDBID查询电视剧演职员表
|
||||
:param tmdbid: TMDBID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)
|
||||
credit_infos = self.tmdb.get_tv_credits(tmdbid=tmdbid, page=page)
|
||||
if credit_infos:
|
||||
return [schemas.MediaPerson(source="themoviedb", **info) for info in credit_infos]
|
||||
return []
|
||||
|
||||
def tmdb_person_detail(self, person_id: int) -> dict:
|
||||
def tmdb_person_detail(self, person_id: int) -> schemas.MediaPerson:
|
||||
"""
|
||||
根据TMDBID查询人物详情
|
||||
:param person_id: 人物ID
|
||||
"""
|
||||
return self.tmdb.get_person_detail(person_id=person_id)
|
||||
detail = self.tmdb.get_person_detail(person_id=person_id)
|
||||
if detail:
|
||||
return schemas.MediaPerson(source="themoviedb", **detail)
|
||||
return schemas.MediaPerson()
|
||||
|
||||
def tmdb_person_credits(self, person_id: int, page: int = 1) -> List[dict]:
|
||||
def tmdb_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
|
||||
"""
|
||||
根据TMDBID查询人物参演作品
|
||||
:param person_id: 人物ID
|
||||
:param page: 页码
|
||||
"""
|
||||
return self.tmdb.get_person_credits(person_id=person_id, page=page)
|
||||
infos = self.tmdb.get_person_credits(person_id=person_id, page=page)
|
||||
if infos:
|
||||
return [MediaInfo(tmdb_info=tmdbinfo) for tmdbinfo in infos]
|
||||
return []
|
||||
|
||||
def clear_cache(self):
|
||||
"""
|
||||
|
||||
@@ -15,7 +15,6 @@ class CategoryHelper(metaclass=Singleton):
|
||||
_categorys = {}
|
||||
_movie_categorys = {}
|
||||
_tv_categorys = {}
|
||||
_anime_categorys = {}
|
||||
|
||||
def __init__(self):
|
||||
self._category_path: Path = settings.CONFIG_PATH / "category.yaml"
|
||||
@@ -25,9 +24,6 @@ class CategoryHelper(metaclass=Singleton):
|
||||
"""
|
||||
初始化
|
||||
"""
|
||||
# 二级分类策略关闭
|
||||
if not settings.LIBRARY_CATEGORY:
|
||||
return
|
||||
try:
|
||||
if not self._category_path.exists():
|
||||
shutil.copy(settings.INNER_CONFIG_PATH / "category.yaml", self._category_path)
|
||||
@@ -44,7 +40,6 @@ class CategoryHelper(metaclass=Singleton):
|
||||
if self._categorys:
|
||||
self._movie_categorys = self._categorys.get('movie')
|
||||
self._tv_categorys = self._categorys.get('tv')
|
||||
self._anime_categorys = self._categorys.get('anime')
|
||||
logger.info(f"已加载二级分类策略 category.yaml")
|
||||
|
||||
@property
|
||||
@@ -83,15 +78,6 @@ class CategoryHelper(metaclass=Singleton):
|
||||
return []
|
||||
return self._tv_categorys.keys()
|
||||
|
||||
@property
|
||||
def anime_categorys(self) -> list:
|
||||
"""
|
||||
获取动漫分类清单
|
||||
"""
|
||||
if not self._anime_categorys:
|
||||
return []
|
||||
return self._anime_categorys.keys()
|
||||
|
||||
def get_movie_category(self, tmdb_info) -> str:
|
||||
"""
|
||||
判断电影的分类
|
||||
@@ -106,10 +92,6 @@ class CategoryHelper(metaclass=Singleton):
|
||||
:param tmdb_info: 识别的TMDB中的信息
|
||||
:return: 二级分类的名称
|
||||
"""
|
||||
genre_ids = tmdb_info.get("genre_ids") or []
|
||||
if self._anime_categorys and genre_ids \
|
||||
and set(genre_ids).intersection(set(settings.ANIME_GENREIDS)):
|
||||
return self.get_category(self._anime_categorys, tmdb_info)
|
||||
return self.get_category(self._tv_categorys, tmdb_info)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -95,6 +95,14 @@ class TmdbApi:
|
||||
ret_infos.append(tv)
|
||||
return ret_infos
|
||||
|
||||
def search_persons(self, name: str) -> List[dict]:
|
||||
"""
|
||||
查询模糊匹配的所有人物TMDB信息
|
||||
"""
|
||||
if not name:
|
||||
return []
|
||||
return self.search.people(term=name) or []
|
||||
|
||||
@staticmethod
|
||||
def __compare_names(file_name: str, tmdb_names: list) -> bool:
|
||||
"""
|
||||
@@ -168,7 +176,7 @@ class TmdbApi:
|
||||
return None
|
||||
# TMDB搜索
|
||||
info = {}
|
||||
if mtype == MediaType.MOVIE:
|
||||
if mtype != MediaType.TV:
|
||||
year_range = [year]
|
||||
if year:
|
||||
year_range.append(str(int(year) + 1))
|
||||
@@ -522,7 +530,7 @@ class TmdbApi:
|
||||
tmdbid: int) -> dict:
|
||||
"""
|
||||
给定TMDB号,查询一条媒体信息
|
||||
:param mtype: 类型:电影、电视剧、动漫,为空时都查(此时用不上年份)
|
||||
:param mtype: 类型:电影、电视剧,为空时都查(此时用不上年份)
|
||||
:param tmdbid: TMDB的ID,有tmdbid时优先使用tmdbid,否则使用年份和标题
|
||||
"""
|
||||
|
||||
@@ -751,10 +759,10 @@ class TmdbApi:
|
||||
if not self.movie:
|
||||
return {}
|
||||
try:
|
||||
logger.info("正在查询TMDB电影:%s ..." % tmdbid)
|
||||
logger.debug("正在查询TMDB电影:%s ..." % tmdbid)
|
||||
tmdbinfo = self.movie.details(tmdbid, append_to_response)
|
||||
if tmdbinfo:
|
||||
logger.info(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
|
||||
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
|
||||
return tmdbinfo or {}
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -934,10 +942,10 @@ class TmdbApi:
|
||||
if not self.tv:
|
||||
return {}
|
||||
try:
|
||||
logger.info("正在查询TMDB电视剧:%s ..." % tmdbid)
|
||||
logger.debug("正在查询TMDB电视剧:%s ..." % tmdbid)
|
||||
tmdbinfo = self.tv.details(tv_id=tmdbid, append_to_response=append_to_response)
|
||||
if tmdbinfo:
|
||||
logger.info(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
|
||||
logger.debug(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
|
||||
return tmdbinfo or {}
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1010,7 +1018,7 @@ class TmdbApi:
|
||||
if not self.season:
|
||||
return {}
|
||||
try:
|
||||
logger.info("正在查询TMDB电视剧:%s,季:%s ..." % (tmdbid, season))
|
||||
logger.debug("正在查询TMDB电视剧:%s,季:%s ..." % (tmdbid, season))
|
||||
tmdbinfo = self.season.details(tv_id=tmdbid, season_num=season)
|
||||
return tmdbinfo or {}
|
||||
except Exception as e:
|
||||
@@ -1027,7 +1035,7 @@ class TmdbApi:
|
||||
if not self.episode:
|
||||
return {}
|
||||
try:
|
||||
logger.info("正在查询TMDB集详情:%s,季:%s,集:%s ..." % (tmdbid, season, episode))
|
||||
logger.debug("正在查询TMDB集详情:%s,季:%s,集:%s ..." % (tmdbid, season, episode))
|
||||
tmdbinfo = self.episode.details(tv_id=tmdbid, season_num=season, episode_num=episode)
|
||||
return tmdbinfo or {}
|
||||
except Exception as e:
|
||||
@@ -1043,7 +1051,7 @@ class TmdbApi:
|
||||
if not self.discover:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在发现电影:{kwargs}...")
|
||||
logger.debug(f"正在发现电影:{kwargs}...")
|
||||
tmdbinfo = self.discover.discover_movies(kwargs)
|
||||
if tmdbinfo:
|
||||
for info in tmdbinfo:
|
||||
@@ -1062,7 +1070,7 @@ class TmdbApi:
|
||||
if not self.discover:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在发现电视剧:{kwargs}...")
|
||||
logger.debug(f"正在发现电视剧:{kwargs}...")
|
||||
tmdbinfo = self.discover.discover_tv_shows(kwargs)
|
||||
if tmdbinfo:
|
||||
for info in tmdbinfo:
|
||||
@@ -1079,7 +1087,7 @@ class TmdbApi:
|
||||
if not self.movie:
|
||||
return {}
|
||||
try:
|
||||
logger.info(f"正在获取电影图片:{tmdbid}...")
|
||||
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
||||
return self.movie.images(movie_id=tmdbid) or {}
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1092,7 +1100,7 @@ class TmdbApi:
|
||||
if not self.tv:
|
||||
return {}
|
||||
try:
|
||||
logger.info(f"正在获取电视剧图片:{tmdbid}...")
|
||||
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
||||
return self.tv.images(tv_id=tmdbid) or {}
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1105,7 +1113,7 @@ class TmdbApi:
|
||||
if not self.movie:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取相似电影:{tmdbid}...")
|
||||
logger.debug(f"正在获取相似电影:{tmdbid}...")
|
||||
return self.movie.similar(movie_id=tmdbid) or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1118,7 +1126,7 @@ class TmdbApi:
|
||||
if not self.tv:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取相似电视剧:{tmdbid}...")
|
||||
logger.debug(f"正在获取相似电视剧:{tmdbid}...")
|
||||
return self.tv.similar(tv_id=tmdbid) or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1131,7 +1139,7 @@ class TmdbApi:
|
||||
if not self.movie:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取推荐电影:{tmdbid}...")
|
||||
logger.debug(f"正在获取推荐电影:{tmdbid}...")
|
||||
return self.movie.recommendations(movie_id=tmdbid) or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1144,7 +1152,7 @@ class TmdbApi:
|
||||
if not self.tv:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取推荐电视剧:{tmdbid}...")
|
||||
logger.debug(f"正在获取推荐电视剧:{tmdbid}...")
|
||||
return self.tv.recommendations(tv_id=tmdbid) or []
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1157,7 +1165,7 @@ class TmdbApi:
|
||||
if not self.movie:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取电影演职人员:{tmdbid}...")
|
||||
logger.debug(f"正在获取电影演职人员:{tmdbid}...")
|
||||
info = self.movie.credits(movie_id=tmdbid) or {}
|
||||
cast = info.get('cast') or []
|
||||
if cast:
|
||||
@@ -1174,7 +1182,7 @@ class TmdbApi:
|
||||
if not self.tv:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取电视剧演职人员:{tmdbid}...")
|
||||
logger.debug(f"正在获取电视剧演职人员:{tmdbid}...")
|
||||
info = self.tv.credits(tv_id=tmdbid) or {}
|
||||
cast = info.get('cast') or []
|
||||
if cast:
|
||||
@@ -1211,7 +1219,7 @@ class TmdbApi:
|
||||
if not self.person:
|
||||
return {}
|
||||
try:
|
||||
logger.info(f"正在获取人物详情:{person_id}...")
|
||||
logger.debug(f"正在获取人物详情:{person_id}...")
|
||||
return self.person.details(person_id=person_id) or {}
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
@@ -1224,10 +1232,14 @@ class TmdbApi:
|
||||
if not self.person:
|
||||
return []
|
||||
try:
|
||||
logger.info(f"正在获取人物参演作品:{person_id}...")
|
||||
info = self.person.movie_credits(person_id=person_id) or {}
|
||||
cast = info.get('cast') or []
|
||||
logger.debug(f"正在获取人物参演作品:{person_id}...")
|
||||
movies = self.person.movie_credits(person_id=person_id) or {}
|
||||
tvs = self.person.tv_credits(person_id=person_id) or {}
|
||||
cast = (movies.get('cast') or []) + (tvs.get('cast') or [])
|
||||
if cast:
|
||||
# 按年份降序排列
|
||||
cast = sorted(cast, key=lambda x: x.get('release_date') or x.get('first_air_date') or '1900-01-01',
|
||||
reverse=True)
|
||||
return cast[(page - 1) * count: page * count]
|
||||
return []
|
||||
except Exception as e:
|
||||
@@ -1250,7 +1262,7 @@ class TmdbApi:
|
||||
return {}
|
||||
episode_years = {}
|
||||
for episode_group in episode_groups:
|
||||
logger.info(f"正在获取剧集组年份:{episode_group.get('id')}...")
|
||||
logger.debug(f"正在获取剧集组年份:{episode_group.get('id')}...")
|
||||
if episode_group.get('type') != 6:
|
||||
# 只处理剧集部分
|
||||
continue
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from ..tmdb import TMDb
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
try:
|
||||
from urllib import urlencode
|
||||
@@ -12,14 +13,16 @@ class Discover(TMDb):
|
||||
"tv": "/discover/tv"
|
||||
}
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
||||
def discover_movies(self, params):
|
||||
"""
|
||||
Discover movies by different types of data like average rating, number of votes, genres and certifications.
|
||||
:param params: dict
|
||||
:return:
|
||||
"""
|
||||
return self._request_obj(self._urls["movies"], urlencode(params), key="results")
|
||||
return self._request_obj(self._urls["movies"], urlencode(params), key="results", call_cached=False)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
||||
def discover_tv_shows(self, params):
|
||||
"""
|
||||
Discover TV shows by different types of data like average rating, number of votes, genres,
|
||||
@@ -27,4 +30,4 @@ class Discover(TMDb):
|
||||
:param params: dict
|
||||
:return:
|
||||
"""
|
||||
return self._request_obj(self._urls["tv"], urlencode(params), key="results")
|
||||
return self._request_obj(self._urls["tv"], urlencode(params), key="results", call_cached=False)
|
||||
|
||||
@@ -14,7 +14,6 @@ class Person(TMDb):
|
||||
"translations": "/person/%s/translations",
|
||||
"latest": "/person/latest",
|
||||
"popular": "/person/popular",
|
||||
"search_people": "/search/person",
|
||||
}
|
||||
|
||||
def details(self, person_id, append_to_response="videos,images"):
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from ..tmdb import TMDb
|
||||
|
||||
|
||||
class Trending(TMDb):
|
||||
_urls = {"trending": "/trending/%s/%s"}
|
||||
|
||||
@cached(cache=TTLCache(maxsize=1, ttl=43200))
|
||||
def _trending(self, media_type="all", time_window="day", page=1):
|
||||
"""
|
||||
Get trending, TTLCache 12 hours
|
||||
"""
|
||||
return self._request_obj(
|
||||
self._urls["trending"] % (media_type, time_window),
|
||||
params="page=%s" % page,
|
||||
key="results",
|
||||
call_cached=False
|
||||
)
|
||||
|
||||
def all_day(self, page=1):
|
||||
|
||||
@@ -141,7 +141,7 @@ class TMDb(object):
|
||||
def cached_request(self, method, url, data, json,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
缓存请求,时间默认1天,None不缓存
|
||||
缓存请求
|
||||
"""
|
||||
return self.request(method, url, data, json)
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ class TheTvDbModule(_ModuleBase):
|
||||
select_first=True,
|
||||
proxies=settings.PROXY)
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "TheTvDb"
|
||||
|
||||
def stop(self):
|
||||
self.tvdb.close()
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ class TransmissionModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.transmission = Transmission()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Transmission"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
@@ -226,7 +230,7 @@ class TransmissionModule(_ModuleBase):
|
||||
return None
|
||||
return ret_torrents
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None,
|
||||
def transfer_completed(self, hashs: str, path: Path = None,
|
||||
downloader: str = settings.DEFAULT_DOWNLOADER) -> None:
|
||||
"""
|
||||
转移完成后的处理
|
||||
@@ -237,7 +241,14 @@ class TransmissionModule(_ModuleBase):
|
||||
"""
|
||||
if downloader != "transmission":
|
||||
return None
|
||||
self.transmission.set_torrent_tag(ids=hashs, tags=['已整理'])
|
||||
# 获取原标签
|
||||
org_tags = self.transmission.get_torrent_tags(ids=hashs)
|
||||
# 种子打上已整理标签
|
||||
if org_tags:
|
||||
tags = org_tags + ['已整理']
|
||||
else:
|
||||
tags = ['已整理']
|
||||
self.transmission.set_torrent_tag(ids=hashs, tags=tags)
|
||||
# 移动模式删除种子
|
||||
if settings.TRANSFER_TYPE == "move":
|
||||
if self.remove_torrents(hashs):
|
||||
|
||||
@@ -2,7 +2,7 @@ from typing import Optional, Union, Tuple, List, Dict
|
||||
|
||||
import transmission_rpc
|
||||
from transmission_rpc import Client, Torrent, File
|
||||
from transmission_rpc.session import SessionStats
|
||||
from transmission_rpc.session import SessionStats, Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
@@ -130,21 +130,38 @@ class Transmission:
|
||||
logger.error(f"获取正在下载的种子列表出错:{str(err)}")
|
||||
return None
|
||||
|
||||
def set_torrent_tag(self, ids: str, tags: list) -> bool:
|
||||
def set_torrent_tag(self, ids: str, tags: list, org_tags: list = None) -> bool:
|
||||
"""
|
||||
设置种子标签
|
||||
设置种子标签,注意TR默认会覆盖原有标签,如需追加需传入原有标签
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
if not ids or not tags:
|
||||
return False
|
||||
try:
|
||||
self.trc.change_torrent(labels=tags, ids=ids)
|
||||
self.trc.change_torrent(labels=list(set((org_tags or []) + tags)), ids=ids)
|
||||
return True
|
||||
except Exception as err:
|
||||
logger.error(f"设置种子标签出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_torrent_tags(self, ids: str) -> List[str]:
|
||||
"""
|
||||
获取所有种子标签
|
||||
"""
|
||||
if not self.trc:
|
||||
return []
|
||||
try:
|
||||
torrent = self.trc.get_torrents(ids=ids, arguments=self._trarg)
|
||||
if torrent:
|
||||
labels = [str(tag).strip()
|
||||
for tag in torrent.labels] if hasattr(torrent, "labels") else []
|
||||
return labels
|
||||
except Exception as err:
|
||||
logger.error(f"获取种子标签出错:{str(err)}")
|
||||
return []
|
||||
return []
|
||||
|
||||
def add_torrent(self, content: Union[str, bytes],
|
||||
is_paused: bool = False,
|
||||
download_dir: str = None,
|
||||
@@ -397,15 +414,15 @@ class Transmission:
|
||||
logger.error(f"修改tracker出错:{str(err)}")
|
||||
return False
|
||||
|
||||
def get_session(self) -> Dict[str, Union[int, bool, str]]:
|
||||
def get_session(self) -> Optional[Session]:
|
||||
"""
|
||||
获取Transmission当前的会话信息和配置设置
|
||||
:return dict or False
|
||||
:return dict
|
||||
"""
|
||||
if not self.trc:
|
||||
return False
|
||||
return None
|
||||
try:
|
||||
return self.trc.get_session()
|
||||
except Exception as err:
|
||||
logger.error(f"获取session出错:{str(err)}")
|
||||
return False
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,10 @@ class VoceChatModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.vocechat = VoceChat()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "VoceChat"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ class WechatModule(_ModuleBase):
|
||||
def init_module(self) -> None:
|
||||
self.wechat = WeChat()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "微信"
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -71,18 +71,18 @@ class WeChat:
|
||||
return None
|
||||
try:
|
||||
token_url = self._token_url % (self._corpid, self._appsecret)
|
||||
with RequestUtils().get_res(token_url) as res:
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
if ret_json.get('errcode') == 0:
|
||||
self._access_token = ret_json.get('access_token')
|
||||
self._expires_in = ret_json.get('expires_in')
|
||||
self._access_token_time = datetime.now()
|
||||
elif res is not None:
|
||||
logger.error(f"获取微信access_token失败,错误码:{res.status_code},错误原因:{res.reason}")
|
||||
else:
|
||||
logger.error(f"获取微信access_token失败,未获取到返回信息")
|
||||
raise Exception("获取微信access_token失败,网络连接失败")
|
||||
res = RequestUtils().get_res(token_url)
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
if ret_json.get('errcode') == 0:
|
||||
self._access_token = ret_json.get('access_token')
|
||||
self._expires_in = ret_json.get('expires_in')
|
||||
self._access_token_time = datetime.now()
|
||||
elif res is not None:
|
||||
logger.error(f"获取微信access_token失败,错误码:{res.status_code},错误原因:{res.reason}")
|
||||
else:
|
||||
logger.error(f"获取微信access_token失败,未获取到返回信息")
|
||||
raise Exception("获取微信access_token失败,网络连接失败")
|
||||
except Exception as e:
|
||||
logger.error(f"获取微信access_token失败,错误信息:{str(e)}")
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Dict, Tuple
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
@@ -55,6 +55,13 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self) -> bool:
|
||||
"""
|
||||
获取插件运行状态
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
@@ -84,19 +91,6 @@ 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]]:
|
||||
"""
|
||||
@@ -113,10 +107,52 @@ class _PluginBase(metaclass=ABCMeta):
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_state(self) -> bool:
|
||||
def get_service(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件运行状态
|
||||
注册插件公共服务
|
||||
[{
|
||||
"id": "服务ID",
|
||||
"name": "服务名称",
|
||||
"trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 定时器参数
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_dashboard(self, key: str, **kwargs) -> Optional[Tuple[Dict[str, Any], Dict[str, Any], List[dict]]]:
|
||||
"""
|
||||
获取插件仪表盘页面,需要返回:1、仪表板col配置字典;2、全局配置(自动刷新等);3、仪表板页面元素配置json(含数据)
|
||||
1、col配置参考:
|
||||
{
|
||||
"cols": 12, "md": 6
|
||||
}
|
||||
2、全局配置参考:
|
||||
{
|
||||
"refresh": 10, // 自动刷新时间,单位秒
|
||||
"border": True, // 是否显示边框,默认True,为False时取消组件边框和边距,由插件自行控制
|
||||
"title": "组件标题", // 组件标题,如有将显示该标题,否则显示插件名称
|
||||
"subtitle": "组件子标题", // 组件子标题,缺省时不展示子标题
|
||||
}
|
||||
3、页面配置使用Vuetify组件拼装,参考:https://vuetifyjs.com/
|
||||
|
||||
kwargs参数可获取的值:1、user_agent:浏览器UA
|
||||
|
||||
:param key: 仪表盘key,根据指定的key返回相应的仪表盘数据,缺省时返回一个固定的仪表盘数据(兼容旧版)
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_dashboard_meta(self) -> Optional[List[Dict[str, str]]]:
|
||||
"""
|
||||
获取插件仪表盘元信息
|
||||
返回示例:
|
||||
[{
|
||||
"key": "dashboard1", // 仪表盘的key,在当前插件范围唯一
|
||||
"name": "仪表盘1" // 仪表盘的名称
|
||||
}, {
|
||||
"key": "dashboard2",
|
||||
"name": "仪表盘2"
|
||||
}]
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
104
app/scheduler.py
104
app/scheduler.py
@@ -19,7 +19,9 @@ 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.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.timer import TimerUtils
|
||||
|
||||
@@ -46,6 +48,8 @@ class Scheduler(metaclass=Singleton):
|
||||
_lock = threading.Lock()
|
||||
# 各服务的运行状态
|
||||
_jobs = {}
|
||||
# 用户认证失败次数
|
||||
_auth_count = 0
|
||||
|
||||
def __init__(self):
|
||||
self.init()
|
||||
@@ -62,21 +66,56 @@ class Scheduler(metaclass=Singleton):
|
||||
TorrentsChain().clear_cache()
|
||||
SchedulerChain().clear_cache()
|
||||
|
||||
def user_auth():
|
||||
"""
|
||||
用户认证检查
|
||||
"""
|
||||
if SitesHelper().auth_level >= 2:
|
||||
return
|
||||
# 最大重试次数
|
||||
__max_try__ = 30
|
||||
if self._auth_count > __max_try__:
|
||||
SchedulerChain().messagehelper.put(title=f"用户认证失败",
|
||||
message="用户认证失败次数过多,将不再尝试认证!",
|
||||
role="system")
|
||||
return
|
||||
logger.info("用户未认证,正在尝试重新认证...")
|
||||
status, msg = SitesHelper().check_user()
|
||||
if status:
|
||||
self._auth_count = 0
|
||||
logger.info(f"{msg} 用户认证成功")
|
||||
SchedulerChain().post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title="MoviePilot用户认证成功",
|
||||
text=f"使用站点:{msg}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._auth_count += 1
|
||||
logger.error(f"用户认证失败:{msg},共失败 {self._auth_count} 次")
|
||||
if self._auth_count >= __max_try__:
|
||||
logger.error("用户认证失败次数过多,将不再尝试认证!")
|
||||
|
||||
# 各服务的运行状态
|
||||
self._jobs = {
|
||||
"cookiecloud": {
|
||||
"name": "同步CookieCloud站点",
|
||||
"func": SiteChain().sync_cookies,
|
||||
"running": False,
|
||||
},
|
||||
"mediaserver_sync": {
|
||||
"name": "同步媒体服务器",
|
||||
"func": MediaServerChain().sync,
|
||||
"running": False,
|
||||
},
|
||||
"subscribe_tmdb": {
|
||||
"name": "订阅元数据更新",
|
||||
"func": SubscribeChain().check,
|
||||
"running": False,
|
||||
},
|
||||
"subscribe_search": {
|
||||
"name": "订阅搜索补全",
|
||||
"func": SubscribeChain().search,
|
||||
"running": False,
|
||||
"kwargs": {
|
||||
@@ -84,6 +123,7 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
},
|
||||
"new_subscribe_search": {
|
||||
"name": "新增订阅搜索",
|
||||
"func": SubscribeChain().search,
|
||||
"running": False,
|
||||
"kwargs": {
|
||||
@@ -91,16 +131,34 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
},
|
||||
"subscribe_refresh": {
|
||||
"name": "订阅刷新",
|
||||
"func": SubscribeChain().refresh,
|
||||
"running": False,
|
||||
},
|
||||
"transfer": {
|
||||
"name": "下载文件整理",
|
||||
"func": TransferChain().process,
|
||||
"running": False,
|
||||
},
|
||||
"clear_cache": {
|
||||
"name": "缓存清理",
|
||||
"func": clear_cache,
|
||||
"running": False,
|
||||
},
|
||||
"user_auth": {
|
||||
"name": "用户认证检查",
|
||||
"func": user_auth,
|
||||
"running": False,
|
||||
},
|
||||
"scheduler_job": {
|
||||
"name": "公共定时服务",
|
||||
"func": SchedulerChain().scheduler_job,
|
||||
"running": False,
|
||||
},
|
||||
"random_wallpager": {
|
||||
"name": "壁纸缓存",
|
||||
"func": TmdbChain().get_random_wallpager,
|
||||
"running": False,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,17 +289,27 @@ class Scheduler(metaclass=Singleton):
|
||||
|
||||
# 后台刷新TMDB壁纸
|
||||
self._scheduler.add_job(
|
||||
TmdbChain().get_random_wallpager,
|
||||
self.start,
|
||||
"interval",
|
||||
id="random_wallpager",
|
||||
name="壁纸缓存",
|
||||
minutes=30,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=3)
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
kwargs={
|
||||
'job_id': 'random_wallpager'
|
||||
}
|
||||
)
|
||||
|
||||
# 公共定时服务
|
||||
self._scheduler.add_job(
|
||||
SchedulerChain().scheduler_job,
|
||||
self.start,
|
||||
"interval",
|
||||
minutes=10
|
||||
id="scheduler_job",
|
||||
name="公共定时服务",
|
||||
minutes=10,
|
||||
kwargs={
|
||||
'job_id': 'scheduler_job'
|
||||
}
|
||||
)
|
||||
|
||||
# 缓存清理服务,每隔24小时
|
||||
@@ -256,6 +324,18 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
|
||||
# 定时检查用户认证,每隔10分钟
|
||||
self._scheduler.add_job(
|
||||
self.start,
|
||||
"interval",
|
||||
id="user_auth",
|
||||
name="用户认证检查",
|
||||
minutes=10,
|
||||
kwargs={
|
||||
'job_id': 'user_auth'
|
||||
}
|
||||
)
|
||||
|
||||
# 注册插件公共服务
|
||||
for pid in PluginManager().get_running_plugin_ids():
|
||||
self.update_plugin_job(pid)
|
||||
@@ -275,8 +355,9 @@ class Scheduler(metaclass=Singleton):
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job_name = job.get("name")
|
||||
if job.get("running"):
|
||||
logger.warning(f"定时任务 {job_id} 正在运行 ...")
|
||||
logger.warning(f"定时任务 {job_id} - {job_name} 正在运行 ...")
|
||||
return
|
||||
self._jobs[job_id]["running"] = True
|
||||
# 开始运行
|
||||
@@ -285,7 +366,10 @@ class Scheduler(metaclass=Singleton):
|
||||
kwargs = job.get("kwargs") or {}
|
||||
job["func"](*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.error(f"定时任务 {job_id} 执行失败:{str(e)} - {traceback.format_exc()}")
|
||||
logger.error(f"定时任务 {job_name} 执行失败:{str(e)} - {traceback.format_exc()}")
|
||||
SchedulerChain().messagehelper.put(title=f"{job_name} 执行失败",
|
||||
message=str(e),
|
||||
role="system")
|
||||
# 运行结束
|
||||
with self._lock:
|
||||
try:
|
||||
@@ -336,6 +420,9 @@ class Scheduler(metaclass=Singleton):
|
||||
logger.info(f"注册插件{plugin_name}服务:{service['name']} - {service['trigger']}")
|
||||
except Exception as e:
|
||||
logger.error(f"注册插件{plugin_name}服务失败:{str(e)} - {service}")
|
||||
SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务注册失败",
|
||||
message=str(e),
|
||||
role="system")
|
||||
|
||||
def remove_plugin_job(self, pid: str):
|
||||
"""
|
||||
@@ -357,6 +444,9 @@ class Scheduler(metaclass=Singleton):
|
||||
logger.info(f"移除插件服务({plugin_name}):{service.get('name')}")
|
||||
except Exception as e:
|
||||
logger.error(f"移除插件服务失败:{str(e)} - {job_id}: {service}")
|
||||
SchedulerChain().messagehelper.put(title=f"插件 {plugin_name} 服务移除失败",
|
||||
message=str(e),
|
||||
role="system")
|
||||
|
||||
def list(self) -> List[schemas.ScheduleInfo]:
|
||||
"""
|
||||
@@ -414,10 +504,12 @@ class Scheduler(metaclass=Singleton):
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
logger.info("正在停止定时任务...")
|
||||
self._event.set()
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
logger.info("定时任务停止完成")
|
||||
except Exception as e:
|
||||
logger.error(f"停止定时任务失败::{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user