mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-14 07:26:50 +00:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f96a562d4 | ||
|
|
cefbd70469 | ||
|
|
30c9c66087 | ||
|
|
1ecbc2f0be | ||
|
|
884a0feb62 | ||
|
|
5f44f07515 | ||
|
|
a902b79684 | ||
|
|
4e13f59b36 | ||
|
|
cbccac87f0 | ||
|
|
eb3c09a3d3 | ||
|
|
2a9a36ac88 | ||
|
|
af2f52a050 | ||
|
|
7a61fa1ee2 | ||
|
|
ac3009d58f | ||
|
|
e835feb056 | ||
|
|
cd391d14f9 | ||
|
|
d7844968ab | ||
|
|
70ea398f14 | ||
|
|
860d55a0e2 | ||
|
|
0e35cec6e2 | ||
|
|
5778e86260 | ||
|
|
967d0b1205 | ||
|
|
0b2d419000 | ||
|
|
149104063c | ||
|
|
498168a2d3 | ||
|
|
88e307416d | ||
|
|
3bb2eedb33 | ||
|
|
36c046ad6a | ||
|
|
85396df221 | ||
|
|
2f0f58783e | ||
|
|
2d989d4229 | ||
|
|
ecc8b6b385 | ||
|
|
aa90c5d5c0 | ||
|
|
5f7d93f170 | ||
|
|
0fbe51f257 | ||
|
|
be941ebdd1 | ||
|
|
4d900c2eb0 | ||
|
|
93c473afe7 | ||
|
|
4c9a66f586 | ||
|
|
375e16e0dc | ||
|
|
91085d13a3 | ||
|
|
3f83894dc6 | ||
|
|
5946684ee6 | ||
|
|
7e3f25879f | ||
|
|
48dcc3ee1b | ||
|
|
fca0a4b511 | ||
|
|
d6831a8881 | ||
|
|
39a646ed92 | ||
|
|
595965c5d0 | ||
|
|
3bb6f8a0c0 | ||
|
|
1924a2017e | ||
|
|
60140fd2e6 | ||
|
|
65b5219e45 | ||
|
|
ae2f649aee | ||
|
|
bf3e860a18 | ||
|
|
0b44a91493 | ||
|
|
16077b3341 | ||
|
|
a7cedde721 | ||
|
|
ecd53192dc | ||
|
|
a03c76e211 | ||
|
|
de427fd7a9 | ||
|
|
c37e02009f | ||
|
|
a96b8a4e07 | ||
|
|
79b4d5fb8e | ||
|
|
de128f5e6a | ||
|
|
ef8ddcde07 | ||
|
|
eaff557d70 | ||
|
|
38f7a31200 | ||
|
|
97f16289c9 | ||
|
|
e15f5ab93e | ||
|
|
15fd312765 | ||
|
|
eea316865f | ||
|
|
05bbfbbd54 | ||
|
|
6039a9d0d5 | ||
|
|
0159b02916 | ||
|
|
8bbd4dc913 | ||
|
|
9e3ded6ad5 | ||
|
|
fe63275a6b | ||
|
|
81ed465607 | ||
|
|
d9aa281ce1 | ||
|
|
56648d664e | ||
|
|
da49d5577a | ||
|
|
f3dbdefdb1 | ||
|
|
d4302759e6 | ||
|
|
914f192fb2 | ||
|
|
522b554e36 | ||
|
|
4c54ab5319 | ||
|
|
d7f4ed069c | ||
|
|
7ea0c5ee4c | ||
|
|
e773a9d9d4 | ||
|
|
b570542fab | ||
|
|
09716e98ba | ||
|
|
9236b361e2 | ||
|
|
f281d8c068 | ||
|
|
83ed17d5c1 | ||
|
|
e2671dd4ed | ||
|
|
4c4d640331 | ||
|
|
6c4307c918 | ||
|
|
5a7062c699 | ||
|
|
7da01f7404 | ||
|
|
2b695cb8c6 | ||
|
|
599817eec7 | ||
|
|
11fa33be0a | ||
|
|
b5ac9d4ce4 | ||
|
|
78f0ac0042 | ||
|
|
00ecd7adc5 | ||
|
|
c39cb3bffc | ||
|
|
2fa902bfff | ||
|
|
f8bcd351ae | ||
|
|
6013d99bf6 | ||
|
|
e7c3977f7b | ||
|
|
47e1218fe0 | ||
|
|
a71a95892f | ||
|
|
b5f53e309f | ||
|
|
3164ba2d98 | ||
|
|
89854d188d | ||
|
|
79c7475435 | ||
|
|
2ee477c35e | ||
|
|
5bcd90c569 | ||
|
|
1a49c7c59e |
118
.github/workflows/build.yml
vendored
118
.github/workflows/build.yml
vendored
@@ -8,23 +8,20 @@ on:
|
||||
- version.py
|
||||
|
||||
jobs:
|
||||
build:
|
||||
Docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
-
|
||||
name: Release version
|
||||
- name: Release version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
-
|
||||
name: Docker meta
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
@@ -33,23 +30,19 @@ jobs:
|
||||
type=raw,value=${{ env.app_version }}
|
||||
type=raw,value=latest
|
||||
|
||||
-
|
||||
name: Set Up QEMU
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
-
|
||||
name: Set Up Buildx
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
-
|
||||
name: Login DockerHub
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
-
|
||||
name: Build Image
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
@@ -63,4 +56,97 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}
|
||||
cache-to: type=gha, scope=${{ github.workflow }}
|
||||
cache-to: type=gha, scope=${{ github.workflow }}
|
||||
|
||||
Windows-build:
|
||||
runs-on: windows-latest
|
||||
name: Build Windows Binary
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Init Python 3.11.4
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11.4'
|
||||
|
||||
- name: Install Dependent Packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install wheel pyinstaller
|
||||
pip install -r requirements.txt
|
||||
shell: pwsh
|
||||
|
||||
- name: Prepare Frontend
|
||||
run: |
|
||||
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"
|
||||
Move-Item -Path "dist/dist/*" -Destination "nginx/html" -Force
|
||||
Remove-Item -Path "dist.zip"
|
||||
Remove-Item -Path "dist" -Recurse -Force
|
||||
Move-Item -Path "nginx/html/nginx.conf" -Destination "nginx/conf/nginx.conf" -Force
|
||||
New-Item -Path "nginx/temp" -ItemType Directory -Force
|
||||
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
|
||||
shell: pwsh
|
||||
|
||||
- name: Pyinstaller
|
||||
run: |
|
||||
pyinstaller windows.spec
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload Windows File
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: dist/MoviePilot.exe
|
||||
|
||||
Create-release:
|
||||
permissions: write-all
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Windows-build, Docker-build ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Release Version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
- name: get release_informations
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir releases
|
||||
mv ./windows/MoviePilot.exe ./releases/MoviePilot_v${{ env.app_version }}.exe
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@latest
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
release_name: v${{ env.app_version }}
|
||||
body: ${{ github.event.commits[0].message }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload Release Asset
|
||||
uses: dwenegar/upload-release-assets@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
release_id: ${{ steps.create_release.outputs.id }}
|
||||
assets_path: |
|
||||
./releases/
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: MoviePilot Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- version.py
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build Docker Image
|
||||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
-
|
||||
name: Release Version
|
||||
id: release_version
|
||||
run: |
|
||||
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
|
||||
echo "app_version=$app_version" >> $GITHUB_ENV
|
||||
|
||||
-
|
||||
name: Generate Release
|
||||
uses: actions/create-release@latest
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
release_name: v${{ env.app_version }}
|
||||
body: ${{ github.event.commits[0].message }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.idea/
|
||||
*.c
|
||||
build/
|
||||
dist/
|
||||
nginx/
|
||||
test.py
|
||||
app/helper/sites.py
|
||||
config/user.db
|
||||
|
||||
33
Dockerfile
33
Dockerfile
@@ -1,38 +1,20 @@
|
||||
FROM python:3.11.4-slim-bullseye
|
||||
ARG MOVIEPILOT_VERSION
|
||||
ENV LANG="C.UTF-8" \
|
||||
HOME="/moviepilot" \
|
||||
TERM="xterm" \
|
||||
TZ="Asia/Shanghai" \
|
||||
HOME="/moviepilot" \
|
||||
CONFIG_DIR="/config" \
|
||||
TERM="xterm" \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
MOVIEPILOT_AUTO_UPDATE=true \
|
||||
MOVIEPILOT_AUTO_UPDATE_DEV=false \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
CONFIG_DIR="/config" \
|
||||
API_TOKEN="moviepilot" \
|
||||
PROXY_HOST="" \
|
||||
MOVIEPILOT_AUTO_UPDATE=true \
|
||||
MOVIEPILOT_AUTO_UPDATE_DEV=false \
|
||||
AUTH_SITE="iyuu" \
|
||||
DOWNLOAD_PATH="/downloads" \
|
||||
DOWNLOAD_CATEGORY="false" \
|
||||
TORRENT_TAG="MOVIEPILOT" \
|
||||
LIBRARY_PATH="" \
|
||||
LIBRARY_CATEGORY="false" \
|
||||
TRANSFER_TYPE="copy" \
|
||||
COOKIECLOUD_HOST="https://movie-pilot.org/cookiecloud" \
|
||||
COOKIECLOUD_KEY="" \
|
||||
COOKIECLOUD_PASSWORD="" \
|
||||
MESSAGER="telegram" \
|
||||
TELEGRAM_TOKEN="" \
|
||||
TELEGRAM_CHAT_ID="" \
|
||||
DOWNLOADER="qbittorrent" \
|
||||
QB_HOST="127.0.0.1:8080" \
|
||||
QB_USER="admin" \
|
||||
QB_PASSWORD="adminadmin" \
|
||||
MEDIASERVER="emby" \
|
||||
EMBY_HOST="http://127.0.0.1:8096" \
|
||||
EMBY_API_KEY=""
|
||||
IYUU_SIGN=""
|
||||
WORKDIR "/app"
|
||||
RUN apt-get update -y \
|
||||
&& apt-get -y install \
|
||||
@@ -49,6 +31,7 @@ RUN apt-get update -y \
|
||||
dumb-init \
|
||||
jq \
|
||||
haproxy \
|
||||
rclone \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
|
||||
139
README.md
139
README.md
@@ -4,8 +4,6 @@
|
||||
|
||||
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
||||
|
||||
Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
## 主要特性
|
||||
@@ -15,77 +13,88 @@ Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
## 安装
|
||||
|
||||
1. **安装CookieCloud插件**
|
||||
### 1. **安装CookieCloud插件**
|
||||
|
||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
||||
|
||||
2. **安装CookieCloud服务端(可选)**
|
||||
### 2. **安装CookieCloud服务端(可选)**
|
||||
|
||||
MoviePilot内置了公共CookieCloud服务器,如果需要自建服务,可参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建,docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)。
|
||||
|
||||
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
||||
|
||||
3. **安装配套管理软件**
|
||||
### 3. **安装配套管理软件**
|
||||
|
||||
MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- 下载器支持:qBittorrent、Transmission,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,推荐使用QB。
|
||||
- 媒体服务器支持:Jellyfin、Emby、Plex,推荐使用Emby。
|
||||
|
||||
4. **安装MoviePilot**
|
||||
### 4. **安装MoviePilot**
|
||||
|
||||
目前仅提供docker镜像,点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
- Docker镜像
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录。
|
||||
|
||||
## 配置
|
||||
|
||||
项目的所有配置均通过环境变量进行设置,部分环境建立容器后会自动显示待配置项,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
项目的所有配置均通过环境变量进行设置,支持两种配置方式:
|
||||
- 在Docker环境变量部分或Wdinows系统环境变量中进行参数配置,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
- 下载 [app.env](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env) 配置文件,修改好配置后放置到配置文件映射路径根目录,配置项可根据说明自主增减。
|
||||
|
||||
配置文件映射路径:`/config`
|
||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**。
|
||||
|
||||
> $\color{red}{*}$ 号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||
|
||||
### 1. **基础设置**
|
||||
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**
|
||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`
|
||||
- **NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||
- **PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||
- **SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **PROXY_HOST:** 网络代理(可选),访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`
|
||||
- **NGINX_PORT $\color{red}{*}$ :** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突(仅支持环境变量配置)
|
||||
- **PORT $\color{red}{*}$ :** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突(仅支持环境变量配置)
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`(仅支持环境变量配置)
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`(仅支持环境变量配置)
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`(仅支持环境变量配置)
|
||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`**(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`(仅支持环境变量配置)
|
||||
---
|
||||
- **SUPERUSER $\color{red}{*}$ :** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **SUPERUSER_PASSWORD $\color{red}{*}$ :** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **API_TOKEN $\color{red}{*}$ :** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **TMDB_API_DOMAIN:** TMDB API地址,默认`api.themoviedb.org`,也可配置为`api.tmdb.org`或其它中转代理服务地址,能连通即可
|
||||
- **DOWNLOAD_PATH:** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
|
||||
- **DOWNLOAD_MOVIE_PATH:** 电影下载保存目录,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_TV_PATH:** 电视剧下载保存目录,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_ANIME_PATH:** 动漫下载保存目录,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_CATEGORY:** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置`category.yaml`自动在下载目录下建立二级目录分类
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
- **REFRESH_MEDIASERVER:** 入库刷新媒体库,`true`/`false`,默认`true`
|
||||
- **TMDB_IMAGE_DOMAIN:** TMDB图片地址,默认`image.tmdb.org`,可配置为其它中转代理以加速TMDB图片显示,如:`static-mdb.v.geilijiasu.com`
|
||||
---
|
||||
- **SCRAP_METADATA:** 刮削入库的媒体文件,`true`/`false`,默认`true`
|
||||
- **SCRAP_SOURCE:** 刮削元数据及图片使用的数据源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
- **SCRAP_FOLLOW_TMDB:** 新增已入库媒体是否跟随TMDB信息变化,`true`/`false`,默认`true`
|
||||
- **TORRENT_TAG:** 种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
||||
- **LIBRARY_PATH:** 媒体库目录,多个目录使用`,`分隔
|
||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名,默认`电影`
|
||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录名,默认`电视剧`
|
||||
- **LIBRARY_ANIME_NAME:** 动漫媒体库目录名,默认`电视剧/动漫`
|
||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置`category.yaml`自动在媒体库目录下建立二级目录分类
|
||||
- **TRANSFER_TYPE:** 转移方式,支持`link`/`copy`/`move`/`softlink` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响**
|
||||
- **COOKIECLOUD_HOST:** CookieCloud服务器地址,格式:`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
|
||||
- **COOKIECLOUD_KEY:** CookieCloud用户KEY
|
||||
- **COOKIECLOUD_PASSWORD:** CookieCloud端对端加密密码
|
||||
- **COOKIECLOUD_INTERVAL:** CookieCloud同步间隔(分钟)
|
||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点二维码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||
- **USER_AGENT:** CookieCloud对应的浏览器UA,可选,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
||||
- **AUTO_DOWNLOAD_USER:** 交互搜索自动下载用户ID,使用,分割
|
||||
---
|
||||
- **TRANSFER_TYPE $\color{red}{*}$ :** 整理转移方式,支持`link`/`copy`/`move`/`softlink`/`rclone_copy`/`rclone_move` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响;rclone需要自行映射rclone配置目录到容器中或在容器内完成rclone配置,节点名称必须为:`MP`**
|
||||
- **LIBRARY_PATH $\color{red}{*}$ :** 媒体库目录,多个目录使用`,`分隔
|
||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名称(不是完整路径),默认`电影`
|
||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录称(不是完整路径),默认`电视剧`
|
||||
- **LIBRARY_ANIME_NAME:** 动漫媒体库目录称(不是完整路径),默认`电视剧/动漫`
|
||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
|
||||
---
|
||||
- **COOKIECLOUD_HOST $\color{red}{*}$ :** CookieCloud服务器地址,格式:`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
|
||||
- **COOKIECLOUD_KEY $\color{red}{*}$ :** CookieCloud用户KEY
|
||||
- **COOKIECLOUD_PASSWORD $\color{red}{*}$ :** CookieCloud端对端加密密码
|
||||
- **COOKIECLOUD_INTERVAL $\color{red}{*}$ :** CookieCloud同步间隔(分钟)
|
||||
- **USER_AGENT $\color{red}{*}$ :** CookieCloud保存Cookie对应的浏览器UA,建议配置,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
||||
- **OCR_HOST:** OCR识别服务器地址,格式:`http(s)://ip:port`,用于识别站点验证码实现自动登录获取Cookie等,不配置默认使用内建服务器`https://movie-pilot.org`,可使用 [这个镜像](https://hub.docker.com/r/jxxghp/moviepilot-ocr) 自行搭建。
|
||||
---
|
||||
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
||||
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
||||
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
||||
- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
- **SEARCH_SOURCE:** 媒体信息搜索来源,`themoviedb`/`douban`,默认`themoviedb`
|
||||
---
|
||||
- **AUTO_DOWNLOAD_USER:** 远程交互搜索时自动择优下载的用户ID,多个用户使用,分割,未设置需要选择资源或者回复`0`
|
||||
- **MESSAGER $\color{red}{*}$ :** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
|
||||
- `wechat`设置项:
|
||||
|
||||
@@ -102,21 +111,29 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **TELEGRAM_TOKEN:** Telegram Bot Token
|
||||
- **TELEGRAM_CHAT_ID:** Telegram Chat ID
|
||||
- **TELEGRAM_USERS:** Telegram 用户ID,多个使用,分隔,只有用户ID在列表中才可以使用Bot,如未设置则均可以使用Bot
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单(可选)
|
||||
|
||||
- `slack`设置项:
|
||||
|
||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`(可选)
|
||||
|
||||
- `synologychat`设置项:
|
||||
|
||||
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
||||
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
||||
|
||||
|
||||
- **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
---
|
||||
- **DOWNLOAD_PATH $\color{red}{*}$ :** 下载保存目录,**注意:需要将`moviepilot`及`下载器`的映射路径保持一致**,否则会导致下载文件无法转移
|
||||
- **DOWNLOAD_MOVIE_PATH:** 电影下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_TV_PATH:** 电视剧下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_ANIME_PATH:** 动漫下载保存目录路径,不设置则下载到`DOWNLOAD_PATH`
|
||||
- **DOWNLOAD_CATEGORY:** 下载二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在下载目录下建立二级目录分类
|
||||
- **DOWNLOAD_SUBTITLE:** 下载站点字幕,`true`/`false`,默认`true`
|
||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
- **TORRENT_TAG:** 下载器种子标签,默认为`MOVIEPILOT`,设置后只有MoviePilot添加的下载才会处理,留空所有下载器中的任务均会处理
|
||||
- **DOWNLOADER $\color{red}{*}$ :** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
|
||||
- `qbittorrent`设置项:
|
||||
|
||||
@@ -131,9 +148,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **TR_USER:** transmission用户名
|
||||
- **TR_PASSWORD:** transmission密码
|
||||
|
||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
|
||||
- **MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
---
|
||||
- **REFRESH_MEDIASERVER:** 入库后是否刷新媒体服务器,`true`/`false`,默认`true`
|
||||
- **MEDIASERVER $\color{red}{*}$ :** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
|
||||
- `emby`设置项:
|
||||
|
||||
@@ -156,9 +173,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
|
||||
### 2. **用户认证**
|
||||
|
||||
- **AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过环境变量配置**)
|
||||
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
- **AUTH_SITE $\color{red}{*}$ :** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
@@ -195,6 +212,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
@@ -224,9 +242,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
|
||||
### 3. **过滤规则**
|
||||
|
||||
在`设定`-`规则`中设定,规则说明:
|
||||
### 3. **优先级规则**
|
||||
|
||||
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等
|
||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
||||
@@ -243,10 +259,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`API服务端口`),可使用Overseerr/Jellyseerr浏览订阅。
|
||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
||||
|
||||
**注意**
|
||||
|
||||
1) 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
2) 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
||||
### **注意**
|
||||
- 容器首次启动需要下载浏览器内核,根据网络情况可能需要较长时间,此时无法登录。可映射`/moviepilot`目录避免容器重置后重新触发浏览器内核下载。
|
||||
- 使用反向代理时,需要添加以下配置,否则可能会导致部分功能无法访问(`ip:port`修改为实际值):
|
||||
```nginx configuration
|
||||
location / {
|
||||
proxy_pass http://ip:port;
|
||||
@@ -256,7 +271,7 @@ location / {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
3) 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
||||
- 新建的企业微信应用需要固定公网IP的代理才能收到消息,代理添加以下代码:
|
||||
```nginx configuration
|
||||
location /cgi-bin/gettoken {
|
||||
proxy_pass https://qyapi.weixin.qq.com;
|
||||
|
||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.dashboard import DashboardChain
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
@@ -32,13 +30,12 @@ def douban_img(imgurl: str) -> Any:
|
||||
|
||||
@router.get("/recognize/{doubanid}", summary="豆瓣ID识别", response_model=schemas.Context)
|
||||
def recognize_doubanid(doubanid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid=doubanid)
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid=doubanid)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
else:
|
||||
@@ -48,12 +45,11 @@ def recognize_doubanid(doubanid: str,
|
||||
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
|
||||
def movie_showing(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣正在热映
|
||||
"""
|
||||
movies = DoubanChain(db).movie_showing(page=page, count=count)
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
if not movies:
|
||||
return []
|
||||
medias = [MediaInfo(douban_info=movie) for movie in movies]
|
||||
@@ -65,13 +61,12 @@ def douban_movies(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣电影信息
|
||||
"""
|
||||
movies = DoubanChain(db).douban_discover(mtype=MediaType.MOVIE,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
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]
|
||||
@@ -86,13 +81,12 @@ def douban_tvs(sort: str = "R",
|
||||
tags: str = "",
|
||||
page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
tvs = DoubanChain(db).douban_discover(mtype=MediaType.TV,
|
||||
sort=sort, tags=tags, page=page, count=count)
|
||||
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]
|
||||
@@ -106,47 +100,54 @@ def douban_tvs(sort: str = "R",
|
||||
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
|
||||
def movie_top250(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览豆瓣剧集信息
|
||||
"""
|
||||
movies = DoubanChain(db).movie_top250(page=page, count=count)
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
return [MediaInfo(douban_info=movie).to_dict() for movie in movies]
|
||||
|
||||
|
||||
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_chinese(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
中国每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain(db).tv_weekly_chinese(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
|
||||
def tv_weekly_global(page: int = 1,
|
||||
count: int = 30,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
全球每周剧集口碑榜
|
||||
"""
|
||||
tvs = DoubanChain(db).tv_weekly_global(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
|
||||
def tv_animation(page: int = 1,
|
||||
count: int = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
热门动画剧集
|
||||
"""
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
return [MediaInfo(douban_info=tv).to_dict() for tv in tvs]
|
||||
|
||||
|
||||
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
|
||||
def douban_info(doubanid: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据豆瓣ID查询豆瓣媒体信息
|
||||
"""
|
||||
doubaninfo = DoubanChain(db).douban_info(doubanid=doubanid)
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
|
||||
if doubaninfo:
|
||||
return MediaInfo(douban_info=doubaninfo).to_dict()
|
||||
else:
|
||||
|
||||
@@ -68,12 +68,12 @@ def exists(media_in: schemas.MediaInfo,
|
||||
if media_in.tmdb_id:
|
||||
mediainfo.from_dict(media_in.dict())
|
||||
elif media_in.douban_id:
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid=media_in.douban_id)
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid=media_in.douban_id)
|
||||
if context:
|
||||
mediainfo = context.media_info
|
||||
meta = context.meta_info
|
||||
else:
|
||||
context = MediaChain(db).recognize_by_title(title=f"{media_in.title} {media_in.year}")
|
||||
context = MediaChain().recognize_by_title(title=f"{media_in.title} {media_in.year}")
|
||||
if context:
|
||||
mediainfo = context.media_info
|
||||
meta = context.meta_info
|
||||
|
||||
@@ -16,10 +16,16 @@ router = APIRouter()
|
||||
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
|
||||
|
||||
|
||||
@router.get("/list", summary="所有插件", response_model=List[schemas.FileItem])
|
||||
def list_path(path: str, sort: str = 'time', _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
@router.get("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
||||
def list_path(path: str,
|
||||
sort: str = 'time',
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
查询当前目录下所有目录和文件
|
||||
:param path: 目录路径
|
||||
:param sort: 排序方式,name:按名称排序,time:按修改时间排序
|
||||
:param _: token
|
||||
:return: 所有目录和文件
|
||||
"""
|
||||
# 返回结果
|
||||
ret_items = []
|
||||
|
||||
@@ -49,10 +49,10 @@ async def login_access_token(
|
||||
user.create(db)
|
||||
elif not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="用户未启用")
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return schemas.Token(
|
||||
access_token=security.create_access_token(
|
||||
user.id, expires_delta=access_token_expires
|
||||
user.id,
|
||||
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
),
|
||||
token_type="bearer",
|
||||
super_user=user.is_superuser,
|
||||
@@ -74,11 +74,11 @@ def bing_wallpaper() -> Any:
|
||||
|
||||
|
||||
@router.get("/tmdb", summary="TMDB电影海报", response_model=schemas.Response)
|
||||
def tmdb_wallpaper(db: Session = Depends(get_db)) -> Any:
|
||||
def tmdb_wallpaper() -> Any:
|
||||
"""
|
||||
获取TMDB电影海报
|
||||
"""
|
||||
wallpager = TmdbChain(db).get_random_wallpager()
|
||||
wallpager = TmdbChain().get_random_wallpager()
|
||||
if wallpager:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
|
||||
@@ -20,13 +20,12 @@ router = APIRouter()
|
||||
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
|
||||
def recognize(title: str,
|
||||
subtitle: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain(db).recognize_by_title(title=title, subtitle=subtitle)
|
||||
context = MediaChain().recognize_by_title(title=title, subtitle=subtitle)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
@@ -34,13 +33,12 @@ def recognize(title: str,
|
||||
|
||||
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
|
||||
def recognize(path: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = MediaChain(db).recognize_by_path(path)
|
||||
context = MediaChain().recognize_by_path(path)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
@@ -50,12 +48,11 @@ def recognize(path: str,
|
||||
def search_by_title(title: str,
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
模糊搜索媒体信息列表
|
||||
"""
|
||||
_, medias = MediaChain(db).search(title=title)
|
||||
_, medias = MediaChain().search(title=title)
|
||||
if medias:
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
|
||||
return []
|
||||
@@ -85,21 +82,20 @@ def exists(title: str = None,
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def tmdb_info(mediaid: str, type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
if mediaid.startswith("tmdb:"):
|
||||
result = TmdbChain(db).tmdb_info(int(mediaid[5:]), mtype)
|
||||
result = TmdbChain().tmdb_info(int(mediaid[5:]), mtype)
|
||||
return MediaInfo(tmdb_info=result).to_dict()
|
||||
elif mediaid.startswith("douban:"):
|
||||
# 查询豆瓣信息
|
||||
doubaninfo = DoubanChain(db).douban_info(doubanid=mediaid[7:])
|
||||
doubaninfo = DoubanChain().douban_info(doubanid=mediaid[7:])
|
||||
if not doubaninfo:
|
||||
return schemas.MediaInfo()
|
||||
result = DoubanChain(db).recognize_by_doubaninfo(doubaninfo)
|
||||
result = DoubanChain().recognize_by_doubaninfo(doubaninfo)
|
||||
if result:
|
||||
# TMDB
|
||||
return result.media_info.to_dict()
|
||||
|
||||
@@ -40,7 +40,7 @@ def search_by_tmdbid(mediaid: str,
|
||||
elif mediaid.startswith("douban:"):
|
||||
doubanid = mediaid.replace("douban:", "")
|
||||
# 识别豆瓣信息
|
||||
context = DoubanChain(db).recognize_by_doubanid(doubanid)
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid)
|
||||
if not context or not context.media_info or not context.media_info.tmdb_id:
|
||||
return []
|
||||
torrents = SearchChain(db).search_by_tmdbid(tmdbid=context.media_info.tmdb_id,
|
||||
|
||||
@@ -223,7 +223,7 @@ def execute_command(jobid: str,
|
||||
if not jobid:
|
||||
return schemas.Response(success=False, message="命令不能为空!")
|
||||
if jobid == "subscribe_search":
|
||||
Scheduler().start(jobid, state = 'R')
|
||||
Scheduler().start(jobid, state='R')
|
||||
else:
|
||||
Scheduler().start(jobid)
|
||||
return schemas.Response(success=True)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
from typing import List, Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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.db import get_db
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
|
||||
def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询themoviedb所有季信息
|
||||
"""
|
||||
seasons_info = TmdbChain(db).tmdb_seasons(tmdbid=tmdbid)
|
||||
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
|
||||
if not seasons_info:
|
||||
return []
|
||||
else:
|
||||
@@ -29,16 +26,15 @@ def tmdb_seasons(tmdbid: int, db: Session = Depends(get_db),
|
||||
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_similar(tmdbid: int,
|
||||
type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询类似电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain(db).movie_similar(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().movie_similar(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain(db).tv_similar(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().tv_similar(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -50,16 +46,15 @@ def tmdb_similar(tmdbid: int,
|
||||
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_recommend(tmdbid: int,
|
||||
type_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询推荐电影/电视剧,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain(db).movie_recommend(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().movie_recommend(tmdbid=tmdbid)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain(db).tv_recommend(tmdbid=tmdbid)
|
||||
tmdbinfos = TmdbChain().tv_recommend(tmdbid=tmdbid)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -72,16 +67,15 @@ def tmdb_recommend(tmdbid: int,
|
||||
def tmdb_credits(tmdbid: int,
|
||||
type_name: str,
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询演员阵容,type_name: 电影/电视剧
|
||||
"""
|
||||
mediatype = MediaType(type_name)
|
||||
if mediatype == MediaType.MOVIE:
|
||||
tmdbinfos = TmdbChain(db).movie_credits(tmdbid=tmdbid, page=page)
|
||||
tmdbinfos = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
|
||||
elif mediatype == MediaType.TV:
|
||||
tmdbinfos = TmdbChain(db).tv_credits(tmdbid=tmdbid, page=page)
|
||||
tmdbinfos = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
|
||||
else:
|
||||
return []
|
||||
if not tmdbinfos:
|
||||
@@ -92,12 +86,11 @@ def tmdb_credits(tmdbid: int,
|
||||
|
||||
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.TmdbPerson)
|
||||
def tmdb_person(person_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物详情
|
||||
"""
|
||||
tmdbinfo = TmdbChain(db).person_detail(person_id=person_id)
|
||||
tmdbinfo = TmdbChain().person_detail(person_id=person_id)
|
||||
if not tmdbinfo:
|
||||
return schemas.TmdbPerson()
|
||||
else:
|
||||
@@ -107,12 +100,11 @@ def tmdb_person(person_id: int,
|
||||
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_person_credits(person_id: int,
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据人物ID查询人物参演作品
|
||||
"""
|
||||
tmdbinfo = TmdbChain(db).person_credits(person_id=person_id, page=page)
|
||||
tmdbinfo = TmdbChain().person_credits(person_id=person_id, page=page)
|
||||
if not tmdbinfo:
|
||||
return []
|
||||
else:
|
||||
@@ -124,16 +116,15 @@ def tmdb_movies(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB电影信息
|
||||
"""
|
||||
movies = TmdbChain(db).tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not movies:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=movie).to_dict() for movie in movies]
|
||||
@@ -144,16 +135,15 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
with_genres: str = "",
|
||||
with_original_language: str = "",
|
||||
page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
tvs = TmdbChain(db).tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
page=page)
|
||||
if not tvs:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=tv).to_dict() for tv in tvs]
|
||||
@@ -161,12 +151,11 @@ def tmdb_tvs(sort_by: str = "popularity.desc",
|
||||
|
||||
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
|
||||
def tmdb_trending(page: int = 1,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
浏览TMDB剧集信息
|
||||
"""
|
||||
infos = TmdbChain(db).tmdb_trending(page=page)
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
if not infos:
|
||||
return []
|
||||
return [MediaInfo(tmdb_info=info).to_dict() for info in infos]
|
||||
@@ -174,12 +163,11 @@ def tmdb_trending(page: int = 1,
|
||||
|
||||
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
|
||||
def tmdb_season_episodes(tmdbid: int, season: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据TMDBID查询某季的所有信信息
|
||||
"""
|
||||
episodes_info = TmdbChain(db).tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
episodes_info = TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)
|
||||
if not episodes_info:
|
||||
return []
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import Any, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
@@ -301,11 +301,11 @@ def arr_movie_lookup(apikey: str, term: str, db: Session = Depends(get_db)) -> A
|
||||
)
|
||||
tmdbid = term.replace("tmdb:", "")
|
||||
# 查询媒体信息
|
||||
mediainfo = MediaChain(db).recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||
mediainfo = MediaChain().recognize_media(mtype=MediaType.MOVIE, tmdbid=int(tmdbid))
|
||||
if not mediainfo:
|
||||
return [RadarrMovie()]
|
||||
# 查询是否已存在
|
||||
exists = MediaChain(db).media_exists(mediainfo=mediainfo)
|
||||
exists = MediaChain().media_exists(mediainfo=mediainfo)
|
||||
if not exists:
|
||||
# 文件不存在
|
||||
hasfile = False
|
||||
@@ -581,7 +581,7 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(term),
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return [SonarrSeries()]
|
||||
@@ -593,7 +593,7 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain(db).tvdb_info(tvdbid=tvdbid)
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
|
||||
@@ -605,11 +605,11 @@ def arr_series_lookup(apikey: str, term: str, db: Session = Depends(get_db)) ->
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
if not mediainfo:
|
||||
mediainfo = MediaChain(db).recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
|
||||
# 查询是否存在
|
||||
exists = MediaChain(db).media_exists(mediainfo)
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
else:
|
||||
|
||||
@@ -115,16 +115,18 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid)
|
||||
|
||||
def match_doubaninfo(self, name: str, mtype: str = None,
|
||||
year: str = None, season: int = None) -> Optional[dict]:
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: str = None, year: str = None, season: int = None) -> Optional[dict]:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 标题
|
||||
:param imdbid: imdbid
|
||||
:param mtype: 类型
|
||||
:param year: 年份
|
||||
:param season: 季
|
||||
"""
|
||||
return self.run_module("match_doubaninfo", name=name, mtype=mtype, year=year, season=season)
|
||||
return self.run_module("match_doubaninfo", name=name, imdbid=imdbid,
|
||||
mtype=mtype, year=year, season=season)
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
|
||||
@@ -6,11 +6,12 @@ from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DoubanChain(ChainBase):
|
||||
class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣处理链
|
||||
豆瓣处理链,单例运行
|
||||
"""
|
||||
|
||||
def recognize_by_doubanid(self, doubanid: str) -> Optional[Context]:
|
||||
@@ -29,18 +30,32 @@ class DoubanChain(ChainBase):
|
||||
"""
|
||||
根据豆瓣信息识别媒体信息
|
||||
"""
|
||||
# 使用原标题匹配
|
||||
meta = MetaInfo(title=doubaninfo.get("original_title") or doubaninfo.get("title"))
|
||||
# 优先使用原标题匹配
|
||||
season_meta = None
|
||||
if doubaninfo.get("original_title"):
|
||||
meta = MetaInfo(title=doubaninfo.get("original_title"))
|
||||
season_meta = MetaInfo(title=doubaninfo.get("title"))
|
||||
# 合并季
|
||||
meta.begin_season = season_meta.begin_season
|
||||
else:
|
||||
meta = MetaInfo(title=doubaninfo.get("title"))
|
||||
# 年份
|
||||
if doubaninfo.get("year"):
|
||||
meta.year = doubaninfo.get("year")
|
||||
# 处理类型
|
||||
if isinstance(doubaninfo.get('media_type'), MediaType):
|
||||
meta.type = doubaninfo.get('media_type')
|
||||
else:
|
||||
meta.type = MediaType.MOVIE if doubaninfo.get("type") == "movie" else MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type)
|
||||
# 使用原标题识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=meta, mtype=meta.type)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{meta.name} 未识别到TMDB媒体信息')
|
||||
return Context(meta_info=meta, media_info=MediaInfo(douban_info=doubaninfo))
|
||||
if season_meta and season_meta.name != meta.name:
|
||||
# 使用主标题识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=season_meta, mtype=season_meta.type)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{meta.name} 未识别到TMDB媒体信息')
|
||||
return Context(meta_info=meta, media_info=MediaInfo(douban_info=doubaninfo))
|
||||
logger.info(f'识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year} {meta.season}')
|
||||
mediainfo.set_douban_info(doubaninfo)
|
||||
return Context(meta_info=meta, media_info=mediainfo)
|
||||
@@ -84,3 +99,9 @@ class DoubanChain(ChainBase):
|
||||
"""
|
||||
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) -> List[dict]:
|
||||
"""
|
||||
获取动画剧集
|
||||
"""
|
||||
return self.run_module("tv_animation", page=page, count=count)
|
||||
|
||||
@@ -74,8 +74,7 @@ class DownloadChain(ChainBase):
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
image=mediainfo.get_message_image()))
|
||||
|
||||
def download_torrent(self, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import copy
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import Context, MediaInfo
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class MediaChain(ChainBase):
|
||||
recognize_lock = Lock()
|
||||
|
||||
|
||||
class MediaChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
媒体信息处理链
|
||||
媒体信息处理链,单例运行
|
||||
"""
|
||||
# 临时识别标题
|
||||
recognize_title: Optional[str] = None
|
||||
# 临时识别结果 {title, name, year, season, episode}
|
||||
recognize_temp: Optional[dict] = None
|
||||
|
||||
def recognize_by_title(self, title: str, subtitle: str = None) -> Optional[Context]:
|
||||
"""
|
||||
@@ -24,14 +37,104 @@ class MediaChain(ChainBase):
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{title} 未识别到媒体信息')
|
||||
return Context(meta_info=metainfo)
|
||||
# 偿试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(EventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{title} ...')
|
||||
mediainfo = self.recognize_help(title=title, org_meta=metainfo)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{title} 未识别到媒体信息')
|
||||
return Context(meta_info=metainfo)
|
||||
# 识别成功
|
||||
logger.info(f'{title} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 返回上下文
|
||||
return Context(meta_info=metainfo, media_info=mediainfo)
|
||||
|
||||
def recognize_help(self, title: str, org_meta: MetaBase) -> Optional[MediaInfo]:
|
||||
"""
|
||||
请求辅助识别,返回媒体信息
|
||||
:param title: 标题
|
||||
:param org_meta: 原始元数据
|
||||
"""
|
||||
with recognize_lock:
|
||||
self.recognize_temp = None
|
||||
self.recognize_title = title
|
||||
|
||||
# 发送请求事件
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognize,
|
||||
{
|
||||
'title': title,
|
||||
}
|
||||
)
|
||||
# 每0.5秒循环一次,等待结果,直到10秒后超时
|
||||
for i in range(10):
|
||||
if self.recognize_temp is not None:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
# 加锁
|
||||
with recognize_lock:
|
||||
mediainfo = None
|
||||
if not self.recognize_temp or self.recognize_title != title:
|
||||
# 没有识别结果或者识别标题已改变
|
||||
return None
|
||||
# 有识别结果
|
||||
meta_dict = copy.deepcopy(self.recognize_temp)
|
||||
logger.info(f'获取到辅助识别结果:{meta_dict}')
|
||||
if meta_dict.get("name") == org_meta.name and meta_dict.get("year") == org_meta.year:
|
||||
logger.info(f'辅助识别结果与原始识别结果一致')
|
||||
else:
|
||||
logger.info(f'辅助识别结果与原始识别结果不一致,重新匹配媒体信息 ...')
|
||||
org_meta.name = meta_dict.get("name")
|
||||
org_meta.year = meta_dict.get("year")
|
||||
org_meta.begin_season = meta_dict.get("season")
|
||||
org_meta.begin_episode = meta_dict.get("episode")
|
||||
if org_meta.begin_season or org_meta.begin_episode:
|
||||
org_meta.type = MediaType.TV
|
||||
# 重新识别
|
||||
mediainfo = self.recognize_media(meta=org_meta)
|
||||
return mediainfo
|
||||
|
||||
@eventmanager.register(EventType.NameRecognizeResult)
|
||||
def recognize_result(self, event: Event):
|
||||
"""
|
||||
监控识别结果事件,获取辅助识别结果,结果格式:{title, name, year, season, episode}
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data or {}
|
||||
# 加锁
|
||||
with recognize_lock:
|
||||
# 不是原标题的结果不要
|
||||
if event_data.get("title") != self.recognize_title:
|
||||
return
|
||||
# 标志收到返回
|
||||
self.recognize_temp = {}
|
||||
# 处理数据格式
|
||||
file_title, file_year, season_number, episode_number = None, None, None, None
|
||||
if event_data.get("name"):
|
||||
file_title = str(event_data["name"]).split("/")[0].strip().replace(".", " ")
|
||||
if event_data.get("year"):
|
||||
file_year = str(event_data["year"]).split("/")[0].strip()
|
||||
if event_data.get("season") and str(event_data["season"]).isdigit():
|
||||
season_number = int(event_data["season"])
|
||||
if event_data.get("episode") and str(event_data["episode"]).isdigit():
|
||||
episode_number = int(event_data["episode"])
|
||||
if not file_title:
|
||||
return
|
||||
if file_title == 'Unknown':
|
||||
return
|
||||
if not str(file_year).isdigit():
|
||||
file_year = None
|
||||
# 结果赋值
|
||||
self.recognize_temp = {
|
||||
"name": file_title,
|
||||
"year": file_year,
|
||||
"season": season_number,
|
||||
"episode": episode_number
|
||||
}
|
||||
|
||||
def recognize_by_path(self, path: str) -> Optional[Context]:
|
||||
"""
|
||||
根据文件路径识别媒体信息
|
||||
@@ -43,8 +146,13 @@ class MediaChain(ChainBase):
|
||||
# 识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{path} 未识别到媒体信息')
|
||||
return Context(meta_info=file_meta)
|
||||
# 偿试使用辅助识别,如果有注册响应事件的话
|
||||
if eventmanager.check(EventType.NameRecognize):
|
||||
logger.info(f'请求辅助识别,标题:{file_path.name} ...')
|
||||
mediainfo = self.recognize_help(title=path, org_meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{path} 未识别到媒体信息')
|
||||
return Context(meta_info=file_meta)
|
||||
logger.info(f'{path} 识别到媒体信息:{mediainfo.type.value} {mediainfo.title_year}')
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
@@ -32,7 +32,7 @@ class MessageChain(ChainBase):
|
||||
self.downloadchain = DownloadChain(self._db)
|
||||
self.subscribechain = SubscribeChain(self._db)
|
||||
self.searchchain = SearchChain(self._db)
|
||||
self.medtachain = MediaChain(self._db)
|
||||
self.medtachain = MediaChain()
|
||||
self.torrent = TorrentHelper()
|
||||
self.eventmanager = EventManager()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
@@ -342,6 +342,12 @@ class SearchChain(ChainBase):
|
||||
include = filter_rule.get("include")
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
@@ -359,6 +365,24 @@ class SearchChain(ChainBase):
|
||||
f"{t.title} {t.description}", re.I):
|
||||
logger.info(f"{t.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
|
||||
# 分辨率
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
|
||||
# 特效
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, t.title, re.I):
|
||||
logger.info(f"{t.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# 使用默认过滤规则再次过滤
|
||||
|
||||
@@ -3,9 +3,10 @@ import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Union, Tuple
|
||||
|
||||
from requests import Session
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
@@ -50,18 +51,28 @@ class SubscribeChain(ChainBase):
|
||||
识别媒体信息并添加订阅
|
||||
"""
|
||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||
# 识别元数据
|
||||
metainfo = MetaInfo(title)
|
||||
if year:
|
||||
metainfo.year = year
|
||||
if mtype:
|
||||
metainfo.type = mtype
|
||||
if season:
|
||||
metainfo.type = MediaType.TV
|
||||
metainfo.begin_season = season
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
|
||||
if not mediainfo:
|
||||
metainfo = None
|
||||
mediainfo = None
|
||||
if not tmdbid and doubanid:
|
||||
# 将豆瓣信息转换为TMDB信息
|
||||
context = DoubanChain().recognize_by_doubanid(doubanid)
|
||||
if context:
|
||||
metainfo = context.meta_info
|
||||
mediainfo = context.media_info
|
||||
else:
|
||||
# 识别元数据
|
||||
metainfo = MetaInfo(title)
|
||||
if year:
|
||||
metainfo.year = year
|
||||
if mtype:
|
||||
metainfo.type = mtype
|
||||
if season:
|
||||
metainfo.type = MediaType.TV
|
||||
metainfo.begin_season = season
|
||||
# 识别媒体信息
|
||||
mediainfo = self.recognize_media(meta=metainfo, mtype=mtype, tmdbid=tmdbid)
|
||||
# 识别失败
|
||||
if not mediainfo or not metainfo or not mediainfo.tmdb_id:
|
||||
logger.warn(f'未识别到媒体信息,标题:{title},tmdbid:{tmdbid}')
|
||||
return None, "未识别到媒体信息"
|
||||
# 更新媒体图片
|
||||
@@ -74,8 +85,8 @@ class SubscribeChain(ChainBase):
|
||||
if not kwargs.get('total_episode'):
|
||||
if not mediainfo.seasons:
|
||||
# 补充媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
mediainfo = self.recognize_media(mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
if not mediainfo:
|
||||
logger.error(f"媒体信息识别失败!")
|
||||
return None, "媒体信息识别失败"
|
||||
@@ -85,7 +96,7 @@ class SubscribeChain(ChainBase):
|
||||
total_episode = len(mediainfo.seasons.get(season) or [])
|
||||
if not total_episode:
|
||||
logger.error(f'未获取到总集数,标题:{title},tmdbid:{tmdbid}')
|
||||
return None, "未获取到总集数"
|
||||
return None, f"未获取到第 {season} 季的总集数"
|
||||
kwargs.update({
|
||||
'total_episode': total_episode
|
||||
})
|
||||
@@ -176,66 +187,75 @@ class SubscribeChain(ChainBase):
|
||||
totals = {
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询缺失的媒体信息
|
||||
# 查询媒体库缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在,完成订阅')
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
continue
|
||||
# 电视剧订阅
|
||||
if meta.type == MediaType.TV:
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
no_exists = self.__get_subscribe_no_exits(
|
||||
no_exists=no_exists,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
begin_season=meta.begin_season,
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode,
|
||||
|
||||
)
|
||||
# 打印缺失集信息
|
||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
else:
|
||||
# 洗版状态
|
||||
exist_flag = False
|
||||
if meta.type == MediaType.TV:
|
||||
no_exists = {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
subscribe.tmdbid: {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
}
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
|
||||
# 已存在
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
continue
|
||||
|
||||
# 电视剧订阅处理缺失集
|
||||
if meta.type == MediaType.TV:
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
no_exists = self.__get_subscribe_no_exits(
|
||||
no_exists=no_exists,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
begin_season=meta.begin_season,
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode,
|
||||
|
||||
)
|
||||
# 打印缺失集信息
|
||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
|
||||
# 站点范围
|
||||
if subscribe.sites:
|
||||
sites = json.loads(subscribe.sites)
|
||||
else:
|
||||
sites = None
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
else:
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
|
||||
# 默认过滤规则
|
||||
if subscribe.include or subscribe.exclude:
|
||||
filter_rule = {
|
||||
"include": subscribe.include,
|
||||
"exclude": subscribe.exclude
|
||||
"exclude": subscribe.exclude,
|
||||
"quality": subscribe.quality,
|
||||
"resolution": subscribe.resolution,
|
||||
"effect": subscribe.effect,
|
||||
}
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
@@ -247,8 +267,10 @@ class SubscribeChain(ChainBase):
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
||||
meta=meta, mediainfo=mediainfo)
|
||||
continue
|
||||
|
||||
# 过滤
|
||||
matched_contexts = []
|
||||
for context in contexts:
|
||||
@@ -278,8 +300,10 @@ class SubscribeChain(ChainBase):
|
||||
logger.warn(f'订阅 {subscribe.name} 没有符合过滤条件的资源')
|
||||
# 非洗版未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
||||
meta=meta, mediainfo=mediainfo)
|
||||
continue
|
||||
|
||||
# 自动下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts,
|
||||
no_exists=no_exists)
|
||||
@@ -299,8 +323,9 @@ class SubscribeChain(ChainBase):
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
# 更新订阅剩余集数和时间
|
||||
update_date = True if downloads else False
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if sid:
|
||||
@@ -309,19 +334,19 @@ class SubscribeChain(ChainBase):
|
||||
self.message.put('所有订阅搜索完成!')
|
||||
|
||||
def finish_subscribe_or_not(self, subscribe: Subscribe, meta: MetaInfo,
|
||||
mediainfo: MediaInfo, downloads: List[Context]):
|
||||
mediainfo: MediaInfo, downloads: List[Context] = None):
|
||||
"""
|
||||
判断是否应完成订阅
|
||||
"""
|
||||
if not subscribe.best_version:
|
||||
# 全部下载完成
|
||||
logger.info(f'{mediainfo.title_year} 下载完成,完成订阅')
|
||||
logger.info(f'{mediainfo.title_year} 完成订阅')
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
else:
|
||||
elif downloads:
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
if priority == 100:
|
||||
@@ -411,46 +436,50 @@ class SubscribeChain(ChainBase):
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
)
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在,完成订阅')
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
self.post_message(Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成订阅',
|
||||
image=mediainfo.get_message_image()))
|
||||
continue
|
||||
# 电视剧订阅
|
||||
if meta.type == MediaType.TV:
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
no_exists = self.__get_subscribe_no_exits(
|
||||
no_exists=no_exists,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
begin_season=meta.begin_season,
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode,
|
||||
|
||||
)
|
||||
# 打印缺失集信息
|
||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
else:
|
||||
# 洗版
|
||||
exist_flag = False
|
||||
if meta.type == MediaType.TV:
|
||||
no_exists = {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
subscribe.tmdbid: {
|
||||
subscribe.season: NotExistMediaInfo(
|
||||
season=subscribe.season,
|
||||
episodes=[],
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode or 1)
|
||||
}
|
||||
}
|
||||
else:
|
||||
no_exists = {}
|
||||
|
||||
# 已存在
|
||||
if exist_flag:
|
||||
logger.info(f'{mediainfo.title_year} 媒体库中已存在')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
continue
|
||||
|
||||
# 电视剧订阅
|
||||
if meta.type == MediaType.TV:
|
||||
# 使用订阅的总集数和开始集数替换no_exists
|
||||
no_exists = self.__get_subscribe_no_exits(
|
||||
no_exists=no_exists,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
begin_season=meta.begin_season,
|
||||
total_episode=subscribe.total_episode,
|
||||
start_episode=subscribe.start_episode,
|
||||
|
||||
)
|
||||
# 打印缺失集信息
|
||||
if no_exists and no_exists.get(subscribe.tmdbid):
|
||||
no_exists_info = no_exists.get(subscribe.tmdbid).get(subscribe.season)
|
||||
if no_exists_info:
|
||||
logger.info(f'订阅 {mediainfo.title_year} {meta.season} 缺失集:{no_exists_info.episodes}')
|
||||
|
||||
# 默认过滤规则
|
||||
default_filter = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
include = subscribe.include or default_filter.get("include")
|
||||
exclude = subscribe.exclude or default_filter.get("exclude")
|
||||
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
@@ -537,6 +566,7 @@ class SubscribeChain(ChainBase):
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
_match_context.append(context)
|
||||
|
||||
# 开始下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
if _match_context:
|
||||
@@ -554,12 +584,13 @@ class SubscribeChain(ChainBase):
|
||||
if meta.type == MediaType.TV and not subscribe.best_version:
|
||||
update_date = True if downloads else False
|
||||
# 未完成下载,计算剩余集数
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe,
|
||||
self.__update_lack_episodes(lefts=lefts, subscribe=subscribe, meta=meta,
|
||||
mediainfo=mediainfo, update_date=update_date)
|
||||
else:
|
||||
if meta.type == MediaType.TV:
|
||||
# 未搜索到资源,但本地缺失可能有变化,更新订阅剩余集数
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe, mediainfo=mediainfo)
|
||||
self.__update_lack_episodes(lefts=no_exists, subscribe=subscribe,
|
||||
meta=meta, mediainfo=mediainfo)
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
@@ -651,31 +682,36 @@ class SubscribeChain(ChainBase):
|
||||
|
||||
def __update_lack_episodes(self, lefts: Dict[int, Dict[int, NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
meta: MetaBase,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: bool = False):
|
||||
"""
|
||||
更新订阅剩余集数
|
||||
"""
|
||||
left_seasons = lefts.get(mediainfo.tmdb_id) or {}
|
||||
for season_info in left_seasons.values():
|
||||
season = season_info.season
|
||||
if season == subscribe.season:
|
||||
left_episodes = season_info.episodes
|
||||
if not left_episodes:
|
||||
lack_episode = season_info.total_episode
|
||||
else:
|
||||
lack_episode = len(left_episodes)
|
||||
logger.info(f'{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...')
|
||||
if update_date:
|
||||
# 同时更新最后时间
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode,
|
||||
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
else:
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
left_seasons = lefts.get(mediainfo.tmdb_id)
|
||||
if left_seasons:
|
||||
for season_info in left_seasons.values():
|
||||
season = season_info.season
|
||||
if season == subscribe.season:
|
||||
left_episodes = season_info.episodes
|
||||
if not left_episodes:
|
||||
lack_episode = season_info.total_episode
|
||||
else:
|
||||
lack_episode = len(left_episodes)
|
||||
logger.info(f'{mediainfo.title_year} 季 {season} 更新缺失集数为{lack_episode} ...')
|
||||
if update_date:
|
||||
# 同时更新最后时间
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode,
|
||||
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
else:
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
"lack_episode": lack_episode
|
||||
})
|
||||
else:
|
||||
# 判断是否应完成订阅
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
|
||||
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
|
||||
@@ -5,13 +5,14 @@ from cachetools import cached, TTLCache
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import settings
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TheMovieDB处理链
|
||||
TheMovieDB处理链,单例运行
|
||||
"""
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
|
||||
@@ -122,5 +123,5 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
while True:
|
||||
info = random.choice(infos)
|
||||
if info and info.get("backdrop_path"):
|
||||
return f"https://image.tmdb.org/t/p/original{info.get('backdrop_path')}"
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('backdrop_path')}"
|
||||
return None
|
||||
|
||||
@@ -41,8 +41,8 @@ class TransferChain(ChainBase):
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.transferhis = TransferHistoryOper(self._db)
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain(self._db)
|
||||
self.tmdbchain = TmdbChain(self._db)
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def process(self) -> bool:
|
||||
@@ -622,8 +622,9 @@ class TransferChain(ChainBase):
|
||||
if not path.exists():
|
||||
return
|
||||
if path.is_file():
|
||||
# 删除文件、nfo、jpg
|
||||
files = glob.glob(f"{Path(path.parent).joinpath(path.stem)}*")
|
||||
# 删除文件、nfo、jpg等同名文件
|
||||
pattern = path.stem.replace('[', '?').replace(']', '?')
|
||||
files = path.parent.glob(f"{pattern}.*")
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.warn(f"文件 {path} 已删除")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import importlib
|
||||
import traceback
|
||||
from threading import Thread, Event
|
||||
from typing import Any, Union, Dict
|
||||
@@ -175,10 +176,24 @@ class Command(metaclass=Singleton):
|
||||
for handler in handlers:
|
||||
try:
|
||||
names = handler.__qualname__.split(".")
|
||||
if names[0] == "Command":
|
||||
self.command_event(event)
|
||||
[class_name, method_name] = names
|
||||
if class_name in self.pluginmanager.get_plugin_ids():
|
||||
# 插件事件
|
||||
self.pluginmanager.run_plugin_method(class_name, method_name, event)
|
||||
else:
|
||||
self.pluginmanager.run_plugin_method(names[0], names[1], event)
|
||||
# 检查全局变量中是否存在
|
||||
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)()
|
||||
else:
|
||||
# 通过类名创建类实例
|
||||
class_obj = globals()[class_name]()
|
||||
# 检查类是否存在并调用方法
|
||||
if hasattr(class_obj, method_name):
|
||||
getattr(class_obj, method_name)(event)
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import secrets
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseSettings
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 项目名称
|
||||
@@ -22,6 +25,8 @@ class Settings(BaseSettings):
|
||||
HOST: str = "0.0.0.0"
|
||||
# API监听端口
|
||||
PORT: int = 3001
|
||||
# 前端监听端口
|
||||
NGINX_PORT: int = 3000
|
||||
# 是否调试模式
|
||||
DEBUG: bool = False
|
||||
# 是否开发模式
|
||||
@@ -208,7 +213,11 @@ class Settings(BaseSettings):
|
||||
def CONFIG_PATH(self):
|
||||
if self.CONFIG_DIR:
|
||||
return Path(self.CONFIG_DIR)
|
||||
return self.INNER_CONFIG_PATH
|
||||
elif SystemUtils.is_docker():
|
||||
return Path("/config")
|
||||
elif SystemUtils.is_frozen():
|
||||
return Path(sys.executable).parent / "config"
|
||||
return self.ROOT_PATH / "config"
|
||||
|
||||
@property
|
||||
def TEMP_PATH(self):
|
||||
@@ -268,11 +277,14 @@ class Settings(BaseSettings):
|
||||
return [Path(path) for path in self.LIBRARY_PATH.split(",")]
|
||||
return []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
with self.CONFIG_PATH as p:
|
||||
if not p.exists():
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
if SystemUtils.is_frozen():
|
||||
if not (p / "app.env").exists():
|
||||
SystemUtils.copy(self.INNER_CONFIG_PATH / "app.env", p / "app.env")
|
||||
with self.TEMP_PATH as p:
|
||||
if not p.exists():
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
@@ -284,4 +296,7 @@ class Settings(BaseSettings):
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
settings = Settings(
|
||||
_env_file=Settings().CONFIG_PATH / "app.env",
|
||||
_env_file_encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -10,16 +10,13 @@ class EventManager(metaclass=Singleton):
|
||||
事件管理器
|
||||
"""
|
||||
|
||||
# 事件队列
|
||||
_eventQueue: Queue = None
|
||||
# 事件响应函数字典
|
||||
_handlers: dict = {}
|
||||
|
||||
def __init__(self):
|
||||
# 事件队列
|
||||
self._eventQueue = Queue()
|
||||
# 事件响应函数字典
|
||||
self._handlers = {}
|
||||
# 已禁用的事件响应
|
||||
self._disabled_handlers = []
|
||||
|
||||
def get_event(self):
|
||||
"""
|
||||
@@ -27,11 +24,21 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
try:
|
||||
event = self._eventQueue.get(block=True, timeout=1)
|
||||
handlerList = self._handlers.get(event.event_type)
|
||||
return event, handlerList or []
|
||||
handlerList = self._handlers.get(event.event_type) or []
|
||||
if handlerList:
|
||||
# 去除掉被禁用的事件响应
|
||||
handlerList = [handler for handler in handlerList
|
||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
|
||||
return event, handlerList
|
||||
except Empty:
|
||||
return None, []
|
||||
|
||||
def check(self, etype: EventType):
|
||||
"""
|
||||
检查事件是否存在响应
|
||||
"""
|
||||
return etype.value in self._handlers
|
||||
|
||||
def add_event_listener(self, etype: EventType, handler: type):
|
||||
"""
|
||||
注册事件处理
|
||||
@@ -45,18 +52,21 @@ class EventManager(metaclass=Singleton):
|
||||
handlerList.append(handler)
|
||||
logger.debug(f"Event Registed:{etype.value} - {handler}")
|
||||
|
||||
def remove_event_listener(self, etype: EventType, handler: type):
|
||||
def disable_events_hander(self, class_name: str):
|
||||
"""
|
||||
移除监听器的处理函数
|
||||
标记对应类事件处理为不可用
|
||||
"""
|
||||
try:
|
||||
handlerList = self._handlers[etype.value]
|
||||
if handler in handlerList[:]:
|
||||
handlerList.remove(handler)
|
||||
if not handlerList:
|
||||
del self._handlers[etype.value]
|
||||
except KeyError:
|
||||
pass
|
||||
if class_name not in self._disabled_handlers:
|
||||
self._disabled_handlers.append(class_name)
|
||||
logger.debug(f"Event Disabled:{class_name}")
|
||||
|
||||
def enable_events_hander(self, class_name: str):
|
||||
"""
|
||||
标记对应类事件处理为可用
|
||||
"""
|
||||
if class_name in self._disabled_handlers:
|
||||
self._disabled_handlers.remove(class_name)
|
||||
logger.debug(f"Event Enabled:{class_name}")
|
||||
|
||||
def send_event(self, etype: EventType, data: dict = None):
|
||||
"""
|
||||
|
||||
47
app/core/meta/customization.py
Normal file
47
app/core/meta/customization.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import regex as re
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class CustomizationMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别自定义占位符
|
||||
"""
|
||||
customization = None
|
||||
custom_separator = None
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.customization = None
|
||||
self.custom_separator = None
|
||||
|
||||
def match(self, title=None):
|
||||
"""
|
||||
:param title: 资源标题或文件名
|
||||
:return: 匹配结果
|
||||
"""
|
||||
if not title:
|
||||
return ""
|
||||
if not self.customization:
|
||||
# 自定义占位符
|
||||
customization = self.systemconfig.get(SystemConfigKey.Customization)
|
||||
if not customization:
|
||||
return ""
|
||||
if isinstance(customization, str):
|
||||
customization = customization.replace("\n", ";").replace("|", ";").strip(";").split(";")
|
||||
self.customization = "|".join([f"({item})" for item in customization])
|
||||
|
||||
customization_re = re.compile(r"%s" % self.customization)
|
||||
# 处理重复多次的情况,保留先后顺序(按添加自定义占位符的顺序)
|
||||
unique_customization = {}
|
||||
for item in re.findall(customization_re, title):
|
||||
if not isinstance(item, tuple):
|
||||
item = (item,)
|
||||
for i in range(len(item)):
|
||||
if item[i] and unique_customization.get(item[i]) is None:
|
||||
unique_customization[item[i]] = i
|
||||
unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys())
|
||||
separator = self.custom_separator or "@"
|
||||
return separator.join(unique_customization)
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
import zhconv
|
||||
import anitopy
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -144,6 +145,8 @@ class MetaAnime(MetaBase):
|
||||
self.resource_team = \
|
||||
ReleaseGroupsMatcher().match(title=original_title) or \
|
||||
anitopy_info_origin.get("release_group") or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
# 视频编码
|
||||
self.video_encode = anitopy_info.get("video_term")
|
||||
if isinstance(self.video_encode, list):
|
||||
|
||||
@@ -51,6 +51,8 @@ class MetaBase(object):
|
||||
resource_pix: Optional[str] = None
|
||||
# 识别的制作组/字幕组
|
||||
resource_team: Optional[str] = None
|
||||
# 识别的自定义占位符
|
||||
customization: Optional[str] = None
|
||||
# 视频编码
|
||||
video_encode: Optional[str] = None
|
||||
# 音频编码
|
||||
@@ -85,6 +87,17 @@ class MetaBase(object):
|
||||
return self.cn_name
|
||||
return ""
|
||||
|
||||
@name.setter
|
||||
def name(self, name: str):
|
||||
"""
|
||||
设置名称
|
||||
"""
|
||||
if StringUtils.is_all_chinese(name):
|
||||
self.cn_name = name
|
||||
else:
|
||||
self.en_name = name
|
||||
self.cn_name = None
|
||||
|
||||
def init_subtitle(self, title_text: str):
|
||||
"""
|
||||
副标题识别
|
||||
@@ -492,6 +505,9 @@ class MetaBase(object):
|
||||
# 制作组/字幕组
|
||||
if not self.resource_team:
|
||||
self.resource_team = meta.resource_team
|
||||
# 自定义占位符
|
||||
if not self.customization:
|
||||
self.customization = meta.customization
|
||||
# 特效
|
||||
if not self.resource_effect:
|
||||
self.resource_effect = meta.resource_effect
|
||||
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -130,6 +131,8 @@ class MetaVideo(MetaBase):
|
||||
self.part = None
|
||||
# 制作组/字幕组
|
||||
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
|
||||
def __fix_name(self, name: str):
|
||||
if not name:
|
||||
|
||||
@@ -61,8 +61,7 @@ class WordsMatcher(metaclass=Singleton):
|
||||
|
||||
if state:
|
||||
appley_words.append(word)
|
||||
else:
|
||||
logger.debug(f"自定义识别词替换失败:{message}")
|
||||
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import traceback
|
||||
from typing import List, Any, Dict, Tuple
|
||||
|
||||
from app.core.event import eventmanager
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
@@ -58,6 +59,8 @@ class PluginManager(metaclass=Singleton):
|
||||
self._plugins[plugin_id] = plugin
|
||||
# 未安装的不加载
|
||||
if plugin_id not in installed_plugins:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
continue
|
||||
# 生成实例
|
||||
plugin_obj = plugin()
|
||||
@@ -66,6 +69,8 @@ class PluginManager(metaclass=Singleton):
|
||||
# 存储运行实例
|
||||
self._running_plugins[plugin_id] = plugin_obj
|
||||
logger.info(f"Plugin Loaded:{plugin_id}")
|
||||
# 设置事件注册状态可用
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
except Exception as err:
|
||||
logger.error(f"加载插件 {plugin_id} 出错:{err} - {traceback.format_exc()}")
|
||||
|
||||
@@ -177,6 +182,12 @@ class PluginManager(metaclass=Singleton):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
|
||||
|
||||
def get_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有插件ID
|
||||
"""
|
||||
return list(self._plugins.keys())
|
||||
|
||||
def get_plugin_apps(self) -> List[dict]:
|
||||
"""
|
||||
获取所有插件信息
|
||||
|
||||
@@ -39,7 +39,7 @@ def update_db():
|
||||
更新数据库
|
||||
"""
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
script_location = settings.ROOT_PATH / 'alembic'
|
||||
script_location = settings.ROOT_PATH / 'database'
|
||||
try:
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||
|
||||
@@ -37,6 +37,12 @@ class Subscribe(Base):
|
||||
include = Column(String)
|
||||
# 排除
|
||||
exclude = Column(String)
|
||||
# 质量
|
||||
quality = Column(String)
|
||||
# 分辨率
|
||||
resolution = Column(String)
|
||||
# 特效
|
||||
effect = Column(String)
|
||||
# 总集数
|
||||
total_episode = Column(Integer)
|
||||
# 开始集数
|
||||
|
||||
@@ -2,12 +2,15 @@ from pyvirtualdisplay import Display
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
_display: Display = None
|
||||
|
||||
def __init__(self):
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
self._display = Display(visible=False, size=(1024, 768))
|
||||
self._display.start()
|
||||
|
||||
98
app/main.py
98
app/main.py
@@ -1,10 +1,22 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn as uvicorn
|
||||
from PIL import Image
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from uvicorn import Config
|
||||
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
# 禁用输出
|
||||
if SystemUtils.is_frozen():
|
||||
sys.stdout = open(os.devnull, 'w')
|
||||
sys.stderr = open(os.devnull, 'w')
|
||||
|
||||
from app.command import Command
|
||||
from app.core.config import settings
|
||||
from app.core.module import ModuleManager
|
||||
@@ -44,6 +56,82 @@ def init_routers():
|
||||
App.include_router(arr_router, prefix="/api/v3")
|
||||
|
||||
|
||||
def start_frontend():
|
||||
"""
|
||||
启动前端服务
|
||||
"""
|
||||
if not SystemUtils.is_frozen():
|
||||
return
|
||||
nginx_path = settings.ROOT_PATH / 'nginx'
|
||||
if not nginx_path.exists():
|
||||
return
|
||||
import subprocess
|
||||
if SystemUtils.is_windows():
|
||||
subprocess.Popen("start nginx.exe",
|
||||
cwd=nginx_path,
|
||||
shell=True)
|
||||
else:
|
||||
subprocess.Popen("nohup ./nginx &",
|
||||
cwd=nginx_path,
|
||||
shell=True)
|
||||
|
||||
|
||||
def stop_frontend():
|
||||
"""
|
||||
停止前端服务
|
||||
"""
|
||||
if not SystemUtils.is_frozen():
|
||||
return
|
||||
import subprocess
|
||||
if SystemUtils.is_windows():
|
||||
subprocess.Popen(f"taskkill /f /im nginx.exe", shell=True)
|
||||
else:
|
||||
subprocess.Popen(f"killall nginx", shell=True)
|
||||
|
||||
|
||||
def start_tray():
|
||||
"""
|
||||
启动托盘图标
|
||||
"""
|
||||
|
||||
if not SystemUtils.is_frozen():
|
||||
return
|
||||
|
||||
def open_web():
|
||||
"""
|
||||
调用浏览器打开前端页面
|
||||
"""
|
||||
import webbrowser
|
||||
webbrowser.open(f"http://localhost:{settings.NGINX_PORT}")
|
||||
|
||||
def quit_app():
|
||||
"""
|
||||
退出程序
|
||||
"""
|
||||
TrayIcon.stop()
|
||||
Server.should_exit = True
|
||||
|
||||
import pystray
|
||||
|
||||
# 托盘图标
|
||||
TrayIcon = pystray.Icon(
|
||||
settings.PROJECT_NAME,
|
||||
icon=Image.open(settings.ROOT_PATH / 'app.ico'),
|
||||
menu=pystray.Menu(
|
||||
pystray.MenuItem(
|
||||
'打开',
|
||||
open_web,
|
||||
),
|
||||
pystray.MenuItem(
|
||||
'退出',
|
||||
quit_app,
|
||||
)
|
||||
)
|
||||
)
|
||||
# 启动托盘图标
|
||||
threading.Thread(target=TrayIcon.run, daemon=True).start()
|
||||
|
||||
|
||||
@App.on_event("shutdown")
|
||||
def shutdown_server():
|
||||
"""
|
||||
@@ -59,6 +147,8 @@ def shutdown_server():
|
||||
DisplayHelper().stop()
|
||||
# 停止定时服务
|
||||
Scheduler().stop()
|
||||
# 停止前端服务
|
||||
stop_frontend()
|
||||
|
||||
|
||||
@App.on_event("startup")
|
||||
@@ -66,7 +156,7 @@ def start_module():
|
||||
"""
|
||||
启动模块
|
||||
"""
|
||||
# 虚伪显示
|
||||
# 虚拟显示
|
||||
DisplayHelper()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
@@ -80,12 +170,16 @@ def start_module():
|
||||
Command()
|
||||
# 初始化路由
|
||||
init_routers()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 启动托盘
|
||||
start_tray()
|
||||
# 初始化数据库
|
||||
init_db()
|
||||
# 更新数据库
|
||||
update_db()
|
||||
# 启动服务
|
||||
# 启动API服务
|
||||
Server.run()
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.modules import _ModuleBase
|
||||
from app.modules.douban.apiv2 import DoubanApi
|
||||
from app.modules.douban.scraper import DoubanScraper
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
@@ -367,6 +368,16 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def tv_animation(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取豆瓣动画剧
|
||||
"""
|
||||
infos = self.doubanapi.tv_animation(start=(page - 1) * count,
|
||||
count=count)
|
||||
if not infos:
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
@@ -393,18 +404,33 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
return ret_medias
|
||||
|
||||
def match_doubaninfo(self, name: str, mtype: str = None,
|
||||
year: str = None, season: int = None) -> dict:
|
||||
@retry(Exception, 5, 3, 3, logger=logger)
|
||||
def match_doubaninfo(self, name: str, imdbid: str = None,
|
||||
mtype: str = None, year: str = None, season: int = None) -> dict:
|
||||
"""
|
||||
搜索和匹配豆瓣信息
|
||||
:param name: 名称
|
||||
:param imdbid: IMDB ID
|
||||
:param mtype: 类型 电影/电视剧
|
||||
:param year: 年份
|
||||
:param season: 季号
|
||||
"""
|
||||
if imdbid:
|
||||
# 优先使用IMDBID查询
|
||||
logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...")
|
||||
result = self.doubanapi.imdbid(imdbid)
|
||||
if result:
|
||||
return result
|
||||
# 搜索
|
||||
logger.info(f"开始使用名称 {name} 查询豆瓣信息 ...")
|
||||
result = self.doubanapi.search(f"{name} {year or ''}".strip())
|
||||
if not result:
|
||||
logger.warn(f"未找到 {name} 的豆瓣信息")
|
||||
return {}
|
||||
# 触发rate limit
|
||||
if "search_access_rate_limit" in result.values():
|
||||
logger.warn(f"触发豆瓣API速率限制 错误信息 {result} ...")
|
||||
raise Exception("触发豆瓣API速率限制")
|
||||
for item_obj in result.get("items"):
|
||||
type_name = item_obj.get("type_name")
|
||||
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
||||
@@ -454,6 +480,7 @@ class DoubanModule(_ModuleBase):
|
||||
return
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
@@ -476,6 +503,7 @@ class DoubanModule(_ModuleBase):
|
||||
continue
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
|
||||
@@ -18,28 +18,29 @@ class DoubanApi(metaclass=Singleton):
|
||||
_urls = {
|
||||
# 搜索类
|
||||
# sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映
|
||||
# q=search_word&start=0&count=20&sort=U
|
||||
# q=search_word&start: int = 0&count: int = 20&sort=U
|
||||
# 聚合搜索
|
||||
"search": "/search/weixin",
|
||||
"search_agg": "/search",
|
||||
"imdbid": "/movie/imdb/%s",
|
||||
|
||||
# 电影探索
|
||||
# sort=U:综合排序 T:近期热度 S:高分优先 R:首播时间
|
||||
# tags='日本,动画,2022'&start=0&count=20&sort=U
|
||||
# tags='日本,动画,2022'&start: int = 0&count: int = 20&sort=U
|
||||
"movie_recommend": "/movie/recommend",
|
||||
# 电视剧探索
|
||||
"tv_recommend": "/tv/recommend",
|
||||
# 搜索
|
||||
"movie_tag": "/movie/tag",
|
||||
"tv_tag": "/tv/tag",
|
||||
# q=search_word&start=0&count=20
|
||||
# q=search_word&start: int = 0&count: int = 20
|
||||
"movie_search": "/search/movie",
|
||||
"tv_search": "/search/movie",
|
||||
"book_search": "/search/book",
|
||||
"group_search": "/search/group",
|
||||
|
||||
# 各类主题合集
|
||||
# start=0&count=20
|
||||
# start: int = 0&count: int = 20
|
||||
# 正在上映
|
||||
"movie_showing": "/subject_collection/movie_showing/items",
|
||||
# 热门电影
|
||||
@@ -145,112 +146,277 @@ class DoubanApi(metaclass=Singleton):
|
||||
"api-client/1 com.douban.frodo/7.3.0(207) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1"]
|
||||
_api_secret_key = "bf7dddc7c9cfe6f7"
|
||||
_api_key = "0dad551ec0f84ed02907ff5c42e8ec70"
|
||||
_api_key2 = "0ab215a8b1977939201640fa14c66bab"
|
||||
_base_url = "https://frodo.douban.com/api/v2"
|
||||
_session = requests.Session()
|
||||
_api_url = "https://api.douban.com/v2"
|
||||
_session = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self._session = requests.Session()
|
||||
|
||||
@classmethod
|
||||
def __sign(cls, url: str, ts: int, method='GET') -> str:
|
||||
"""
|
||||
签名
|
||||
"""
|
||||
url_path = parse.urlparse(url).path
|
||||
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
|
||||
return base64.b64encode(hmac.new(cls._api_secret_key.encode(), raw_sign.encode(), hashlib.sha1).digest()
|
||||
).decode()
|
||||
return base64.b64encode(
|
||||
hmac.new(
|
||||
cls._api_secret_key.encode(),
|
||||
raw_sign.encode(),
|
||||
hashlib.sha1
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
@classmethod
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
def __invoke(cls, url, **kwargs):
|
||||
req_url = cls._base_url + url
|
||||
def __invoke(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
GET请求
|
||||
"""
|
||||
req_url = self._base_url + url
|
||||
|
||||
params = {'apiKey': cls._api_key}
|
||||
params = {'apiKey': self._api_key}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
|
||||
ts = params.pop('_ts', int(datetime.strftime(datetime.now(), '%Y%m%d')))
|
||||
params.update({'os_rom': 'android', 'apiKey': cls._api_key, '_ts': ts, '_sig': cls.__sign(url=req_url, ts=ts)})
|
||||
|
||||
resp = RequestUtils(ua=choice(cls._user_agents), session=cls._session).get_res(url=req_url, params=params)
|
||||
|
||||
ts = params.pop(
|
||||
'_ts',
|
||||
datetime.strftime(datetime.now(), '%Y%m%d')
|
||||
)
|
||||
params.update({
|
||||
'os_rom': 'android',
|
||||
'apiKey': self._api_key,
|
||||
'_ts': ts,
|
||||
'_sig': self.__sign(url=req_url, ts=ts)
|
||||
})
|
||||
resp = RequestUtils(
|
||||
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:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
def search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
@lru_cache(maxsize=settings.CACHE_CONF.get('douban'))
|
||||
def __post(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
POST请求
|
||||
esponse = requests.post(
|
||||
url="https://api.douban.com/v2/movie/imdb/tt29139455",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
"Cookie": "bid=J9zb1zA5sJc",
|
||||
},
|
||||
data={
|
||||
"apikey": "0ab215a8b1977939201640fa14c66bab",
|
||||
},
|
||||
)
|
||||
"""
|
||||
req_url = self._api_url + url
|
||||
params = {'apikey': self._api_key2}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
if '_ts' in params:
|
||||
params.pop('_ts')
|
||||
resp = RequestUtils(
|
||||
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:
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
def movie_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')) -> dict:
|
||||
"""
|
||||
关键字搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_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 book_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["book_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def movie_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def group_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["group_search"], q=keyword, start=start, count=count, _ts=ts)
|
||||
def tv_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_showing(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_showing"], start=start, count=count, _ts=ts)
|
||||
def book_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
书籍搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["book_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_soon(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_soon"], start=start, count=count, _ts=ts)
|
||||
def group_search(self, keyword: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
小组搜索
|
||||
"""
|
||||
return self.__invoke(self._urls["group_search"], q=keyword,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_hot_gaia(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_hot_gaia"], start=start, count=count, _ts=ts)
|
||||
def movie_showing(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
正在热映
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_showing"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_hot"], start=start, count=count, _ts=ts)
|
||||
def movie_soon(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
即将上映
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_soon"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_animation(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_animation"], start=start, count=count, _ts=ts)
|
||||
def movie_hot_gaia(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
热门电影
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_hot_gaia"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_variety_show(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_variety_show"], start=start, count=count, _ts=ts)
|
||||
def tv_hot(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
热门剧集
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_rank_list(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_rank_list"], start=start, count=count, _ts=ts)
|
||||
def tv_animation(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
动画
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_animation"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def show_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["show_hot"], start=start, count=count, _ts=ts)
|
||||
def tv_variety_show(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
综艺
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_variety_show"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_detail(self, subject_id):
|
||||
def tv_rank_list(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧排行榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_rank_list"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def show_hot(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
综艺热门
|
||||
"""
|
||||
return self.__invoke(self._urls["show_hot"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_detail(self, subject_id: str):
|
||||
"""
|
||||
电影详情
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_detail"] + subject_id)
|
||||
|
||||
def movie_celebrities(self, subject_id):
|
||||
def movie_celebrities(self, subject_id: str):
|
||||
"""
|
||||
电影演职员
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_celebrities"] % subject_id)
|
||||
|
||||
def tv_detail(self, subject_id):
|
||||
def tv_detail(self, subject_id: str):
|
||||
"""
|
||||
电视剧详情
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_detail"] + subject_id)
|
||||
|
||||
def tv_celebrities(self, subject_id):
|
||||
def tv_celebrities(self, subject_id: str):
|
||||
"""
|
||||
电视剧演职员
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_celebrities"] % subject_id)
|
||||
|
||||
def book_detail(self, subject_id):
|
||||
def book_detail(self, subject_id: str):
|
||||
"""
|
||||
书籍详情
|
||||
"""
|
||||
return self.__invoke(self._urls["book_detail"] + subject_id)
|
||||
|
||||
def movie_top250(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_top250"], start=start, count=count, _ts=ts)
|
||||
def movie_top250(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影TOP250
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_top250"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def movie_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
|
||||
def movie_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电影探索
|
||||
"""
|
||||
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
|
||||
def tv_recommend(self, tags='', sort='R', start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
电视剧探索
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_chinese_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"], start=start, count=count, _ts=ts)
|
||||
def tv_chinese_best_weekly(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
华语口碑周榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_chinese_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def tv_global_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"], start=start, count=count, _ts=ts)
|
||||
def tv_global_best_weekly(self, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
全球口碑周榜
|
||||
"""
|
||||
return self.__invoke(self._urls["tv_global_best_weekly"],
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def doulist_detail(self, subject_id):
|
||||
def doulist_detail(self, subject_id: str):
|
||||
"""
|
||||
豆列详情
|
||||
:param subject_id: 豆列id
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist"] + subject_id)
|
||||
|
||||
def doulist_items(self, subject_id, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
def doulist_items(self, subject_id: str, start: int = 0, count: int = 20,
|
||||
ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
豆列列表
|
||||
:param subject_id: 豆列id
|
||||
@@ -258,4 +424,9 @@ class DoubanApi(metaclass=Singleton):
|
||||
:param count: 数量
|
||||
:param ts: 时间戳
|
||||
"""
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id, start=start, count=count, _ts=ts)
|
||||
return self.__invoke(self._urls["doulist_items"] % subject_id,
|
||||
start=start, count=count, _ts=ts)
|
||||
|
||||
def __del__(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
@@ -161,7 +161,13 @@ class DoubanScraper:
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
# 没有后缀时,处理URL转化为jpg格式
|
||||
if not file_path.suffix:
|
||||
url = url.replace("/format/webp", "/format/jpg")
|
||||
file_path.with_suffix(".jpg")
|
||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||
r = RequestUtils().get_res(url=url)
|
||||
if r:
|
||||
|
||||
@@ -23,7 +23,7 @@ class Emby(metaclass=Singleton):
|
||||
if not self._host.startswith("http"):
|
||||
self._host = "http://" + self._host
|
||||
self._apikey = settings.EMBY_API_KEY
|
||||
self.user = self.get_user()
|
||||
self.user = self.get_user(settings.SUPERUSER)
|
||||
self.folders = self.get_emby_folders()
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
@@ -473,8 +473,8 @@ class Emby(metaclass=Singleton):
|
||||
return None
|
||||
# 查找需要刷新的媒体库ID
|
||||
item_path = Path(item.target_path)
|
||||
# 匹配子目录
|
||||
for folder in self.folders:
|
||||
# 匹配子目录
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
try:
|
||||
# 匹配子目录
|
||||
@@ -483,7 +483,8 @@ class Emby(metaclass=Singleton):
|
||||
return folder.get("Id")
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
for folder in self.folders:
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
|
||||
subfolder.get("Path")):
|
||||
|
||||
@@ -80,6 +80,12 @@ class FileTransferModule(_ModuleBase):
|
||||
elif transfer_type == 'move':
|
||||
# 移动
|
||||
retcode, retmsg = SystemUtils.move(file_item, target_file)
|
||||
elif transfer_type == 'rclone_move':
|
||||
# Rclone 移动
|
||||
retcode, retmsg = SystemUtils.rclone_move(file_item, target_file)
|
||||
elif transfer_type == 'rclone_copy':
|
||||
# Rclone 复制
|
||||
retcode, retmsg = SystemUtils.rclone_copy(file_item, target_file)
|
||||
else:
|
||||
# 复制
|
||||
retcode, retmsg = SystemUtils.copy(file_item, target_file)
|
||||
@@ -376,13 +382,14 @@ class FileTransferModule(_ModuleBase):
|
||||
path=in_path,
|
||||
message=f"{in_path} 路径不存在")
|
||||
|
||||
if not target_dir.exists():
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{target_dir} 目标路径不存在")
|
||||
|
||||
# 媒体库目的目录
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
if transfer_type not in ['rclone_copy', 'rclone_move']:
|
||||
# 检查目标路径
|
||||
if not target_dir.exists():
|
||||
return TransferInfo(success=False,
|
||||
path=in_path,
|
||||
message=f"{target_dir} 目标路径不存在")
|
||||
# 媒体库目的目录
|
||||
target_dir = self.__get_dest_dir(mediainfo=mediainfo, target_dir=target_dir)
|
||||
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
@@ -542,7 +549,9 @@ class FileTransferModule(_ModuleBase):
|
||||
# 剧集标题
|
||||
"episode_title": episode_title,
|
||||
# 文件后缀
|
||||
"fileExt": file_ext
|
||||
"fileExt": file_ext,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -70,11 +70,16 @@ class FilterModule(_ModuleBase):
|
||||
"include": [r'[Hx].?264|AVC'],
|
||||
"exclude": []
|
||||
},
|
||||
# 杜比
|
||||
# 杜比视界
|
||||
"DOLBY": {
|
||||
"include": [r"Dolby[\s.]+Vision|DOVI|[\s.]+DV[\s.]+|杜比视界"],
|
||||
"exclude": []
|
||||
},
|
||||
# 杜比全景声
|
||||
"ATMOS": {
|
||||
"include": [r"Dolby[\s.+]+Atmos|Atmos|杜比全景[声聲]"],
|
||||
"exclude": []
|
||||
},
|
||||
# HDR
|
||||
"HDR": {
|
||||
"include": [r"[\s.]+HDR[\s.]+|HDR10|HDR10\+"],
|
||||
@@ -98,7 +103,12 @@ class FilterModule(_ModuleBase):
|
||||
"CNVOI": {
|
||||
"include": [r'[国國][语語]配音|[国國]配|[国國][语語]'],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
# 60FPS
|
||||
"60FPS": {
|
||||
"include": [r'60fps'],
|
||||
"exclude": []
|
||||
},
|
||||
}
|
||||
|
||||
def init_module(self) -> None:
|
||||
|
||||
@@ -21,7 +21,7 @@ class Jellyfin(metaclass=Singleton):
|
||||
if not self._host.startswith("http"):
|
||||
self._host = "http://" + self._host
|
||||
self._apikey = settings.JELLYFIN_API_KEY
|
||||
self.user = self.get_user()
|
||||
self.user = self.get_user(settings.SUPERUSER)
|
||||
self.serverid = self.get_server_id()
|
||||
|
||||
def is_inactive(self) -> bool:
|
||||
|
||||
@@ -225,6 +225,8 @@ class QbittorrentModule(_ModuleBase):
|
||||
"""
|
||||
# 调用Qbittorrent API查询实时信息
|
||||
info = self.qbittorrent.transfer_info()
|
||||
if not info:
|
||||
return schemas.DownloaderInfo()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.get("dl_info_speed"),
|
||||
upload_speed=info.get("up_info_speed"),
|
||||
|
||||
@@ -63,7 +63,10 @@ class TheMovieDbModule(_ModuleBase):
|
||||
# 直接查询详情
|
||||
info = self.tmdb.get_info(mtype=mtype, tmdbid=tmdbid)
|
||||
elif meta:
|
||||
logger.info(f"正在识别 {meta.name} ...")
|
||||
if meta.begin_season:
|
||||
logger.info(f"正在识别 {meta.name} 第{meta.begin_season}季 ...")
|
||||
else:
|
||||
logger.info(f"正在识别 {meta.name} ...")
|
||||
if meta.type == MediaType.UNKNOWN and not meta.year:
|
||||
info = self.tmdb.match_multi(meta.name)
|
||||
else:
|
||||
@@ -280,6 +283,8 @@ class TheMovieDbModule(_ModuleBase):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:return: 更新后的媒体信息
|
||||
"""
|
||||
if not mediainfo.tmdb_id:
|
||||
return mediainfo
|
||||
if mediainfo.logo_path \
|
||||
and mediainfo.poster_path \
|
||||
and mediainfo.backdrop_path:
|
||||
@@ -345,7 +350,7 @@ class TheMovieDbModule(_ModuleBase):
|
||||
image_path = seasoninfo.get(image_type.value)
|
||||
|
||||
if image_path:
|
||||
return f"https://image.tmdb.org/t/p/{image_prefix}{image_path}"
|
||||
return f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/{image_prefix}{image_path}"
|
||||
return None
|
||||
|
||||
def movie_similar(self, tmdbid: int) -> List[dict]:
|
||||
|
||||
@@ -165,6 +165,10 @@ class TmdbScraper:
|
||||
DomUtils.add_node(doc, xactor, "type", "Actor")
|
||||
DomUtils.add_node(doc, xactor, "role", actor.get("character") or actor.get("role") or "")
|
||||
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
|
||||
DomUtils.add_node(doc, xactor, "thumb",
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{actor.get('profile_path')}")
|
||||
DomUtils.add_node(doc, xactor, "profile",
|
||||
f"https://www.themoviedb.org/person/{actor.get('id')}")
|
||||
# 风格
|
||||
genres = mediainfo.genres or []
|
||||
for genre in genres:
|
||||
@@ -317,6 +321,10 @@ class TmdbScraper:
|
||||
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
|
||||
DomUtils.add_node(doc, xactor, "type", "Actor")
|
||||
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
|
||||
DomUtils.add_node(doc, xactor, "thumb",
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{actor.get('profile_path')}")
|
||||
DomUtils.add_node(doc, xactor, "profile",
|
||||
f"https://www.themoviedb.org/person/{actor.get('id')}")
|
||||
# 保存文件
|
||||
self.__save_nfo(doc, file_path.with_suffix(".nfo"))
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
import requests.exceptions
|
||||
|
||||
from app.utils.http import RequestUtils
|
||||
from .exceptions import TMDbException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,7 +26,15 @@ class TMDb(object):
|
||||
TMDB_DOMAIN = "TMDB_DOMAIN"
|
||||
REQUEST_CACHE_MAXSIZE = None
|
||||
|
||||
def __init__(self, obj_cached=True):
|
||||
_req = None
|
||||
_session = None
|
||||
|
||||
def __init__(self, obj_cached=True, session=None):
|
||||
if session is not None:
|
||||
self._req = RequestUtils(session=session, proxies=self.proxies)
|
||||
else:
|
||||
self._session = requests.Session()
|
||||
self._req = RequestUtils(session=self._session, proxies=self.proxies)
|
||||
self._remaining = 40
|
||||
self._reset = None
|
||||
self._timeout = 15
|
||||
@@ -128,10 +138,18 @@ class TMDb(object):
|
||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||
|
||||
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
|
||||
def cached_request(self, method, url, data, json):
|
||||
with requests.Session() as s:
|
||||
return s.request(method, url, data=data, json=json,
|
||||
timeout=self._timeout, proxies=self.proxies)
|
||||
def cached_request(self, method, url, data, json,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
缓存请求,时间默认1天
|
||||
"""
|
||||
return self.request(method, url, data, json)
|
||||
|
||||
def request(self, method, url, data, json):
|
||||
if method == "GET":
|
||||
return self._req.get_res(url, params=data, json=json)
|
||||
else:
|
||||
return self._req.post_res(url, data=data, json=json)
|
||||
|
||||
def cache_clear(self):
|
||||
return self.cached_request.cache_clear()
|
||||
@@ -152,9 +170,10 @@ class TMDb(object):
|
||||
if self.cache and self.obj_cached and call_cached and method != "POST":
|
||||
req = self.cached_request(method, url, data, json)
|
||||
else:
|
||||
with requests.Session() as s:
|
||||
req = s.request(method, url, data=data, json=json,
|
||||
timeout=self._timeout, proxies=self.proxies)
|
||||
req = self.request(method, url, data, json)
|
||||
|
||||
if req is None:
|
||||
raise TMDbException("Failed to establish a new connection: no response from the server.")
|
||||
|
||||
headers = req.headers
|
||||
|
||||
@@ -199,3 +218,7 @@ class TMDb(object):
|
||||
if key:
|
||||
return json.get(key)
|
||||
return json
|
||||
|
||||
def __del__(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
@@ -211,6 +211,8 @@ class TransmissionModule(_ModuleBase):
|
||||
下载器信息
|
||||
"""
|
||||
info = self.transmission.transfer_info()
|
||||
if not info:
|
||||
return schemas.DownloaderInfo()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.download_speed,
|
||||
upload_speed=info.upload_speed,
|
||||
|
||||
@@ -237,7 +237,7 @@ class BrushFlow(_PluginBase):
|
||||
self._scheduler.add_job(self.brush, 'interval', minutes=self._cron)
|
||||
except Exception as e:
|
||||
logger.error(f"站点刷流服务启动失败:{e}")
|
||||
self.systemmessage(f"站点刷流服务启动失败:{e}")
|
||||
self.systemmessage.put(f"站点刷流服务启动失败:{e}")
|
||||
return
|
||||
if self._onlyonce:
|
||||
logger.info(f"站点刷流服务启动,立即运行一次")
|
||||
@@ -858,7 +858,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/upload.png'
|
||||
'src': '/plugin_icon/upload.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -928,7 +928,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/download.png'
|
||||
'src': '/plugin_icon/download.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -998,7 +998,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/seed.png'
|
||||
'src': '/plugin_icon/seed.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1068,7 +1068,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/delete.png'
|
||||
'src': '/plugin_icon/delete.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1882,7 +1882,7 @@ class BrushFlow(_PluginBase):
|
||||
pubdate = pubdate.replace("T", " ").replace("Z", "")
|
||||
pubdate = datetime.strptime(pubdate, "%Y-%m-%d %H:%M:%S")
|
||||
now = datetime.now()
|
||||
return (now - pubdate).seconds // 60
|
||||
return (now - pubdate).total_seconds() // 60
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
return 0
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.plugins.chatgpt.openai import OpenAi
|
||||
from app.schemas.types import EventType
|
||||
@@ -33,6 +34,7 @@ class ChatGPT(_PluginBase):
|
||||
openai = None
|
||||
_enabled = False
|
||||
_proxy = False
|
||||
_recognize = False
|
||||
_openai_url = None
|
||||
_openai_key = None
|
||||
|
||||
@@ -40,6 +42,7 @@ class ChatGPT(_PluginBase):
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._proxy = config.get("proxy")
|
||||
self._recognize = config.get("recognize")
|
||||
self._openai_url = config.get("openai_url")
|
||||
self._openai_key = config.get("openai_key")
|
||||
self.openai = OpenAi(api_key=self._openai_key, api_url=self._openai_url,
|
||||
@@ -70,7 +73,7 @@ class ChatGPT(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -86,7 +89,7 @@ class ChatGPT(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -97,6 +100,22 @@ class ChatGPT(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'recognize',
|
||||
'label': '辅助识别',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -143,6 +162,7 @@ class ChatGPT(_PluginBase):
|
||||
], {
|
||||
"enabled": False,
|
||||
"proxy": False,
|
||||
"recognize": False,
|
||||
"openai_url": "https://api.openai.com",
|
||||
"openai_key": ""
|
||||
}
|
||||
@@ -151,10 +171,12 @@ class ChatGPT(_PluginBase):
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.UserMessage)
|
||||
def talk(self, event):
|
||||
def talk(self, event: Event):
|
||||
"""
|
||||
监听用户消息,获取ChatGPT回复
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
if not self.openai:
|
||||
return
|
||||
text = event.event_data.get("text")
|
||||
@@ -166,6 +188,42 @@ class ChatGPT(_PluginBase):
|
||||
if response:
|
||||
self.post_message(channel=channel, title=response, userid=userid)
|
||||
|
||||
@eventmanager.register(EventType.NameRecognize)
|
||||
def recognize(self, event: Event):
|
||||
"""
|
||||
监听识别事件,使用ChatGPT辅助识别名称
|
||||
"""
|
||||
if not event.event_data:
|
||||
return
|
||||
title = event.event_data.get("title")
|
||||
if not title:
|
||||
return
|
||||
# 收到事件后需要立码返回,避免主程序等待
|
||||
if not self._enabled \
|
||||
or not self.openai \
|
||||
or not self._recognize:
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognizeResult,
|
||||
{
|
||||
'title': title
|
||||
}
|
||||
)
|
||||
return
|
||||
# 调用ChatGPT
|
||||
response = self.openai.get_media_name(filename=title)
|
||||
logger.info(f"ChatGPT辅助识别结果:{response}")
|
||||
if response:
|
||||
eventmanager.send_event(
|
||||
EventType.NameRecognizeResult,
|
||||
{
|
||||
'title': title,
|
||||
'name': response.get("title"),
|
||||
'year': response.get("year"),
|
||||
'season': response.get("season"),
|
||||
'episode': response.get("episode")
|
||||
}
|
||||
)
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
|
||||
@@ -251,6 +251,6 @@ class ChineseSubFinder(_PluginBase):
|
||||
else:
|
||||
logger.info("ChineseSubFinder任务添加成功:%s" % job_id)
|
||||
else:
|
||||
logger.error("%s 目录缺失nfo元数据" % file_path)
|
||||
logger.warn(f"ChineseSubFinder调用出错:{res.status_code} - {res.reason}")
|
||||
except Exception as e:
|
||||
logger.error("连接ChineseSubFinder出错:" + str(e))
|
||||
|
||||
@@ -211,7 +211,7 @@ class CustomHosts(_PluginBase):
|
||||
# 添加新的Hosts
|
||||
system_hosts.add(new_entrys)
|
||||
system_hosts.write()
|
||||
logger.info("更新系统hosts文件成功")
|
||||
logger.info("更新系统hosts文件成功(注:容器运行则更新容器hosts!)")
|
||||
except Exception as err:
|
||||
err_flag = True
|
||||
logger.error(f"更新系统hosts文件失败:{str(err) or '请检查权限'}")
|
||||
|
||||
@@ -87,6 +87,8 @@ class DirMonitor(_PluginBase):
|
||||
_exclude_keywords = ""
|
||||
# 存储源目录与目的目录关系
|
||||
_dirconf: Dict[str, Path] = {}
|
||||
# 存储源目录转移方式
|
||||
_transferconf: Dict[str, str] = {}
|
||||
_medias = {}
|
||||
# 退出事件
|
||||
_event = Event()
|
||||
@@ -95,9 +97,10 @@ class DirMonitor(_PluginBase):
|
||||
self.transferhis = TransferHistoryOper(self.db)
|
||||
self.downloadhis = DownloadHistoryOper(self.db)
|
||||
self.transferchian = TransferChain(self.db)
|
||||
self.tmdbchain = TmdbChain(self.db)
|
||||
self.tmdbchain = TmdbChain()
|
||||
# 清空配置
|
||||
self._dirconf = {}
|
||||
self._transferconf = {}
|
||||
|
||||
# 读取配置
|
||||
if config:
|
||||
@@ -123,6 +126,12 @@ class DirMonitor(_PluginBase):
|
||||
if not mon_path:
|
||||
continue
|
||||
|
||||
# 自定义转移方式
|
||||
_transfer_type = self._transfer_type
|
||||
if mon_path.count("#") == 1:
|
||||
_transfer_type = mon_path.split("#")[1]
|
||||
mon_path = mon_path.split("#")[0]
|
||||
|
||||
# 存储目的目录
|
||||
if SystemUtils.is_windows():
|
||||
if mon_path.count(":") > 1:
|
||||
@@ -132,15 +141,20 @@ class DirMonitor(_PluginBase):
|
||||
paths = [mon_path]
|
||||
else:
|
||||
paths = mon_path.split(":")
|
||||
|
||||
# 目的目录
|
||||
target_path = None
|
||||
if len(paths) > 1:
|
||||
mon_path = paths[0]
|
||||
target_path = Path(paths[1])
|
||||
self._dirconf[mon_path] = target_path
|
||||
|
||||
# 转移方式
|
||||
self._transferconf[mon_path] = _transfer_type
|
||||
|
||||
# 检查媒体库目录是不是下载目录的子目录
|
||||
try:
|
||||
if target_path.is_relative_to(Path(mon_path)):
|
||||
if target_path and target_path.is_relative_to(Path(mon_path)):
|
||||
logger.warn(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
|
||||
self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
|
||||
continue
|
||||
@@ -247,6 +261,8 @@ class DirMonitor(_PluginBase):
|
||||
|
||||
# 查询转移目的目录
|
||||
target: Path = self._dirconf.get(mon_path)
|
||||
# 查询转移方式
|
||||
transfer_type = self._transferconf.get(mon_path)
|
||||
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta)
|
||||
@@ -260,7 +276,7 @@ class DirMonitor(_PluginBase):
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=self._transfer_type,
|
||||
mode=transfer_type,
|
||||
meta=file_meta
|
||||
)
|
||||
return
|
||||
@@ -289,7 +305,7 @@ class DirMonitor(_PluginBase):
|
||||
# 转移
|
||||
transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=self._transfer_type,
|
||||
transfer_type=transfer_type,
|
||||
target=target,
|
||||
meta=file_meta,
|
||||
episodes_info=episodes_info)
|
||||
@@ -303,7 +319,7 @@ class DirMonitor(_PluginBase):
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=self._transfer_type,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
@@ -320,7 +336,7 @@ class DirMonitor(_PluginBase):
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=self._transfer_type,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
@@ -402,7 +418,7 @@ class DirMonitor(_PluginBase):
|
||||
})
|
||||
|
||||
# 移动模式删除空目录
|
||||
if self._transfer_type == "move":
|
||||
if transfer_type == "move":
|
||||
for file_dir in file_path.parents:
|
||||
if len(str(file_dir)) <= len(str(Path(mon_path))):
|
||||
# 重要,删除到监控目录为止
|
||||
@@ -601,9 +617,11 @@ class DirMonitor(_PluginBase):
|
||||
'model': 'monitor_dirs',
|
||||
'label': '监控目录',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录,支持两种配置方式:\n'
|
||||
'placeholder': '每一行一个目录,支持三种配置方式:\n'
|
||||
'监控目录\n'
|
||||
'监控目录:转移目的目录(需同时在媒体库目录中配置该目的目录)'
|
||||
'监控目录#转移方式(move|copy|link|softlink|rclone_copy|rclone_move)\n'
|
||||
'监控目录:转移目的目录(需同时在媒体库目录中配置该目的目录)\n'
|
||||
'监控目录:转移目的目录#转移方式(move|copy|link|softlink|rclone_copy|rclone_move)'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -449,15 +449,19 @@ class DoubanSync(_PluginBase):
|
||||
results = self.rsshelper.parse(url)
|
||||
if not results:
|
||||
logger.error(f"未获取到用户 {user_id} 豆瓣RSS数据:{url}")
|
||||
return
|
||||
continue
|
||||
else:
|
||||
logger.info(f"获取到用户 {user_id} 豆瓣RSS数据:{len(results)}")
|
||||
# 解析数据
|
||||
for result in results:
|
||||
try:
|
||||
dtype = result.get("title", "")[:2]
|
||||
title = result.get("title", "")[2:]
|
||||
if dtype not in ["想看"]:
|
||||
if dtype not in ["想看", "在看"]:
|
||||
logger.info(f'标题:{title},非想看/在看数据,跳过')
|
||||
continue
|
||||
if not result.get("link"):
|
||||
logger.warn(f'标题:{title},未获取到链接,跳过')
|
||||
continue
|
||||
# 判断是否在天数范围
|
||||
pubdate: Optional[datetime.datetime] = result.get("pubdate")
|
||||
@@ -468,6 +472,7 @@ class DoubanSync(_PluginBase):
|
||||
douban_id = result.get("link", "").split("/")[-2]
|
||||
# 检查是否处理过
|
||||
if not douban_id or douban_id in [h.get("doubanid") for h in history]:
|
||||
logger.info(f'标题:{title},豆瓣ID:{douban_id} 已处理过')
|
||||
continue
|
||||
# 根据豆瓣ID获取豆瓣数据
|
||||
doubaninfo: Optional[dict] = self.chain.douban_info(doubanid=douban_id)
|
||||
|
||||
@@ -156,7 +156,7 @@ class DownloadingMsg(_PluginBase):
|
||||
channel_value = downloadhis.channel
|
||||
else:
|
||||
try:
|
||||
context = MediaChain(self.db).recognize_by_title(title=torrent.title)
|
||||
context = MediaChain().recognize_by_title(title=torrent.title)
|
||||
if not context or not context.media_info:
|
||||
continue
|
||||
media_info = context.media_info
|
||||
|
||||
292
app/plugins/invitessignin/__init__.py
Normal file
292
app/plugins/invitessignin/__init__.py
Normal file
@@ -0,0 +1,292 @@
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.core.config import settings
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType
|
||||
from app.utils.http import RequestUtils
|
||||
|
||||
|
||||
class InvitesSignin(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "药丸签到"
|
||||
# 插件描述
|
||||
plugin_desc = "药丸论坛签到。"
|
||||
# 插件图标
|
||||
plugin_icon = "invites.png"
|
||||
# 主题色
|
||||
plugin_color = "#FFFFFF"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "invitessignin"
|
||||
# 加载顺序
|
||||
plugin_order = 24
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_cron = None
|
||||
_cookie = None
|
||||
_onlyonce = False
|
||||
_notify = False
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._cron = config.get("cron")
|
||||
self._cookie = config.get("cookie")
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
|
||||
# 加载模块
|
||||
if self._enabled:
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self._cron:
|
||||
try:
|
||||
self._scheduler.add_job(func=self.__signin,
|
||||
trigger=CronTrigger.from_crontab(self._cron),
|
||||
name="药丸签到")
|
||||
except Exception as err:
|
||||
logger.error(f"定时任务配置错误:{err}")
|
||||
|
||||
if self._onlyonce:
|
||||
logger.info(f"药丸签到服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__signin, trigger='date',
|
||||
run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3),
|
||||
name="药丸签到")
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
self.update_config({
|
||||
"onlyonce": False,
|
||||
"cron": self._cron,
|
||||
"enabled": self._enabled,
|
||||
"cookie": self._cookie,
|
||||
"notify": self._notify,
|
||||
})
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __signin(self):
|
||||
"""
|
||||
药丸签到
|
||||
"""
|
||||
res = RequestUtils(cookies=self._cookie).get_res(url="https://invites.fun")
|
||||
if not res or res.status_code != 200:
|
||||
logger.error("请求药丸错误")
|
||||
return
|
||||
|
||||
# 获取csrfToken
|
||||
pattern = r'"csrfToken":"(.*?)"'
|
||||
csrfToken = re.findall(pattern, res.text)
|
||||
if not csrfToken:
|
||||
logger.error("请求csrfToken失败")
|
||||
return
|
||||
|
||||
csrfToken = csrfToken[0]
|
||||
logger.info(f"获取csrfToken成功 {csrfToken}")
|
||||
|
||||
# 获取userid
|
||||
pattern = r'"userId":(\d+)'
|
||||
match = re.search(pattern, res.text)
|
||||
|
||||
if match:
|
||||
userId = match.group(1)
|
||||
logger.info(f"获取userid成功 {userId}")
|
||||
else:
|
||||
logger.error("未找到userId")
|
||||
return
|
||||
|
||||
headers = {
|
||||
"X-Csrf-Token": csrfToken,
|
||||
"X-Http-Method-Override": "PATCH",
|
||||
"Cookie": self._cookie
|
||||
}
|
||||
|
||||
data = {
|
||||
"data": {
|
||||
"type": "users",
|
||||
"attributes": {
|
||||
"canCheckin": False,
|
||||
"totalContinuousCheckIn": 2
|
||||
},
|
||||
"id": userId
|
||||
}
|
||||
}
|
||||
|
||||
# 开始签到
|
||||
res = RequestUtils(headers=headers).post_res(url=f"https://invites.fun/api/users/{userId}", json=data)
|
||||
|
||||
if not res or res.status_code != 200:
|
||||
logger.error("药丸签到失败")
|
||||
return
|
||||
|
||||
sign_dict = json.loads(res.text)
|
||||
money = sign_dict['data']['attributes']['money']
|
||||
totalContinuousCheckIn = sign_dict['data']['attributes']['totalContinuousCheckIn']
|
||||
|
||||
# 发送通知
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title="【药丸签到任务完成】",
|
||||
text=f"累计签到 {totalContinuousCheckIn} \n"
|
||||
f"剩余药丸 {money}")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
|
||||
def get_form(self) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '开启通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '签到周期'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cookie',
|
||||
'label': '药丸cookie'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"notify": False,
|
||||
"cookie": "",
|
||||
"cron": "0 9 * * *"
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
try:
|
||||
if self._scheduler:
|
||||
self._scheduler.remove_all_jobs()
|
||||
if self._scheduler.running:
|
||||
self._scheduler.shutdown()
|
||||
self._scheduler = None
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
@@ -109,7 +109,7 @@ class IYUUAutoSeed(_PluginBase):
|
||||
self._nolabels = config.get("nolabels")
|
||||
self._nopaths = config.get("nopaths")
|
||||
self._clearcache = config.get("clearcache")
|
||||
self._permanent_error_caches = config.get("permanent_error_caches") or []
|
||||
self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or []
|
||||
self._error_caches = [] if self._clearcache else config.get("error_caches") or []
|
||||
self._success_caches = [] if self._clearcache else config.get("success_caches") or []
|
||||
|
||||
|
||||
@@ -741,7 +741,11 @@ class MediaSyncDel(_PluginBase):
|
||||
return
|
||||
|
||||
# 遍历删除
|
||||
last_del_time = None
|
||||
for del_media in del_medias:
|
||||
# 删除时间
|
||||
del_time = del_media.get("time")
|
||||
last_del_time = del_time
|
||||
# 媒体类型 Movie|Series|Season|Episode
|
||||
media_type = del_media.get("type")
|
||||
# 媒体名称 蜀山战纪
|
||||
@@ -881,7 +885,7 @@ class MediaSyncDel(_PluginBase):
|
||||
# 保存历史
|
||||
self.save_data("history", history)
|
||||
|
||||
self.save_data("last_time", datetime.datetime.now())
|
||||
self.save_data("last_time", last_del_time or datetime.datetime.now())
|
||||
|
||||
def handle_torrent(self, src: str, torrent_hash: str):
|
||||
"""
|
||||
@@ -1047,146 +1051,207 @@ class MediaSyncDel(_PluginBase):
|
||||
|
||||
@staticmethod
|
||||
def parse_emby_log(last_time):
|
||||
log_url = "[HOST]System/Logs/embyserver.txt?api_key=[APIKEY]"
|
||||
log_res = Emby().get_data(log_url)
|
||||
if not log_res or log_res.status_code != 200:
|
||||
logger.error("获取emby日志失败,请检查服务器配置")
|
||||
return []
|
||||
"""
|
||||
获取emby日志列表、解析emby日志
|
||||
"""
|
||||
|
||||
# 正则解析删除的媒体信息
|
||||
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)'
|
||||
matches = re.findall(pattern, log_res.text)
|
||||
def __parse_log(file_name: str, del_list: list):
|
||||
"""
|
||||
解析emby日志
|
||||
"""
|
||||
log_url = f"[HOST]System/Logs/{file_name}?api_key=[APIKEY]"
|
||||
log_res = Emby().get_data(log_url)
|
||||
if not log_res or log_res.status_code != 200:
|
||||
logger.error("获取emby日志失败,请检查服务器配置")
|
||||
return del_list
|
||||
|
||||
# 正则解析删除的媒体信息
|
||||
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)'
|
||||
matches = re.findall(pattern, log_res.text)
|
||||
|
||||
# 循环获取媒体信息
|
||||
for match in matches:
|
||||
mtime = match[0]
|
||||
# 排除已处理的媒体信息
|
||||
if last_time and mtime < last_time:
|
||||
continue
|
||||
|
||||
mtype = match[1]
|
||||
name = match[2]
|
||||
path = match[3]
|
||||
|
||||
year = None
|
||||
year_pattern = r'\(\d+\)'
|
||||
year_match = re.search(year_pattern, path)
|
||||
if year_match:
|
||||
year = year_match.group()[1:-1]
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
if mtype == 'Episode' or mtype == 'Season':
|
||||
name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
|
||||
season_pattern = r"Season\s*(\d+)"
|
||||
episode_pattern = r"S\d+E(\d+)"
|
||||
name_match = re.search(name_pattern, path)
|
||||
season_match = re.search(season_pattern, path)
|
||||
episode_match = re.search(episode_pattern, path)
|
||||
|
||||
if name_match:
|
||||
name = name_match.group(1)
|
||||
|
||||
if season_match:
|
||||
season = season_match.group(1)
|
||||
if int(season) < 10:
|
||||
season = f'S0{season}'
|
||||
else:
|
||||
season = f'S{season}'
|
||||
else:
|
||||
season = None
|
||||
|
||||
if episode_match:
|
||||
episode = episode_match.group(1)
|
||||
episode = f'E{episode}'
|
||||
else:
|
||||
episode = None
|
||||
|
||||
media = {
|
||||
"time": mtime,
|
||||
"type": mtype,
|
||||
"name": name,
|
||||
"year": year,
|
||||
"path": path,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
logger.debug(f"解析到删除媒体:{json.dumps(media)}")
|
||||
del_list.append(media)
|
||||
|
||||
return del_list
|
||||
|
||||
log_files = []
|
||||
try:
|
||||
# 获取所有emby日志
|
||||
log_list_url = "[HOST]System/Logs/Query?Limit=3&api_key=[APIKEY]"
|
||||
log_list_res = Emby().get_data(log_list_url)
|
||||
|
||||
if log_list_res and log_list_res.status_code == 200:
|
||||
log_files_dict = json.loads(log_list_res.text)
|
||||
for item in log_files_dict.get("Items"):
|
||||
if str(item.get('Name')).startswith("embyserver"):
|
||||
log_files.append(str(item.get('Name')))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
if not log_files:
|
||||
log_files.append("embyserver.txt")
|
||||
|
||||
del_medias = []
|
||||
# 循环获取媒体信息
|
||||
for match in matches:
|
||||
mtime = match[0]
|
||||
# 排除已处理的媒体信息
|
||||
if last_time and mtime < last_time:
|
||||
continue
|
||||
|
||||
mtype = match[1]
|
||||
name = match[2]
|
||||
path = match[3]
|
||||
|
||||
year = None
|
||||
year_pattern = r'\(\d+\)'
|
||||
year_match = re.search(year_pattern, path)
|
||||
if year_match:
|
||||
year = year_match.group()[1:-1]
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
if mtype == 'Episode' or mtype == 'Season':
|
||||
name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
|
||||
season_pattern = r"Season\s*(\d+)"
|
||||
episode_pattern = r"S\d+E(\d+)"
|
||||
name_match = re.search(name_pattern, path)
|
||||
season_match = re.search(season_pattern, path)
|
||||
episode_match = re.search(episode_pattern, path)
|
||||
|
||||
if name_match:
|
||||
name = name_match.group(1)
|
||||
|
||||
if season_match:
|
||||
season = season_match.group(1)
|
||||
if int(season) < 10:
|
||||
season = f'S0{season}'
|
||||
else:
|
||||
season = f'S{season}'
|
||||
else:
|
||||
season = None
|
||||
|
||||
if episode_match:
|
||||
episode = episode_match.group(1)
|
||||
episode = f'E{episode}'
|
||||
else:
|
||||
episode = None
|
||||
|
||||
media = {
|
||||
"time": mtime,
|
||||
"type": mtype,
|
||||
"name": name,
|
||||
"year": year,
|
||||
"path": path,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
logger.debug(f"解析到删除媒体:{json.dumps(media)}")
|
||||
del_medias.append(media)
|
||||
log_files.reverse()
|
||||
for log_file in log_files:
|
||||
del_medias = __parse_log(log_file, del_medias)
|
||||
|
||||
return del_medias
|
||||
|
||||
@staticmethod
|
||||
def parse_jellyfin_log(last_time: datetime):
|
||||
# 根据加入日期 降序排序
|
||||
log_url = "[HOST]System/Logs/Log?name=log_%s.log&api_key=[APIKEY]" % datetime.date.today().strftime("%Y%m%d")
|
||||
log_res = Jellyfin().get_data(log_url)
|
||||
if not log_res or log_res.status_code != 200:
|
||||
logger.error("获取jellyfin日志失败,请检查服务器配置")
|
||||
return []
|
||||
"""
|
||||
获取jellyfin日志列表、解析jellyfin日志
|
||||
"""
|
||||
|
||||
# 正则解析删除的媒体信息
|
||||
pattern = r'\[(.*?)\].*?Removing item, Type: "(.*?)", Name: "(.*?)", Path: "(.*?)"'
|
||||
matches = re.findall(pattern, log_res.text)
|
||||
def __parse_log(file_name: str, del_list: list):
|
||||
"""
|
||||
解析jellyfin日志
|
||||
"""
|
||||
log_url = f"[HOST]System/Logs/Log?name={file_name}&api_key=[APIKEY]"
|
||||
log_res = Jellyfin().get_data(log_url)
|
||||
if not log_res or log_res.status_code != 200:
|
||||
logger.error("获取jellyfin日志失败,请检查服务器配置")
|
||||
return del_list
|
||||
|
||||
# 正则解析删除的媒体信息
|
||||
pattern = r'\[(.*?)\].*?Removing item, Type: "(.*?)", Name: "(.*?)", Path: "(.*?)"'
|
||||
matches = re.findall(pattern, log_res.text)
|
||||
|
||||
# 循环获取媒体信息
|
||||
for match in matches:
|
||||
mtime = match[0]
|
||||
# 排除已处理的媒体信息
|
||||
if last_time and mtime < last_time:
|
||||
continue
|
||||
|
||||
mtype = match[1]
|
||||
name = match[2]
|
||||
path = match[3]
|
||||
|
||||
year = None
|
||||
year_pattern = r'\(\d+\)'
|
||||
year_match = re.search(year_pattern, path)
|
||||
if year_match:
|
||||
year = year_match.group()[1:-1]
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
if mtype == 'Episode' or mtype == 'Season':
|
||||
name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
|
||||
season_pattern = r"Season\s*(\d+)"
|
||||
episode_pattern = r"S\d+E(\d+)"
|
||||
name_match = re.search(name_pattern, path)
|
||||
season_match = re.search(season_pattern, path)
|
||||
episode_match = re.search(episode_pattern, path)
|
||||
|
||||
if name_match:
|
||||
name = name_match.group(1)
|
||||
|
||||
if season_match:
|
||||
season = season_match.group(1)
|
||||
if int(season) < 10:
|
||||
season = f'S0{season}'
|
||||
else:
|
||||
season = f'S{season}'
|
||||
else:
|
||||
season = None
|
||||
|
||||
if episode_match:
|
||||
episode = episode_match.group(1)
|
||||
episode = f'E{episode}'
|
||||
else:
|
||||
episode = None
|
||||
|
||||
media = {
|
||||
"time": mtime,
|
||||
"type": mtype,
|
||||
"name": name,
|
||||
"year": year,
|
||||
"path": path,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
logger.debug(f"解析到删除媒体:{json.dumps(media)}")
|
||||
del_list.append(media)
|
||||
|
||||
return del_list
|
||||
|
||||
log_files = []
|
||||
try:
|
||||
# 获取所有jellyfin日志
|
||||
log_list_url = "[HOST]System/Logs?api_key=[APIKEY]"
|
||||
log_list_res = Jellyfin().get_data(log_list_url)
|
||||
|
||||
if log_list_res and log_list_res.status_code == 200:
|
||||
log_files_dict = json.loads(log_list_res.text)
|
||||
for item in log_files_dict:
|
||||
if str(item.get('Name')).startswith("log_"):
|
||||
log_files.append(str(item.get('Name')))
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
if not log_files:
|
||||
log_files.append("log_%s.log" % datetime.date.today().strftime("%Y%m%d"))
|
||||
|
||||
del_medias = []
|
||||
# 循环获取媒体信息
|
||||
for match in matches:
|
||||
mtime = match[0]
|
||||
# 排除已处理的媒体信息
|
||||
if last_time and mtime < last_time:
|
||||
continue
|
||||
|
||||
mtype = match[1]
|
||||
name = match[2]
|
||||
path = match[3]
|
||||
|
||||
year = None
|
||||
year_pattern = r'\(\d+\)'
|
||||
year_match = re.search(year_pattern, path)
|
||||
if year_match:
|
||||
year = year_match.group()[1:-1]
|
||||
|
||||
season = None
|
||||
episode = None
|
||||
if mtype == 'Episode' or mtype == 'Season':
|
||||
name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()"
|
||||
season_pattern = r"Season\s*(\d+)"
|
||||
episode_pattern = r"S\d+E(\d+)"
|
||||
name_match = re.search(name_pattern, path)
|
||||
season_match = re.search(season_pattern, path)
|
||||
episode_match = re.search(episode_pattern, path)
|
||||
|
||||
if name_match:
|
||||
name = name_match.group(1)
|
||||
|
||||
if season_match:
|
||||
season = season_match.group(1)
|
||||
if int(season) < 10:
|
||||
season = f'S0{season}'
|
||||
else:
|
||||
season = f'S{season}'
|
||||
else:
|
||||
season = None
|
||||
|
||||
if episode_match:
|
||||
episode = episode_match.group(1)
|
||||
episode = f'E{episode}'
|
||||
else:
|
||||
episode = None
|
||||
|
||||
media = {
|
||||
"time": mtime,
|
||||
"type": mtype,
|
||||
"name": name,
|
||||
"year": year,
|
||||
"path": path,
|
||||
"season": season,
|
||||
"episode": episode,
|
||||
}
|
||||
logger.debug(f"解析到删除媒体:{json.dumps(media)}")
|
||||
del_medias.append(media)
|
||||
log_files.reverse()
|
||||
for log_file in log_files:
|
||||
del_medias = __parse_log(log_file, del_medias)
|
||||
|
||||
return del_medias
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class PersonMeta(_PluginBase):
|
||||
_remove_nozh = False
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.tmdbchain = TmdbChain(self.db)
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.mschain = MediaServerChain(self.db)
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
@@ -307,11 +307,57 @@ class PersonMeta(_PluginBase):
|
||||
logger.info(f"媒体库 {library.name} 的演员信息刮削完成")
|
||||
logger.info(f"服务器 {server} 的演员信息刮削完成")
|
||||
|
||||
def __update_peoples(self, server: str, itemid: str, iteminfo: dict, douban_actors):
|
||||
# 处理媒体项中的人物信息
|
||||
"""
|
||||
"People": [
|
||||
{
|
||||
"Name": "丹尼尔·克雷格",
|
||||
"Id": "33625",
|
||||
"Role": "James Bond",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "bef4f764540f10577f804201d8d27918"
|
||||
}
|
||||
]
|
||||
"""
|
||||
peoples = []
|
||||
# 更新当前媒体项人物
|
||||
for people in iteminfo["People"] or []:
|
||||
if self._event.is_set():
|
||||
logger.info(f"演职人员刮削服务停止")
|
||||
return
|
||||
if not people.get("Name"):
|
||||
continue
|
||||
if StringUtils.is_chinese(people.get("Name")) \
|
||||
and StringUtils.is_chinese(people.get("Role")):
|
||||
peoples.append(people)
|
||||
continue
|
||||
info = self.__update_people(server=server, people=people,
|
||||
douban_actors=douban_actors)
|
||||
if info:
|
||||
peoples.append(info)
|
||||
elif not self._remove_nozh:
|
||||
peoples.append(people)
|
||||
# 保存媒体项信息
|
||||
if peoples:
|
||||
iteminfo["People"] = peoples
|
||||
self.set_iteminfo(server=server, itemid=itemid, iteminfo=iteminfo)
|
||||
|
||||
def __update_item(self, server: str, item: MediaServerItem,
|
||||
mediainfo: MediaInfo = None, season: int = None):
|
||||
"""
|
||||
更新媒体服务器中的条目
|
||||
"""
|
||||
|
||||
def __need_trans_actor(_item):
|
||||
# 是否需要处理人物信息
|
||||
_peoples = [x for x in _item.get("People", []) if
|
||||
(x.get("Name") and not StringUtils.is_chinese(x.get("Name")))
|
||||
or (x.get("Role") and not StringUtils.is_chinese(x.get("Role")))]
|
||||
if _peoples:
|
||||
return True
|
||||
return False
|
||||
|
||||
# 识别媒体信息
|
||||
if not mediainfo:
|
||||
if not item.tmdbid:
|
||||
@@ -323,49 +369,19 @@ class PersonMeta(_PluginBase):
|
||||
logger.warn(f"{item.title} 未识别到媒体信息")
|
||||
return
|
||||
|
||||
# 获取豆瓣演员信息
|
||||
douban_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season)
|
||||
|
||||
# 获取媒体项
|
||||
iteminfo = self.get_iteminfo(server=server, itemid=item.item_id)
|
||||
if not iteminfo:
|
||||
logger.warn(f"{item.title} 未找到媒体项")
|
||||
return
|
||||
|
||||
# 处理媒体项中的人物信息
|
||||
if iteminfo.get("People"):
|
||||
"""
|
||||
"People": [
|
||||
{
|
||||
"Name": "丹尼尔·克雷格",
|
||||
"Id": "33625",
|
||||
"Role": "James Bond",
|
||||
"Type": "Actor",
|
||||
"PrimaryImageTag": "bef4f764540f10577f804201d8d27918"
|
||||
}
|
||||
]
|
||||
"""
|
||||
peoples = []
|
||||
# 更新当前媒体项人物
|
||||
for people in iteminfo["People"]:
|
||||
if not people.get("Name"):
|
||||
continue
|
||||
if StringUtils.is_chinese(people.get("Name")):
|
||||
peoples.append(people)
|
||||
continue
|
||||
if self._event.is_set():
|
||||
logger.info(f"演职人员刮削服务停止")
|
||||
return
|
||||
info = self.__update_people(server=server, people=people,
|
||||
douban_actors=douban_actors)
|
||||
if info:
|
||||
peoples.append(info)
|
||||
elif not self._remove_nozh:
|
||||
peoples.append(people)
|
||||
# 保存媒体项信息
|
||||
if peoples:
|
||||
iteminfo["People"] = peoples
|
||||
self.set_iteminfo(server=server, itemid=item.item_id, iteminfo=iteminfo)
|
||||
if __need_trans_actor(iteminfo):
|
||||
# 获取豆瓣演员信息
|
||||
logger.info(f"开始获取 {item.title} 的豆瓣演员信息 ...")
|
||||
douban_actors = self.__get_douban_actors(mediainfo=mediainfo, season=season)
|
||||
self.__update_peoples(server=server, itemid=item.item_id, iteminfo=iteminfo, douban_actors=douban_actors)
|
||||
else:
|
||||
logger.info(f"{item.title} 的人物信息已是中文,无需更新")
|
||||
|
||||
# 处理季和集人物
|
||||
if iteminfo.get("Type") and "Series" in iteminfo["Type"]:
|
||||
@@ -383,31 +399,14 @@ class PersonMeta(_PluginBase):
|
||||
if not seasoninfo:
|
||||
logger.warn(f"{item.title} 未找到季媒体项:{season.get('Id')}")
|
||||
continue
|
||||
# 更新季媒体项人物
|
||||
peoples = []
|
||||
if seasoninfo.get("People"):
|
||||
logger.info(f"开始更新季 {seasoninfo.get('Id')} 的人物信息 ...")
|
||||
for people in seasoninfo["People"]:
|
||||
if not people.get("Name"):
|
||||
continue
|
||||
if StringUtils.is_chinese(people.get("Name")):
|
||||
peoples.append(people)
|
||||
continue
|
||||
if self._event.is_set():
|
||||
logger.info(f"演职人员刮削服务停止")
|
||||
return
|
||||
# 更新人物信息
|
||||
info = self.__update_people(server=server, people=people,
|
||||
douban_actors=season_actors)
|
||||
if info:
|
||||
peoples.append(info)
|
||||
elif not self._remove_nozh:
|
||||
peoples.append(people)
|
||||
# 保存季媒体项信息
|
||||
if peoples:
|
||||
seasoninfo["People"] = peoples
|
||||
self.set_iteminfo(server=server, itemid=season.get("Id"), iteminfo=seasoninfo)
|
||||
|
||||
if __need_trans_actor(seasoninfo):
|
||||
# 更新季媒体项人物
|
||||
self.__update_peoples(server=server, itemid=season.get("Id"), iteminfo=seasoninfo,
|
||||
douban_actors=season_actors)
|
||||
logger.info(f"季 {seasoninfo.get('Id')} 的人物信息更新完成")
|
||||
else:
|
||||
logger.info(f"季 {seasoninfo.get('Id')} 的人物信息已是中文,无需更新")
|
||||
# 获取集媒体项
|
||||
episodes = self.get_items(server=server, parentid=season.get("Id"), mtype="Episode")
|
||||
if not episodes:
|
||||
@@ -420,31 +419,13 @@ class PersonMeta(_PluginBase):
|
||||
if not episodeinfo:
|
||||
logger.warn(f"{item.title} 未找到集媒体项:{episode.get('Id')}")
|
||||
continue
|
||||
# 更新集媒体项人物
|
||||
if episodeinfo.get("People"):
|
||||
logger.info(f"开始更新集 {episodeinfo.get('Id')} 的人物信息 ...")
|
||||
peoples = []
|
||||
for people in episodeinfo["People"]:
|
||||
if not people.get("Name"):
|
||||
continue
|
||||
if StringUtils.is_chinese(people.get("Name")):
|
||||
peoples.append(people)
|
||||
continue
|
||||
if self._event.is_set():
|
||||
logger.info(f"演职人员刮削服务停止")
|
||||
return
|
||||
# 更新人物信息
|
||||
info = self.__update_people(server=server, people=people,
|
||||
douban_actors=season_actors)
|
||||
if info:
|
||||
peoples.append(info)
|
||||
elif not self._remove_nozh:
|
||||
peoples.append(people)
|
||||
# 保存集媒体项信息
|
||||
if peoples:
|
||||
episodeinfo["People"] = peoples
|
||||
self.set_iteminfo(server=server, itemid=episode.get("Id"), iteminfo=episodeinfo)
|
||||
if __need_trans_actor(episodeinfo):
|
||||
# 更新集媒体项人物
|
||||
self.__update_peoples(server=server, itemid=episode.get("Id"), iteminfo=episodeinfo,
|
||||
douban_actors=season_actors)
|
||||
logger.info(f"集 {episodeinfo.get('Id')} 的人物信息更新完成")
|
||||
else:
|
||||
logger.info(f"集 {episodeinfo.get('Id')} 的人物信息已是中文,无需更新")
|
||||
|
||||
def __update_people(self, server: str, people: dict, douban_actors: list = None) -> Optional[dict]:
|
||||
"""
|
||||
@@ -506,15 +487,39 @@ class PersonMeta(_PluginBase):
|
||||
profile_path = person_tmdbinfo.get('profile_path')
|
||||
if profile_path:
|
||||
logger.info(f"{people.get('Name')} 从TMDB获取到图片:{profile_path}")
|
||||
profile_path = f"https://image.tmdb.org/t/p/original{profile_path}"
|
||||
profile_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{profile_path}"
|
||||
|
||||
# 从豆瓣信息中更新人物信息
|
||||
"""
|
||||
{
|
||||
"name": "丹尼尔·克雷格",
|
||||
"roles": [
|
||||
"演员",
|
||||
"制片人",
|
||||
"配音"
|
||||
],
|
||||
"title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员",
|
||||
"url": "https://movie.douban.com/celebrity/1025175/",
|
||||
"user": null,
|
||||
"character": "饰 詹姆斯·邦德 James Bond 007",
|
||||
"uri": "douban://douban.com/celebrity/1025175?subject_id=27230907",
|
||||
"avatar": {
|
||||
"large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
|
||||
"normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
|
||||
},
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/",
|
||||
"type": "celebrity",
|
||||
"id": "1025175",
|
||||
"latin_name": "Daniel Craig"
|
||||
}
|
||||
"""
|
||||
if douban_actors and (not updated_name
|
||||
or not updated_overview
|
||||
or not update_character):
|
||||
# 从豆瓣演员中匹配中文名称、角色和简介
|
||||
for douban_actor in douban_actors:
|
||||
if douban_actor.get("latin_name") == people.get("Name"):
|
||||
if douban_actor.get("latin_name") == people.get("Name") \
|
||||
or douban_actor.get("name") == people.get("Name"):
|
||||
# 名称
|
||||
if not updated_name:
|
||||
logger.info(f"{people.get('Name')} 从豆瓣中获取到中文名:{douban_actor.get('name')}")
|
||||
@@ -576,10 +581,13 @@ class PersonMeta(_PluginBase):
|
||||
"""
|
||||
获取豆瓣演员信息
|
||||
"""
|
||||
# 随机休眠1-5秒
|
||||
time.sleep(1 + int(time.time()) % 5)
|
||||
# 随机休眠 3-10 秒
|
||||
sleep_time = 3 + int(time.time()) % 7
|
||||
logger.info(f"随机休眠 {sleep_time}秒 ...")
|
||||
time.sleep(sleep_time)
|
||||
# 匹配豆瓣信息
|
||||
doubaninfo = self.chain.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=season)
|
||||
@@ -587,6 +595,8 @@ class PersonMeta(_PluginBase):
|
||||
if doubaninfo:
|
||||
doubanitem = self.chain.douban_info(doubaninfo.get("id")) or {}
|
||||
return (doubanitem.get("actors") or []) + (doubanitem.get("directors") or [])
|
||||
else:
|
||||
logger.warn(f"未找到豆瓣信息:{mediainfo.title_year}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@@ -705,7 +715,7 @@ class PersonMeta(_PluginBase):
|
||||
logger.error(f"获取Jellyfin媒体的所有子媒体项失败:{err}")
|
||||
return {}
|
||||
|
||||
def __get_plex_items(t: str) -> dict:
|
||||
def __get_plex_items() -> dict:
|
||||
"""
|
||||
获得Plex媒体的所有子媒体项
|
||||
"""
|
||||
@@ -714,7 +724,7 @@ class PersonMeta(_PluginBase):
|
||||
plex = Plex().get_plex()
|
||||
items['Items'] = []
|
||||
if parentid:
|
||||
if mtype and 'Season' in t:
|
||||
if mtype and 'Season' in mtype:
|
||||
plexitem = plex.library.fetchItem(ekey=parentid)
|
||||
items['Items'] = []
|
||||
for season in plexitem.seasons():
|
||||
@@ -725,7 +735,7 @@ class PersonMeta(_PluginBase):
|
||||
'Overview': season.summary
|
||||
}
|
||||
items['Items'].append(item)
|
||||
elif mtype and 'Episode' in t:
|
||||
elif mtype and 'Episode' in mtype:
|
||||
plexitem = plex.library.fetchItem(ekey=parentid)
|
||||
items['Items'] = []
|
||||
for episode in plexitem.episodes():
|
||||
@@ -776,7 +786,7 @@ class PersonMeta(_PluginBase):
|
||||
elif server == "jellyfin":
|
||||
return __get_jellyfin_items()
|
||||
else:
|
||||
return __get_plex_items(mtype)
|
||||
return __get_plex_items()
|
||||
|
||||
@staticmethod
|
||||
def set_iteminfo(server: str, itemid: str, iteminfo: dict):
|
||||
|
||||
@@ -467,7 +467,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/upload.png'
|
||||
'src': '/plugin_icon/upload.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -537,7 +537,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/download.png'
|
||||
'src': '/plugin_icon/download.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -607,7 +607,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/seed.png'
|
||||
'src': '/plugin_icon/seed.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -677,7 +677,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/database.png'
|
||||
'src': '/plugin_icon/database.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -841,87 +841,88 @@ class SiteStatistic(_PluginBase):
|
||||
url = site_info.get("url")
|
||||
proxy = site_info.get("proxy")
|
||||
ua = site_info.get("ua")
|
||||
session = requests.Session()
|
||||
proxies = settings.PROXY if proxy else None
|
||||
proxy_server = settings.PROXY_SERVER if proxy else None
|
||||
render = site_info.get("render")
|
||||
# 会话管理
|
||||
with requests.Session() as session:
|
||||
proxies = settings.PROXY if proxy else None
|
||||
proxy_server = settings.PROXY_SERVER if proxy else None
|
||||
render = site_info.get("render")
|
||||
|
||||
logger.debug(f"站点 {site_name} url={url} site_cookie={site_cookie} ua={ua}")
|
||||
if render:
|
||||
# 演染模式
|
||||
html_text = PlaywrightHelper().get_page_source(url=url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
else:
|
||||
# 普通模式
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
# 第一次登录反爬
|
||||
if html_text.find("title") == -1:
|
||||
i = html_text.find("window.location")
|
||||
if i == -1:
|
||||
return None
|
||||
tmp_url = url + html_text[i:html_text.find(";")] \
|
||||
.replace("\"", "") \
|
||||
.replace("+", "") \
|
||||
.replace(" ", "") \
|
||||
.replace("window.location=", "")
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=tmp_url)
|
||||
if res and res.status_code == 200:
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
else:
|
||||
logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code))
|
||||
return None
|
||||
|
||||
# 兼容假首页情况,假首页通常没有 <link rel="search" 属性
|
||||
if '"search"' not in html_text and '"csrf-token"' not in html_text:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url + "/index.php")
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
elif res is not None:
|
||||
logger.error(f"站点 {site_name} 连接失败,状态码:{res.status_code}")
|
||||
return None
|
||||
logger.debug(f"站点 {site_name} url={url} site_cookie={site_cookie} ua={ua}")
|
||||
if render:
|
||||
# 演染模式
|
||||
html_text = PlaywrightHelper().get_page_source(url=url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
else:
|
||||
logger.error(f"站点 {site_name} 无法访问:{url}")
|
||||
return None
|
||||
# 解析站点类型
|
||||
if html_text:
|
||||
site_schema = self.__build_class(html_text)
|
||||
if not site_schema:
|
||||
logger.error("站点 %s 无法识别站点类型" % site_name)
|
||||
return None
|
||||
return site_schema(site_name, url, site_cookie, html_text, session=session, ua=ua, proxy=proxy)
|
||||
return None
|
||||
# 普通模式
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
# 第一次登录反爬
|
||||
if html_text.find("title") == -1:
|
||||
i = html_text.find("window.location")
|
||||
if i == -1:
|
||||
return None
|
||||
tmp_url = url + html_text[i:html_text.find(";")] \
|
||||
.replace("\"", "") \
|
||||
.replace("+", "") \
|
||||
.replace(" ", "") \
|
||||
.replace("window.location=", "")
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=tmp_url)
|
||||
if res and res.status_code == 200:
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
else:
|
||||
logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code))
|
||||
return None
|
||||
|
||||
# 兼容假首页情况,假首页通常没有 <link rel="search" 属性
|
||||
if '"search"' not in html_text and '"csrf-token"' not in html_text:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url + "/index.php")
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
elif res is not None:
|
||||
logger.error(f"站点 {site_name} 连接失败,状态码:{res.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.error(f"站点 {site_name} 无法访问:{url}")
|
||||
return None
|
||||
# 解析站点类型
|
||||
if html_text:
|
||||
site_schema = self.__build_class(html_text)
|
||||
if not site_schema:
|
||||
logger.error("站点 %s 无法识别站点类型" % site_name)
|
||||
return None
|
||||
return site_schema(site_name, url, site_cookie, html_text, session=session, ua=ua, proxy=proxy)
|
||||
return None
|
||||
|
||||
def refresh_by_domain(self, domain: str) -> schemas.Response:
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,6 @@ from enum import Enum
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import requests
|
||||
from requests import Session
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -107,7 +106,7 @@ class ISiteUserInfo(metaclass=ABCMeta):
|
||||
self._base_url = f"{split_url.scheme}://{split_url.netloc}"
|
||||
self._site_cookie = site_cookie
|
||||
self._index_html = index_html
|
||||
self._session = session if session else requests.Session()
|
||||
self._session = session if session else None
|
||||
self._ua = ua
|
||||
|
||||
self._emulate = emulate
|
||||
|
||||
@@ -100,7 +100,7 @@ class TorrentTransfer(_PluginBase):
|
||||
return
|
||||
if self._fromdownloader == self._todownloader:
|
||||
logger.error(f"源下载器和目的下载器不能相同")
|
||||
self.systemmessage(f"源下载器和目的下载器不能相同")
|
||||
self.systemmessage.put(f"源下载器和目的下载器不能相同")
|
||||
return
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
if self._cron:
|
||||
@@ -110,7 +110,7 @@ class TorrentTransfer(_PluginBase):
|
||||
CronTrigger.from_crontab(self._cron))
|
||||
except Exception as e:
|
||||
logger.error(f"转移做种服务启动失败:{e}")
|
||||
self.systemmessage(f"转移做种服务启动失败:{e}")
|
||||
self.systemmessage.put(f"转移做种服务启动失败:{e}")
|
||||
return
|
||||
if self._onlyonce:
|
||||
logger.info(f"转移做种服务启动,立即运行一次")
|
||||
|
||||
@@ -152,12 +152,12 @@ class WebHook(_PluginBase):
|
||||
return tuple(__to_dict(list(_event)))
|
||||
elif isinstance(_event, set):
|
||||
return set(__to_dict(list(_event)))
|
||||
elif isinstance(_event, frozenset):
|
||||
return frozenset(__to_dict(list(_event)))
|
||||
elif hasattr(_event, 'to_dict'):
|
||||
return __to_dict(_event.to_dict())
|
||||
elif hasattr(_event, '__dict__'):
|
||||
return __to_dict(_event.__dict__)
|
||||
elif isinstance(_event, (int, float, str, bool, type(None))):
|
||||
return _event
|
||||
elif hasattr(_event, '__dict__'):
|
||||
return _event.__dict__
|
||||
else:
|
||||
return str(_event)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.db import SessionFactory
|
||||
@@ -183,6 +184,14 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
|
||||
# 后台刷新TMDB壁纸
|
||||
self._scheduler.add_job(
|
||||
TmdbChain(self._db).get_random_wallpager,
|
||||
"interval",
|
||||
minutes=30,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=3)
|
||||
)
|
||||
|
||||
# 公共定时服务
|
||||
self._scheduler.add_job(
|
||||
SchedulerChain(self._db).scheduler_job,
|
||||
|
||||
@@ -56,7 +56,7 @@ class TransferHistory(BaseModel):
|
||||
src: Optional[str] = None
|
||||
# 目的目录
|
||||
dest: Optional[str] = None
|
||||
# 转移模式link/copy/move/softlink
|
||||
# 转移模式
|
||||
mode: Optional[str] = None
|
||||
# 类型:电影、电视剧
|
||||
type: Optional[str] = None
|
||||
|
||||
@@ -31,6 +31,12 @@ class Subscribe(BaseModel):
|
||||
include: Optional[str] = None
|
||||
# 排除
|
||||
exclude: Optional[str] = None
|
||||
# 质量
|
||||
quality: Optional[str] = None
|
||||
# 分辨率
|
||||
resolution: Optional[str] = None
|
||||
# 特效
|
||||
effect: Optional[str] = None
|
||||
# 总集数
|
||||
total_episode: Optional[int] = 0
|
||||
# 开始集数
|
||||
|
||||
@@ -40,6 +40,10 @@ class EventType(Enum):
|
||||
UserMessage = "user.message"
|
||||
# 通知消息
|
||||
NoticeMessage = "notice.message"
|
||||
# 名称识别请求
|
||||
NameRecognize = "name.recognize"
|
||||
# 名称识别结果
|
||||
NameRecognizeResult = "name.recognize.result"
|
||||
|
||||
|
||||
# 系统配置Key字典
|
||||
@@ -58,6 +62,8 @@ class SystemConfigKey(Enum):
|
||||
NotificationChannels = "NotificationChannels"
|
||||
# 自定义制作组/字幕组
|
||||
CustomReleaseGroups = "CustomReleaseGroups"
|
||||
# 自定义占位符
|
||||
Customization = "Customization"
|
||||
# 自定义识别词
|
||||
CustomIdentifiers = "CustomIdentifiers"
|
||||
# 搜索优先级规则
|
||||
|
||||
@@ -59,7 +59,8 @@ class RequestUtils:
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
timeout=self._timeout,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
else:
|
||||
return requests.post(url,
|
||||
data=data,
|
||||
@@ -67,7 +68,8 @@ class RequestUtils:
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
timeout=self._timeout,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
@@ -91,27 +93,38 @@ class RequestUtils:
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
def get_res(self, url: str, params: dict = None,
|
||||
allow_redirects: bool = True, raise_exception: bool = False) -> Optional[Response]:
|
||||
def get_res(self, url: str,
|
||||
params: dict = None,
|
||||
data: Any = None,
|
||||
json: dict = None,
|
||||
allow_redirects: bool = True,
|
||||
raise_exception: bool = False
|
||||
) -> Optional[Response]:
|
||||
try:
|
||||
if self._session:
|
||||
return self._session.get(url,
|
||||
params=params,
|
||||
data=data,
|
||||
json=json,
|
||||
verify=False,
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
cookies=self._cookies,
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects)
|
||||
allow_redirects=allow_redirects,
|
||||
stream=False)
|
||||
else:
|
||||
return requests.get(url,
|
||||
params=params,
|
||||
data=data,
|
||||
json=json,
|
||||
verify=False,
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
cookies=self._cookies,
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects)
|
||||
allow_redirects=allow_redirects,
|
||||
stream=False)
|
||||
except requests.exceptions.RequestException:
|
||||
if raise_exception:
|
||||
raise requests.exceptions.RequestException
|
||||
@@ -120,7 +133,8 @@ class RequestUtils:
|
||||
def post_res(self, url: str, data: Any = None, params: dict = None,
|
||||
allow_redirects: bool = True,
|
||||
files: Any = None,
|
||||
json: dict = None) -> Optional[Response]:
|
||||
json: dict = None,
|
||||
raise_exception: bool = False) -> Optional[Response]:
|
||||
try:
|
||||
if self._session:
|
||||
return self._session.post(url,
|
||||
@@ -133,7 +147,8 @@ class RequestUtils:
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects,
|
||||
files=files,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
else:
|
||||
return requests.post(url,
|
||||
data=data,
|
||||
@@ -145,8 +160,11 @@ class RequestUtils:
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects,
|
||||
files=files,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
except requests.exceptions.RequestException:
|
||||
if raise_exception:
|
||||
raise requests.exceptions.RequestException
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -68,6 +68,8 @@ class StringUtils:
|
||||
"""
|
||||
判断是否含有中文
|
||||
"""
|
||||
if not word:
|
||||
return False
|
||||
if isinstance(word, list):
|
||||
word = " ".join(word)
|
||||
chn = re.compile(r'[\u4e00-\u9fff]')
|
||||
|
||||
@@ -3,6 +3,8 @@ import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Union, Tuple
|
||||
|
||||
@@ -27,20 +29,39 @@ class SystemUtils:
|
||||
|
||||
@staticmethod
|
||||
def is_docker() -> bool:
|
||||
"""
|
||||
判断是否为Docker环境
|
||||
"""
|
||||
return Path("/.dockerenv").exists()
|
||||
|
||||
@staticmethod
|
||||
def is_synology() -> bool:
|
||||
"""
|
||||
判断是否为群晖系统
|
||||
"""
|
||||
if SystemUtils.is_windows():
|
||||
return False
|
||||
return True if "synology" in SystemUtils.execute('uname -a') else False
|
||||
|
||||
@staticmethod
|
||||
def is_windows() -> bool:
|
||||
"""
|
||||
判断是否为Windows系统
|
||||
"""
|
||||
return True if os.name == "nt" else False
|
||||
|
||||
@staticmethod
|
||||
def is_frozen() -> bool:
|
||||
"""
|
||||
判断是否为冻结的二进制文件
|
||||
"""
|
||||
return True if getattr(sys, 'frozen', False) else False
|
||||
|
||||
@staticmethod
|
||||
def is_macos() -> bool:
|
||||
"""
|
||||
判断是否为MacOS系统
|
||||
"""
|
||||
return True if platform.system() == 'Darwin' else False
|
||||
|
||||
@staticmethod
|
||||
@@ -77,7 +98,7 @@ class SystemUtils:
|
||||
"""
|
||||
try:
|
||||
# link到当前目录并改名
|
||||
tmp_path = (src.parent / dest.name).with_suffix(".mp")
|
||||
tmp_path = src.parent / (dest.name + ".mp")
|
||||
tmp_path.hardlink_to(src)
|
||||
# 移动到目标目录
|
||||
shutil.move(tmp_path, dest)
|
||||
@@ -98,6 +119,54 @@ class SystemUtils:
|
||||
print(str(err))
|
||||
return -1, str(err)
|
||||
|
||||
@staticmethod
|
||||
def rclone_move(src: Path, dest: Path):
|
||||
"""
|
||||
Rclone移动
|
||||
"""
|
||||
try:
|
||||
retcode = subprocess.run(
|
||||
[
|
||||
'rclone', 'moveto',
|
||||
str(src),
|
||||
f'MP:{dest}'
|
||||
],
|
||||
startupinfo=SystemUtils.__get_hidden_shell()
|
||||
).returncode
|
||||
return retcode, ""
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return -1, str(err)
|
||||
|
||||
@staticmethod
|
||||
def rclone_copy(src: Path, dest: Path):
|
||||
"""
|
||||
Rclone复制
|
||||
"""
|
||||
try:
|
||||
retcode = subprocess.run(
|
||||
[
|
||||
'rclone', 'copyto',
|
||||
str(src),
|
||||
f'MP:{dest}'
|
||||
],
|
||||
startupinfo=SystemUtils.__get_hidden_shell()
|
||||
).returncode
|
||||
return retcode, ""
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return -1, str(err)
|
||||
|
||||
@staticmethod
|
||||
def __get_hidden_shell():
|
||||
if SystemUtils.is_windows():
|
||||
st = subprocess.STARTUPINFO()
|
||||
st.dwFlags = subprocess.STARTF_USESHOWWINDOW
|
||||
st.wShowWindow = subprocess.SW_HIDE
|
||||
return st
|
||||
else:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def list_files(directory: Path, extensions: list, min_filesize: int = 0) -> List[Path]:
|
||||
"""
|
||||
|
||||
183
config/app.env
Normal file
183
config/app.env
Normal file
@@ -0,0 +1,183 @@
|
||||
#######################################################################
|
||||
# 【*】为必配项,其余为选配项,选配项可以删除整项配置项或者保留配置默认值 #
|
||||
#######################################################################
|
||||
|
||||
####################################
|
||||
# 基础设置 #
|
||||
####################################
|
||||
# 【*】API监听地址
|
||||
HOST=0.0.0.0
|
||||
# 是否调试模式
|
||||
DEBUG=false
|
||||
# 是否开发模式
|
||||
DEV=false
|
||||
# 【*】超级管理员
|
||||
SUPERUSER=admin
|
||||
# 【*】超级管理员初始密码
|
||||
SUPERUSER_PASSWORD=password
|
||||
# 【*】API密钥,建议更换复杂字符串
|
||||
API_TOKEN=moviepilot
|
||||
# TMDB图片地址,无需修改需保留默认值
|
||||
TMDB_IMAGE_DOMAIN=image.tmdb.org
|
||||
# TMDB API地址,无需修改需保留默认值
|
||||
TMDB_API_DOMAIN=api.themoviedb.org
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE=false
|
||||
|
||||
####################################
|
||||
# 媒体识别&刮削 #
|
||||
####################################
|
||||
# 媒体信息搜索来源 themoviedb/douban
|
||||
SEARCH_SOURCE=themoviedb
|
||||
# 刮削入库的媒体文件 true/false
|
||||
SCRAP_METADATA=true
|
||||
# 新增已入库媒体是否跟随TMDB信息变化
|
||||
SCRAP_FOLLOW_TMDB=true
|
||||
# 刮削来源 themoviedb/douban
|
||||
SCRAP_SOURCE=themoviedb
|
||||
|
||||
####################################
|
||||
# 媒体库 #
|
||||
####################################
|
||||
# 【*】转移方式 link/copy/move/softlink/rclone_copy/rclone_move
|
||||
TRANSFER_TYPE=copy
|
||||
# 【*】媒体库目录,多个目录使用,分隔
|
||||
LIBRARY_PATH=
|
||||
# 电影媒体库目录名,默认电影
|
||||
LIBRARY_MOVIE_NAME=
|
||||
# 电视剧媒体库目录名,默认电视剧
|
||||
LIBRARY_TV_NAME=
|
||||
# 动漫媒体库目录名,默认电视剧/动漫
|
||||
LIBRARY_ANIME_NAME=
|
||||
# 二级分类
|
||||
LIBRARY_CATEGORY=true
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT={{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
|
||||
# 电视剧重命名格式
|
||||
TV_RENAME_FORMAT={{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
|
||||
|
||||
####################################
|
||||
# 站点 #
|
||||
####################################
|
||||
# 【*】CookieCloud服务器地址,默认为公共服务器
|
||||
COOKIECLOUD_HOST=https://movie-pilot.org/cookiecloud
|
||||
# 【*】CookieCloud用户KEY
|
||||
COOKIECLOUD_KEY=
|
||||
# 【*】CookieCloud端对端加密密码
|
||||
COOKIECLOUD_PASSWORD=
|
||||
# 【*】CookieCloud同步间隔(分钟)
|
||||
COOKIECLOUD_INTERVAL=1440
|
||||
# OCR服务器地址
|
||||
OCR_HOST=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
|
||||
|
||||
####################################
|
||||
# 订阅 & 搜索 #
|
||||
####################################
|
||||
# 订阅模式 spider/rss
|
||||
SUBSCRIBE_MODE=spider
|
||||
# RSS订阅模式刷新时间间隔(分钟)
|
||||
SUBSCRIBE_RSS_INTERVAL=30
|
||||
# 订阅搜索开关
|
||||
SUBSCRIBE_SEARCH=false
|
||||
# 交互搜索自动下载用户ID,使用,分割
|
||||
AUTO_DOWNLOAD_USER=
|
||||
|
||||
####################################
|
||||
# 消息通知 #
|
||||
####################################
|
||||
# 【*】消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔
|
||||
MESSAGER=telegram
|
||||
# WeChat企业ID
|
||||
WECHAT_CORPID=
|
||||
# WeChat应用Secret
|
||||
WECHAT_APP_SECRET=
|
||||
# WeChat应用ID
|
||||
WECHAT_APP_ID=
|
||||
# WeChat代理服务器,无需代理需保留默认值
|
||||
WECHAT_PROXY=https://qyapi.weixin.qq.com
|
||||
# WeChat Token
|
||||
WECHAT_TOKEN=
|
||||
# WeChat EncodingAESKey
|
||||
WECHAT_ENCODING_AESKEY=
|
||||
# WeChat 管理员
|
||||
WECHAT_ADMINS=
|
||||
# Telegram Bot Token
|
||||
TELEGRAM_TOKEN=
|
||||
# Telegram Chat ID
|
||||
TELEGRAM_CHAT_ID=
|
||||
# Telegram 用户ID,使用,分隔
|
||||
TELEGRAM_USERS=
|
||||
# Telegram 管理员ID,使用,分隔
|
||||
TELEGRAM_ADMINS=
|
||||
# Slack Bot User OAuth Token
|
||||
SLACK_OAUTH_TOKEN=
|
||||
# Slack App-Level Token
|
||||
SLACK_APP_TOKEN=
|
||||
# Slack 频道名称
|
||||
SLACK_CHANNEL=
|
||||
# SynologyChat Webhook
|
||||
SYNOLOGYCHAT_WEBHOOK=
|
||||
# SynologyChat Token
|
||||
SYNOLOGYCHAT_TOKEN=
|
||||
|
||||
####################################
|
||||
# 下载 #
|
||||
####################################
|
||||
# 【*】下载器 qbittorrent/transmission
|
||||
DOWNLOADER=qbittorrent
|
||||
# 下载器监控开关
|
||||
DOWNLOADER_MONITOR=true
|
||||
# Qbittorrent地址,IP:PORT
|
||||
QB_HOST=
|
||||
# Qbittorrent用户名
|
||||
QB_USER=
|
||||
# Qbittorrent密码
|
||||
QB_PASSWORD=
|
||||
# Qbittorrent分类自动管理
|
||||
QB_CATEGORY=false
|
||||
# Transmission地址,IP:PORT
|
||||
TR_HOST=
|
||||
# Transmission用户名
|
||||
TR_USER=
|
||||
# Transmission密码
|
||||
TR_PASSWORD=
|
||||
# 种子标签
|
||||
TORRENT_TAG=MOVIEPILOT
|
||||
# 【*】下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_PATH=/downloads
|
||||
# 电影下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_MOVIE_PATH=
|
||||
# 电视剧下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_TV_PATH=
|
||||
# 动漫下载保存目录,容器内映射路径需要一致
|
||||
DOWNLOAD_ANIME_PATH=
|
||||
# 下载目录二级分类
|
||||
DOWNLOAD_CATEGORY=false
|
||||
# 下载站点字幕
|
||||
DOWNLOAD_SUBTITLE=true
|
||||
|
||||
####################################
|
||||
# 媒体服务器 #
|
||||
####################################
|
||||
# 【*】媒体服务器 emby/jellyfin/plex,多个媒体服务器,分割
|
||||
MEDIASERVER=emby
|
||||
# 入库刷新媒体库
|
||||
REFRESH_MEDIASERVER=true
|
||||
# 媒体服务器同步间隔(小时)
|
||||
MEDIASERVER_SYNC_INTERVAL=6
|
||||
# 媒体服务器同步黑名单,多个媒体库名称,分割
|
||||
MEDIASERVER_SYNC_BLACKLIST=
|
||||
# EMBY服务器地址,IP:PORT
|
||||
EMBY_HOST=
|
||||
# EMBY Api Key
|
||||
EMBY_API_KEY=
|
||||
# Jellyfin服务器地址,IP:PORT
|
||||
JELLYFIN_HOST=
|
||||
# Jellyfin Api Key
|
||||
JELLYFIN_API_KEY=
|
||||
# Plex服务器地址,IP:PORT
|
||||
PLEX_HOST=
|
||||
# Plex Token
|
||||
PLEX_TOKEN=
|
||||
@@ -12,7 +12,7 @@ for module in Path(__file__).with_name("models").glob("*.py"):
|
||||
|
||||
db_version = input("请输入版本号:")
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
script_location = settings.ROOT_PATH / 'alembic'
|
||||
script_location = settings.ROOT_PATH / 'database'
|
||||
alembic_cfg = AlembicConfig()
|
||||
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||
alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}")
|
||||
@@ -8,7 +8,6 @@ Create Date: 2023-09-28 13:37:16.479360
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a521fbc28b18'
|
||||
down_revision = 'b2f011d3a8b7'
|
||||
@@ -26,5 +25,6 @@ def upgrade() -> None:
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
pass
|
||||
32
database/versions/d633ca6cd572_1_0_10.py
Normal file
32
database/versions/d633ca6cd572_1_0_10.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""1.0.10
|
||||
|
||||
Revision ID: d633ca6cd572
|
||||
Revises: a521fbc28b18
|
||||
Create Date: 2023-10-12 08:54:49.728638
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd633ca6cd572'
|
||||
down_revision = 'a521fbc28b18'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
try:
|
||||
with op.batch_alter_table("subscribe") as batch_op:
|
||||
batch_op.add_column(sa.Column('quality', sa.String, nullable=True))
|
||||
batch_op.add_column(sa.Column('resolution', sa.String, nullable=True))
|
||||
batch_op.add_column(sa.Column('effect', sa.String, nullable=True))
|
||||
except Exception as e:
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -42,6 +42,7 @@ chardet~=4.0.0
|
||||
starlette~=0.27.0
|
||||
PyVirtualDisplay~=3.0
|
||||
psutil~=5.9.4
|
||||
python_dotenv~=1.0.0
|
||||
python_hosts~=1.0.3
|
||||
watchdog~=3.0.0
|
||||
tailer~=0.4.1
|
||||
@@ -52,4 +53,5 @@ requests_cache~=0.5.2
|
||||
parse~=1.19.0
|
||||
docker~=6.1.3
|
||||
cachetools~=5.3.1
|
||||
fast-bencode==1.1.3
|
||||
fast-bencode~=1.1.3
|
||||
pystray~=0.19.5
|
||||
@@ -1 +1 @@
|
||||
APP_VERSION = 'v1.2.8'
|
||||
APP_VERSION = 'v1.3.2'
|
||||
|
||||
98
windows.spec
Normal file
98
windows.spec
Normal file
@@ -0,0 +1,98 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
def collect_pkg_data(package: str, include_py_files: bool = False, subdir: str = None):
|
||||
"""
|
||||
Collect all data files from the given package.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from PyInstaller.utils.hooks import get_package_paths, PY_IGNORE_EXTENSIONS
|
||||
from PyInstaller.building.datastruct import TOC
|
||||
|
||||
# Accept only strings as packages.
|
||||
if type(package) is not str:
|
||||
raise ValueError
|
||||
|
||||
pkg_base, pkg_dir = get_package_paths(package)
|
||||
if subdir:
|
||||
pkg_path = Path(pkg_dir) / subdir
|
||||
else:
|
||||
pkg_path = Path(pkg_dir)
|
||||
# Walk through all file in the given package, looking for data files.
|
||||
data_toc = TOC()
|
||||
for file in pkg_path.rglob('*'):
|
||||
if file.is_file():
|
||||
extension = file.suffix
|
||||
if not include_py_files and (extension in PY_IGNORE_EXTENSIONS):
|
||||
continue
|
||||
data_toc.append((str(file.relative_to(pkg_base)), str(file), 'DATA'))
|
||||
return data_toc
|
||||
|
||||
|
||||
def collect_local_submodules(package: str):
|
||||
"""
|
||||
Collect all local submodules from the given package.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
package_dir = Path(package.replace('.', os.sep))
|
||||
submodules = [package]
|
||||
# Walk through all file in the given package, looking for data files.
|
||||
for file in package_dir.rglob('*.py'):
|
||||
if file.name == '__init__.py':
|
||||
module = f"{file.parent}".replace(os.sep, '.')
|
||||
else:
|
||||
module = f"{file.parent}.{file.stem}".replace(os.sep, '.')
|
||||
if module not in submodules:
|
||||
submodules.append(module)
|
||||
return submodules
|
||||
|
||||
|
||||
hiddenimports = [
|
||||
'passlib.handlers.bcrypt',
|
||||
'app.modules',
|
||||
'app.plugins',
|
||||
] + collect_local_submodules('app.modules') + collect_local_submodules('app.plugins')
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
['app/main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[],
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas + [('./app.ico', './app.ico', 'DATA')],
|
||||
collect_pkg_data('config'),
|
||||
collect_pkg_data('nginx'),
|
||||
collect_pkg_data('cf_clearance'),
|
||||
collect_pkg_data('database', include_py_files=True),
|
||||
[],
|
||||
name='MoviePilot',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
icon="app.ico"
|
||||
)
|
||||
Reference in New Issue
Block a user