mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-08 21:02:44 +08:00
Compare commits
284 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec309180da | ||
|
|
ab3b674a6e | ||
|
|
9231144518 | ||
|
|
13c04de87c | ||
|
|
70f533684f | ||
|
|
c94866631b | ||
|
|
40a77b438e | ||
|
|
f5de48ca30 | ||
|
|
89a2c00e64 | ||
|
|
35afb50b26 | ||
|
|
0e3e01bf9c | ||
|
|
6e3ebd73c6 | ||
|
|
add9b875aa | ||
|
|
b1790ee730 | ||
|
|
47d7800250 | ||
|
|
4849c281d3 | ||
|
|
c36acd7bb4 | ||
|
|
986e96a88e | ||
|
|
493b7c2d24 | ||
|
|
0539ddab85 | ||
|
|
202fdf8905 | ||
|
|
9191ed0a21 | ||
|
|
9697cf3901 | ||
|
|
e6a11294fd | ||
|
|
cd046d8023 | ||
|
|
4d08928b8c | ||
|
|
bc8a243a6d | ||
|
|
3b804e13a8 | ||
|
|
f126f927b4 | ||
|
|
d4f202c2b1 | ||
|
|
77a1d56c5b | ||
|
|
7415f94da2 | ||
|
|
fa50d8b884 | ||
|
|
40776c10bc | ||
|
|
6578a2f977 | ||
|
|
e780485fc6 | ||
|
|
8213cdba63 | ||
|
|
8d5b0d4035 | ||
|
|
3eaa22d068 | ||
|
|
4797983f43 | ||
|
|
0e7e2fc44b | ||
|
|
9a51286c54 | ||
|
|
ddbf93f2c5 | ||
|
|
418411b10d | ||
|
|
dceb7340dd | ||
|
|
e7e9ca539d | ||
|
|
333d187615 | ||
|
|
761e66b200 | ||
|
|
eec52fa5ba | ||
|
|
b6c3c03748 | ||
|
|
4eebaa5d75 | ||
|
|
f6dfe9cb88 | ||
|
|
c36c94971e | ||
|
|
e83a15ad1f | ||
|
|
16aa353cf6 | ||
|
|
5adfa89d10 | ||
|
|
b1805c1a46 | ||
|
|
7e51d70cd6 | ||
|
|
b5cba64227 | ||
|
|
f20c81efae | ||
|
|
bfbd93b912 | ||
|
|
6be074e647 | ||
|
|
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 | ||
|
|
d995932a1c | ||
|
|
1b0bbbbbfd | ||
|
|
2aa93fa341 | ||
|
|
a970f90c6f | ||
|
|
44f612fed5 | ||
|
|
564a48dd8f | ||
|
|
9d029de56a | ||
|
|
2dd3fc5d8c | ||
|
|
9c335dbdfb | ||
|
|
0e30ea92f1 | ||
|
|
a0ced4e43c | ||
|
|
cfaaf65edc | ||
|
|
35be18bb1a | ||
|
|
02296e1758 | ||
|
|
0b84b05cdd | ||
|
|
99e3d5acca | ||
|
|
8001511484 | ||
|
|
8420b2ea85 | ||
|
|
9af883acbb | ||
|
|
e21ba5ad51 | ||
|
|
1293fafd34 | ||
|
|
4bcc6bd733 | ||
|
|
53a514feb6 | ||
|
|
e697889aad | ||
|
|
8b0fba054e | ||
|
|
32ff385444 | ||
|
|
8456c7f4a3 | ||
|
|
fcbfb63645 | ||
|
|
1fa7d15982 | ||
|
|
a173978f6b | ||
|
|
2f069afc77 | ||
|
|
ea998b4e41 | ||
|
|
ba27d02854 | ||
|
|
f78df58906 | ||
|
|
308683a7e9 | ||
|
|
b3f4a6f251 | ||
|
|
d1841d8f15 | ||
|
|
c8d6de3e9b | ||
|
|
938f5c8cea | ||
|
|
d166930b0a | ||
|
|
e1ac3c0d15 | ||
|
|
59da489e05 | ||
|
|
be12c736fb | ||
|
|
71c52aae7b | ||
|
|
dbfe2af53c | ||
|
|
cca898f5b6 | ||
|
|
9abd780aa2 | ||
|
|
2e89eeca2c | ||
|
|
dbb3bead6b | ||
|
|
d0b88ec7f6 | ||
|
|
5898bc7eb1 | ||
|
|
cfe113f6c3 | ||
|
|
83500128c9 | ||
|
|
2bff3a80da | ||
|
|
3dd7b33f3e | ||
|
|
8de487b0bf | ||
|
|
ce88a6818f | ||
|
|
6172832f41 | ||
|
|
a0ed228f4b | ||
|
|
01fd56a019 | ||
|
|
087fcd340a | ||
|
|
b3b09f3c03 | ||
|
|
11d17bf21a | ||
|
|
b1ee80edee | ||
|
|
107d496adb | ||
|
|
9f1112b58d | ||
|
|
989d6e3fe7 | ||
|
|
3999c64853 | ||
|
|
760e3d6de0 | ||
|
|
02111a3b9f | ||
|
|
e6af2c0f34 | ||
|
|
bd4c639761 | ||
|
|
d39b7ec021 | ||
|
|
63ca5f5017 | ||
|
|
2202cf457b | ||
|
|
5d04b7abd6 | ||
|
|
0588d5d5f3 | ||
|
|
5a59e443d7 | ||
|
|
470f4df979 | ||
|
|
84bda71330 | ||
|
|
ea883255cb | ||
|
|
e9abb69fb5 | ||
|
|
ff63390794 | ||
|
|
78b3135276 | ||
|
|
15bd2c09ed | ||
|
|
34d44857e4 | ||
|
|
dccded2d3e | ||
|
|
295cafc060 | ||
|
|
c792e97f67 | ||
|
|
d30a02987d | ||
|
|
84d4c9cf73 | ||
|
|
21ecd1f708 | ||
|
|
248b9a8e8c | ||
|
|
3c7abfada6 | ||
|
|
f363656e0a | ||
|
|
e9ee9dbce1 | ||
|
|
ab0b8653ab | ||
|
|
20711e17fb | ||
|
|
a89bd8b816 | ||
|
|
3692cfea64 | ||
|
|
81d9d39029 | ||
|
|
f5a61ceff1 |
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -9,8 +9,9 @@ body:
|
||||
请确认以下信息:
|
||||
1. 请按此模板提交issues,不按模板提交的问题将直接关闭。
|
||||
2. 如果你的问题可以直接在以往 issue 或者 Telegram频道 中找到,那么你的 issue 将会被直接关闭。
|
||||
3. 提交问题务必描述清楚、附上日志,描述不清导致无法理解和分析的问题会被直接关闭。
|
||||
3. 【提交问题务必描述清楚、附上日志】,描述不清导致无法理解和分析的问题会被直接关闭。
|
||||
4. 此仓库为后端仓库,如果是前端 WebUI 问题请在[前端仓库](https://github.com/jxxghp/MoviePilot-Frontend)提 issue。
|
||||
5. 【不要通过issues来寻求解决你的环境问题、配置安装类问题、咨询类问题】,否则直接关闭并加入用户黑名单!实在没有精力陪一波又一波的伸手党玩。
|
||||
- type: checkboxes
|
||||
id: ensure
|
||||
attributes:
|
||||
|
||||
12
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
12
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -14,6 +14,18 @@ body:
|
||||
description: 目前使用的程序版本
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: type
|
||||
attributes:
|
||||
label: 功能改进类型
|
||||
description: 你需要在下面哪个方面改进功能
|
||||
options:
|
||||
- 主程序
|
||||
- 插件
|
||||
- Docker
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: feature-request
|
||||
attributes:
|
||||
|
||||
119
.github/workflows/build.yml
vendored
119
.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: .
|
||||
@@ -62,3 +55,99 @@ jobs:
|
||||
MOVIEPILOT_VERSION=${{ env.app_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: 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'
|
||||
cache: 'pip'
|
||||
|
||||
- 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
|
||||
|
||||
77
Dockerfile
77
Dockerfile
@@ -1,41 +1,22 @@
|
||||
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"
|
||||
COPY . .
|
||||
RUN apt-get update \
|
||||
RUN apt-get update -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
@@ -50,32 +31,28 @@ RUN apt-get update \
|
||||
dumb-init \
|
||||
jq \
|
||||
haproxy \
|
||||
fuse3 \
|
||||
&& \
|
||||
if [ "$(uname -m)" = "x86_64" ]; \
|
||||
then ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1; \
|
||||
elif [ "$(uname -m)" = "aarch64" ]; \
|
||||
then ln -s /usr/lib/aarch64-linux-musl/libc.so /lib/libc.musl-aarch64.so.1; \
|
||||
fi \
|
||||
&& cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
|
||||
&& groupadd -r moviepilot -g 911 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
||||
&& curl https://rclone.org/install.sh | bash \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY requirements.txt requirements.txt
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install Cython \
|
||||
&& pip install -r requirements.txt \
|
||||
&& playwright install-deps chromium \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& apt-get remove -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
@@ -84,6 +61,22 @@ RUN apt-get update \
|
||||
/moviepilot/.cache \
|
||||
/var/lib/apt/lists/* \
|
||||
/var/tmp/*
|
||||
COPY . .
|
||||
RUN cp -f /app/nginx.conf /etc/nginx/nginx.template.conf \
|
||||
&& cp -f /app/update /usr/local/bin/mp_update \
|
||||
&& cp -f /app/entrypoint /entrypoint \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
|
||||
&& groupadd -r moviepilot -g 911 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
|
||||
&& python_ver=$(python3 -V | awk '{print $2}') \
|
||||
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
|
||||
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
|
||||
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
|
||||
&& locale-gen zh_CN.UTF-8 \
|
||||
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
|
||||
145
README.md
145
README.md
@@ -4,8 +4,6 @@
|
||||
|
||||
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
||||
|
||||
Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
## 主要特性
|
||||
@@ -15,76 +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文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**。
|
||||
|
||||
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||
|
||||
### 1. **基础设置**
|
||||
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**
|
||||
- **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:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突(仅支持环境变量配置)
|
||||
- **❗PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突(仅支持环境变量配置)
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`(仅支持环境变量配置)
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`(仅支持环境变量配置)
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`(仅支持环境变量配置)
|
||||
- **PROXY_HOST:** 网络代理,访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`、`socks5://user:pass@host:port`(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`**(仅支持环境变量配置)
|
||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`(仅支持环境变量配置)
|
||||
---
|
||||
- **❗SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **❗SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **❗API_TOKEN:** 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:** 整理转移方式,支持`link`/`copy`/`move`/`softlink`/`rclone_copy`/`rclone_move` **注意:在`link`和`softlink`转移方式下,转移后的文件会继承源文件的权限掩码,不受`UMASK`影响;rclone需要自行映射rclone配置目录到容器中或在容器内完成rclone配置,节点名称必须为:`MP`**
|
||||
- **❗LIBRARY_PATH:** 媒体库目录,多个目录使用`,`分隔
|
||||
- **LIBRARY_MOVIE_NAME:** 电影媒体库目录名称(不是完整路径),默认`电影`
|
||||
- **LIBRARY_TV_NAME:** 电视剧媒体库目录称(不是完整路径),默认`电视剧`
|
||||
- **LIBRARY_ANIME_NAME:** 动漫媒体库目录称(不是完整路径),默认`电视剧/动漫`
|
||||
- **LIBRARY_CATEGORY:** 媒体库二级分类开关,`true`/`false`,默认`false`,开启后会根据配置 [category.yaml](https://github.com/jxxghp/MoviePilot/raw/main/config/category.yaml) 自动在媒体库目录下建立二级目录分类
|
||||
---
|
||||
- **❗COOKIECLOUD_HOST:** CookieCloud服务器地址,格式:`http(s)://ip:port`,不配置默认使用内建服务器`https://movie-pilot.org/cookiecloud`
|
||||
- **❗COOKIECLOUD_KEY:** CookieCloud用户KEY
|
||||
- **❗COOKIECLOUD_PASSWORD:** CookieCloud端对端加密密码
|
||||
- **❗COOKIECLOUD_INTERVAL:** CookieCloud同步间隔(分钟)
|
||||
- **❗USER_AGENT:** CookieCloud保存Cookie对应的浏览器UA,建议配置,设置后可增加连接站点的成功率,同步站点后可以在管理界面中修改
|
||||
- **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:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||
|
||||
- `wechat`设置项:
|
||||
|
||||
@@ -101,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:** 下载保存目录,**注意:需要将`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:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
|
||||
- `qbittorrent`设置项:
|
||||
|
||||
@@ -130,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:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
|
||||
- `emby`设置项:
|
||||
|
||||
@@ -155,9 +173,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
|
||||
### 2. **用户认证**
|
||||
|
||||
- **AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过环境变量配置**)
|
||||
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
- **❗AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
@@ -173,6 +191,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
| 1ptba | `1PTBA_UID`:用户ID<br/>`1PTBA_PASSKEY`:密钥 |
|
||||
| icc2022 | `ICC2022_UID`:用户ID<br/>`ICC2022_PASSKEY`:密钥 |
|
||||
| ptlsp | `PTLSP_UID`:用户ID<br/>`PTLSP_PASSKEY`:密钥 |
|
||||
| xingtan | `XINGTAN_UID`:用户ID<br/>`XINGTAN_PASSKEY`:密钥 |
|
||||
|
||||
|
||||
### 2. **进阶配置**
|
||||
@@ -188,10 +207,12 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `original_title`: 原语种标题
|
||||
> `name`: 识别名称
|
||||
> `year`: 年份
|
||||
> `edition`: 版本
|
||||
> `resourceType`:资源类型
|
||||
> `effect`:特效
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `effect`: 特效
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
@@ -212,6 +233,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `season`: 季号
|
||||
> `episode`: 集号
|
||||
> `season_episode`: 季集 SxxExx
|
||||
> `episode_title`: 集标题
|
||||
|
||||
`TV_RENAME_FORMAT`默认配置格式:
|
||||
|
||||
@@ -220,9 +242,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
|
||||
### 3. **过滤规则**
|
||||
|
||||
在`设定`-`规则`中设定,规则说明:
|
||||
### 3. **优先级规则**
|
||||
|
||||
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等
|
||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
||||
@@ -239,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;
|
||||
@@ -252,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 = []
|
||||
|
||||
@@ -6,11 +6,12 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.types import EventType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -78,26 +79,13 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
# 删除源文件
|
||||
if deletesrc and history.src:
|
||||
TransferChain(db).delete_files(Path(history.src))
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src
|
||||
}
|
||||
)
|
||||
# 删除记录
|
||||
TransferHistory.delete(db, history_in.id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/transfer", summary="历史记录重新转移", response_model=schemas.Response)
|
||||
def redo_transfer_history(history_in: schemas.TransferHistory,
|
||||
mtype: str = None,
|
||||
new_tmdbid: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
历史记录重新转移,不输入 mtype 和 new_tmdbid 时,自动使用文件名重新识别
|
||||
"""
|
||||
if mtype and new_tmdbid:
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id,
|
||||
mtype=MediaType(mtype), tmdbid=new_tmdbid)
|
||||
else:
|
||||
state, errmsg = TransferChain(db).re_transfer(logid=history_in.id)
|
||||
if state:
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message=errmsg)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -162,7 +162,9 @@ def search_subscribes(
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=None, state='R'
|
||||
sid=None,
|
||||
state='R',
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
@@ -178,7 +180,9 @@ def search_subscribe(
|
||||
background_tasks.add_task(
|
||||
Scheduler().start,
|
||||
job_id="subscribe_search",
|
||||
sid=subscribe_id, state=None
|
||||
sid=subscribe_id,
|
||||
state=None,
|
||||
manual=True
|
||||
)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -222,5 +222,8 @@ def execute_command(jobid: str,
|
||||
"""
|
||||
if not jobid:
|
||||
return schemas.Response(success=False, message="命令不能为空!")
|
||||
Scheduler().start(jobid)
|
||||
if jobid == "subscribe_search":
|
||||
Scheduler().start(jobid, state='R')
|
||||
else:
|
||||
Scheduler().start(jobid)
|
||||
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:
|
||||
|
||||
@@ -8,13 +8,15 @@ from app import schemas
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
from app.schemas import MediaType
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
|
||||
def manual_transfer(path: str,
|
||||
def manual_transfer(path: str = None,
|
||||
logid: int = None,
|
||||
target: str = None,
|
||||
tmdbid: int = None,
|
||||
type_name: str = None,
|
||||
@@ -28,8 +30,9 @@ def manual_transfer(path: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
手动转移,支持自定义剧集识别格式
|
||||
手动转移,文件或历史记录,支持自定义剧集识别格式
|
||||
:param path: 转移路径或文件
|
||||
:param logid: 转移历史记录ID
|
||||
:param target: 目标路径
|
||||
:param type_name: 媒体类型、电影/电视剧
|
||||
:param tmdbid: tmdbid
|
||||
@@ -43,11 +46,30 @@ def manual_transfer(path: str,
|
||||
:param db: 数据库
|
||||
:param _: Token校验
|
||||
"""
|
||||
in_path = Path(path)
|
||||
force = False
|
||||
if logid:
|
||||
# 查询历史记录
|
||||
history = TransferHistory.get(db, logid)
|
||||
if not history:
|
||||
return schemas.Response(success=False, message=f"历史记录不存在,ID:{logid}")
|
||||
# 强制转移
|
||||
force = True
|
||||
# 源路径
|
||||
in_path = Path(history.src)
|
||||
# 目的路径
|
||||
if history.dest:
|
||||
# 删除旧的已整理文件
|
||||
TransferChain(db).delete_files(Path(history.dest))
|
||||
if not target:
|
||||
target = history.dest
|
||||
elif path:
|
||||
in_path = Path(path)
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"缺少参数:path/logid")
|
||||
|
||||
if target:
|
||||
target = Path(target)
|
||||
if not target.exists():
|
||||
return schemas.Response(success=False, message=f"目标路径不存在")
|
||||
|
||||
# 类型
|
||||
mtype = MediaType(type_name) if type_name else None
|
||||
# 自定义格式
|
||||
@@ -68,7 +90,8 @@ def manual_transfer(path: str,
|
||||
season=season,
|
||||
transfer_type=transfer_type,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
min_filesize=min_filesize,
|
||||
force=force
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
WebhookEventInfo
|
||||
WebhookEventInfo, TmdbEpisode
|
||||
from app.schemas.types import TorrentStatus, MediaType, MediaImageType, EventType
|
||||
from app.utils.object import ObjectUtils
|
||||
|
||||
@@ -115,6 +115,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("recognize_media", meta=meta, mtype=mtype, tmdbid=tmdbid)
|
||||
|
||||
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, imdbid=imdbid,
|
||||
mtype=mtype, year=year, season=season)
|
||||
|
||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||
"""
|
||||
补充抓取媒体信息图片
|
||||
@@ -197,21 +210,19 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("search_medias", meta=meta)
|
||||
|
||||
def search_torrents(self, site: CommentedMap,
|
||||
mediainfo: MediaInfo,
|
||||
keyword: str = None,
|
||||
page: int = 0,
|
||||
area: str = "title") -> List[TorrentInfo]:
|
||||
keywords: List[str],
|
||||
mtype: MediaType = None,
|
||||
page: int = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
搜索一个站点的种子资源
|
||||
:param site: 站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param mtype: 媒体类型
|
||||
:param page: 页码
|
||||
:param area: 搜索区域
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
return self.run_module("search_torrents", mediainfo=mediainfo, site=site,
|
||||
keyword=keyword, page=page, area=area)
|
||||
return self.run_module("search_torrents", site=site, keywords=keywords,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
def refresh_torrents(self, site: CommentedMap) -> List[TorrentInfo]:
|
||||
"""
|
||||
@@ -274,7 +285,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("list_torrents", status=status, hashs=hashs)
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None) -> Optional[TransferInfo]:
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -282,10 +294,12 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:param target: 转移目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer", path=path, meta=meta, mediainfo=mediainfo,
|
||||
transfer_type=transfer_type, target=target)
|
||||
transfer_type=transfer_type, target=target,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
def transfer_completed(self, hashs: Union[str, list], path: Path = None) -> None:
|
||||
"""
|
||||
@@ -384,14 +398,15 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("post_torrents_message", message=message, torrents=torrents)
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移模式
|
||||
:return: 成功或失败
|
||||
"""
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo)
|
||||
self.run_module("scrape_metadata", path=path, mediainfo=mediainfo, transfer_type=transfer_type)
|
||||
|
||||
def register_commands(self, commands: Dict[str, dict]) -> None:
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Set, Dict, Union
|
||||
|
||||
@@ -39,8 +40,10 @@ class DownloadChain(ChainBase):
|
||||
发送添加下载的消息
|
||||
"""
|
||||
msg_text = ""
|
||||
if userid:
|
||||
msg_text = f"用户:{userid}"
|
||||
if torrent.site_name:
|
||||
msg_text = f"站点:{torrent.site_name}"
|
||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
||||
if meta.resource_term:
|
||||
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
|
||||
if torrent.size:
|
||||
@@ -71,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,
|
||||
@@ -266,7 +268,10 @@ class DownloadChain(ChainBase):
|
||||
download_hash=_hash,
|
||||
torrent_name=_torrent.title,
|
||||
torrent_description=_torrent.description,
|
||||
torrent_site=_torrent.site_name
|
||||
torrent_site=_torrent.site_name,
|
||||
userid=userid,
|
||||
channel=channel.value if channel else None,
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
|
||||
)
|
||||
|
||||
# 登记下载文件
|
||||
@@ -318,7 +323,7 @@ class DownloadChain(ChainBase):
|
||||
contexts: List[Context],
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
save_path: str = None,
|
||||
channel: str = None,
|
||||
channel: MessageChannel = None,
|
||||
userid: str = None) -> Tuple[List[Context], Dict[int, Dict[int, NotExistMediaInfo]]]:
|
||||
"""
|
||||
根据缺失数据,自动种子列表中组合择优下载
|
||||
@@ -781,6 +786,7 @@ class DownloadChain(ChainBase):
|
||||
for torrent in torrents:
|
||||
history = self.downloadhis.get_by_hash(torrent.hash)
|
||||
if history:
|
||||
# 媒体信息
|
||||
torrent.media = {
|
||||
"tmdbid": history.tmdbid,
|
||||
"type": history.type,
|
||||
@@ -789,6 +795,8 @@ class DownloadChain(ChainBase):
|
||||
"episode": history.episodes,
|
||||
"image": history.image,
|
||||
}
|
||||
# 下载用户
|
||||
torrent.userid = history.userid
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,12 +28,18 @@ class MediaServerChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("mediaserver_librarys", server=server)
|
||||
|
||||
def items(self, server: str, library_id: Union[str, int]) -> Generator:
|
||||
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取媒体服务器所有项目
|
||||
"""
|
||||
return self.run_module("mediaserver_items", server=server, library_id=library_id)
|
||||
|
||||
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
|
||||
"""
|
||||
获取媒体服务器项目信息
|
||||
"""
|
||||
return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id)
|
||||
|
||||
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
|
||||
"""
|
||||
获取媒体服务器剧集信息
|
||||
|
||||
@@ -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()
|
||||
@@ -111,7 +111,8 @@ class MessageChain(ChainBase):
|
||||
f"第 {sea} 季缺失 {StringUtils.str_series(no_exist.episodes) if no_exist.episodes else no_exist.total_episode} 集"
|
||||
for sea, no_exist in no_exists.get(mediainfo.tmdb_id).items()]
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"{mediainfo.title_year}:\n" + "\n".join(messages)))
|
||||
title=f"{mediainfo.title_year}:\n" + "\n".join(messages),
|
||||
userid=userid))
|
||||
# 搜索种子,过滤掉不需要的剧集,以便选择
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中不存在,开始搜索 ...")
|
||||
self.post_message(
|
||||
@@ -187,7 +188,7 @@ class MessageChain(ChainBase):
|
||||
# 下载种子
|
||||
context: Context = cache_list[int(text) - 1]
|
||||
# 下载
|
||||
self.downloadchain.download_single(context, userid=userid)
|
||||
self.downloadchain.download_single(context, userid=userid, channel=channel)
|
||||
|
||||
elif text.lower() == "p":
|
||||
# 上一页
|
||||
|
||||
@@ -62,7 +62,7 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{site} ...')
|
||||
# 搜索
|
||||
return self.__search_all_sites(keyword=title, sites=[site] if site else None, page=page) or []
|
||||
return self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
|
||||
|
||||
def last_search_results(self) -> List[Context]:
|
||||
"""
|
||||
@@ -117,16 +117,12 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
keywords = [mediainfo.title]
|
||||
# 执行搜索
|
||||
torrents: List[TorrentInfo] = []
|
||||
for keyword in keywords:
|
||||
torrents = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if torrents:
|
||||
break
|
||||
torrents: List[TorrentInfo] = self.__search_all_sites(
|
||||
mediainfo=mediainfo,
|
||||
keywords=keywords,
|
||||
sites=sites,
|
||||
area=area
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
|
||||
return []
|
||||
@@ -145,7 +141,7 @@ class SearchChain(ChainBase):
|
||||
if not torrents:
|
||||
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
|
||||
return []
|
||||
# 使用默认过滤规则再次过滤
|
||||
# 使用过滤规则再次过滤
|
||||
torrents = self.filter_torrents_by_rule(torrents=torrents,
|
||||
filter_rule=filter_rule)
|
||||
if not torrents:
|
||||
@@ -241,15 +237,15 @@ class SearchChain(ChainBase):
|
||||
# 返回
|
||||
return contexts
|
||||
|
||||
def __search_all_sites(self, mediainfo: Optional[MediaInfo] = None,
|
||||
keyword: str = None,
|
||||
def __search_all_sites(self, keywords: List[str],
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: int = 0,
|
||||
area: str = "title") -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
多线程搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
@@ -291,8 +287,18 @@ class SearchChain(ChainBase):
|
||||
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
task = executor.submit(self.search_torrents, mediainfo=mediainfo,
|
||||
site=site, keyword=keyword, page=page, area=area)
|
||||
if area == "imdbid":
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=[mediainfo.imdb_id] if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keywords=keywords,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page)
|
||||
all_task.append(task)
|
||||
# 结果集
|
||||
results = []
|
||||
@@ -303,7 +309,7 @@ class SearchChain(ChainBase):
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
@@ -327,15 +333,21 @@ class SearchChain(ChainBase):
|
||||
:param filter_rule: 过滤规则
|
||||
"""
|
||||
|
||||
# 取默认过滤规则
|
||||
if not filter_rule:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
# 没有则取搜索默认过滤规则
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
|
||||
if not filter_rule:
|
||||
return torrents
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
|
||||
def __filter_torrent(t: TorrentInfo) -> bool:
|
||||
"""
|
||||
@@ -353,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,66 @@ 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
|
||||
}
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultFilterRules)
|
||||
|
||||
# 过滤规则
|
||||
filter_rule = self.get_filter_rule(subscribe)
|
||||
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
@@ -247,8 +258,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 +291,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 +314,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 +325,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:
|
||||
@@ -375,6 +391,67 @@ class SubscribeChain(ChainBase):
|
||||
|
||||
return ret_sites
|
||||
|
||||
def get_filter_rule(self, subscribe: Subscribe):
|
||||
"""
|
||||
获取订阅过滤规则,没有则返回默认规则
|
||||
"""
|
||||
# 默认过滤规则
|
||||
if (subscribe.include
|
||||
or subscribe.exclude
|
||||
or subscribe.quality
|
||||
or subscribe.resolution
|
||||
or subscribe.effect):
|
||||
return {
|
||||
"include": subscribe.include,
|
||||
"exclude": subscribe.exclude,
|
||||
"quality": subscribe.quality,
|
||||
"resolution": subscribe.resolution,
|
||||
"effect": subscribe.effect,
|
||||
}
|
||||
# 订阅默认过滤规则
|
||||
return self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
|
||||
@staticmethod
|
||||
def check_filter_rule(torrent_info: TorrentInfo, filter_rule: Dict[str, str]) -> bool:
|
||||
"""
|
||||
检查种子是否匹配订阅过滤规则
|
||||
"""
|
||||
if not filter_rule:
|
||||
return True
|
||||
# 包含
|
||||
include = filter_rule.get("include")
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
return False
|
||||
# 排除
|
||||
exclude = filter_rule.get("exclude")
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
return False
|
||||
# 质量
|
||||
quality = filter_rule.get("quality")
|
||||
if quality:
|
||||
if not re.search(r"%s" % quality, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配质量规则 {quality}")
|
||||
return False
|
||||
# 分辨率
|
||||
resolution = filter_rule.get("resolution")
|
||||
if resolution:
|
||||
if not re.search(r"%s" % resolution, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配分辨率规则 {resolution}")
|
||||
return False
|
||||
# 特效
|
||||
effect = filter_rule.get("effect")
|
||||
if effect:
|
||||
if not re.search(r"%s" % effect, torrent_info.title, re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配特效规则 {effect}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def match(self, torrents: Dict[str, List[Context]]):
|
||||
"""
|
||||
从缓存中匹配订阅,并自动下载
|
||||
@@ -411,46 +488,48 @@ 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 = {}
|
||||
# 默认过滤规则
|
||||
default_filter = self.systemconfig.get(SystemConfigKey.DefaultFilterRules) or {}
|
||||
include = subscribe.include or default_filter.get("include")
|
||||
exclude = subscribe.exclude or default_filter.get("exclude")
|
||||
|
||||
# 已存在
|
||||
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}')
|
||||
|
||||
# 过滤规则
|
||||
filter_rule = self.get_filter_rule(subscribe)
|
||||
|
||||
# 遍历缓存种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
@@ -465,11 +544,11 @@ class SubscribeChain(ChainBase):
|
||||
continue
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.BestVersionFilterRules)
|
||||
else:
|
||||
filter_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
priority_rule = self.systemconfig.get(SystemConfigKey.SubscribeFilterRules)
|
||||
result: List[TorrentInfo] = self.filter_torrents(
|
||||
rule_string=filter_rule,
|
||||
rule_string=priority_rule,
|
||||
torrent_list=[torrent_info],
|
||||
mediainfo=torrent_mediainfo)
|
||||
if result is not None and not result:
|
||||
@@ -510,7 +589,8 @@ class SubscribeChain(ChainBase):
|
||||
set(torrent_meta.episode_list)
|
||||
):
|
||||
logger.info(
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集')
|
||||
f'{torrent_info.title} 对应剧集 {torrent_meta.episode_list} 未包含缺失的剧集'
|
||||
)
|
||||
continue
|
||||
# 过滤掉已经下载的集数
|
||||
if self.__check_subscribe_note(subscribe, torrent_meta.episode_list):
|
||||
@@ -522,21 +602,16 @@ class SubscribeChain(ChainBase):
|
||||
if torrent_meta.episode_list:
|
||||
logger.info(f'{subscribe.name} 正在洗版,{torrent_info.title} 不是整季')
|
||||
continue
|
||||
# 包含
|
||||
if include:
|
||||
if not re.search(r"%s" % include,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 不匹配包含规则 {include}")
|
||||
continue
|
||||
# 排除
|
||||
if exclude:
|
||||
if re.search(r"%s" % exclude,
|
||||
f"{torrent_info.title} {torrent_info.description}", re.I):
|
||||
logger.info(f"{torrent_info.title} 匹配排除规则 {exclude}")
|
||||
continue
|
||||
|
||||
# 过滤规则
|
||||
if not self.check_filter_rule(torrent_info=torrent_info,
|
||||
filter_rule=filter_rule):
|
||||
continue
|
||||
|
||||
# 匹配成功
|
||||
logger.info(f'{mediainfo.title_year} 匹配成功:{torrent_info.title}')
|
||||
_match_context.append(context)
|
||||
|
||||
# 开始下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
if _match_context:
|
||||
@@ -554,12 +629,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 +727,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):
|
||||
"""
|
||||
|
||||
@@ -2,12 +2,14 @@ from typing import Union
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.schemas import Notification, MessageChannel
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class SystemChain(ChainBase):
|
||||
"""
|
||||
系统级处理链
|
||||
"""
|
||||
|
||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
清理系统缓存
|
||||
@@ -15,3 +17,11 @@ class SystemChain(ChainBase):
|
||||
self.clear_cache()
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"缓存清理完成!", userid=userid))
|
||||
|
||||
def restart(self, channel: MessageChannel, userid: Union[int, str]):
|
||||
"""
|
||||
重启系统
|
||||
"""
|
||||
self.post_message(Notification(channel=channel,
|
||||
title=f"系统正在重启,请耐心等候!", userid=userid))
|
||||
SystemUtils.restart()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import glob
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
@@ -9,6 +8,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
@@ -40,7 +40,8 @@ class TransferChain(ChainBase):
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.transferhis = TransferHistoryOper(self._db)
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain(self._db)
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def process(self) -> bool:
|
||||
@@ -110,17 +111,6 @@ class TransferChain(ChainBase):
|
||||
logger.warn(f"{path.name} 没有找到可转移的媒体文件")
|
||||
return False, f"{path.name} 没有找到可转移的媒体文件"
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 有集自定义格式
|
||||
formaterHandler = FormatParser(eformat=epformat.format,
|
||||
details=epformat.detail,
|
||||
@@ -129,17 +119,24 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
# 总数
|
||||
# 目录所有文件清单
|
||||
transfer_files = SystemUtils.list_files(directory=path,
|
||||
extensions=settings.RMT_MEDIAEXT,
|
||||
min_filesize=min_filesize)
|
||||
if formaterHandler:
|
||||
# 有集自定义格式,过滤文件
|
||||
transfer_files = [f for f in transfer_files if formaterHandler.match(f.name)]
|
||||
# 总数
|
||||
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 总文件数
|
||||
total_num = len(transfer_files)
|
||||
# 已处理数量
|
||||
processed_num = 0
|
||||
# 失败数量
|
||||
fail_num = 0
|
||||
# 跳过数量
|
||||
skip_num = 0
|
||||
self.progress.update(value=0,
|
||||
text=f"开始转移 {path},共 {total_num} 个文件 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
@@ -149,6 +146,15 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 处理所有待转移目录或文件,默认一个转移路径或文件只有一个媒体信息
|
||||
for trans_path in trans_paths:
|
||||
# 汇总季集清单
|
||||
season_episodes: Dict[Tuple, List[int]] = {}
|
||||
# 汇总元数据
|
||||
metas: Dict[Tuple, MetaBase] = {}
|
||||
# 汇总媒体信息
|
||||
medias: Dict[Tuple, MediaInfo] = {}
|
||||
# 汇总转移信息
|
||||
transfers: Dict[Tuple, TransferInfo] = {}
|
||||
|
||||
# 如果是目录且不是⼀蓝光原盘,获取所有文件并转移
|
||||
if (not trans_path.is_file()
|
||||
and not SystemUtils.is_bluray_dir(trans_path)):
|
||||
@@ -165,7 +171,6 @@ class TransferChain(ChainBase):
|
||||
|
||||
# 转移所有文件
|
||||
for file_path in file_paths:
|
||||
|
||||
# 回收站及隐藏的文件不处理
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str.find('/@Recycle/') != -1 \
|
||||
@@ -173,6 +178,9 @@ class TransferChain(ChainBase):
|
||||
or file_path_str.find('/.') != -1 \
|
||||
or file_path_str.find('/@eaDir') != -1:
|
||||
logger.debug(f"{file_path_str} 是回收站或隐藏的文件")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
@@ -187,6 +195,9 @@ class TransferChain(ChainBase):
|
||||
break
|
||||
if is_blocked:
|
||||
err_msgs.append(f"{file_path.name} 命中整理屏蔽词")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 转移成功的不再处理
|
||||
@@ -194,6 +205,9 @@ class TransferChain(ChainBase):
|
||||
transferd = self.transferhis.get_by_src(file_path_str)
|
||||
if transferd and transferd.status:
|
||||
logger.info(f"{file_path} 已成功转移过,如需重新处理,请删除历史记录。")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
skip_num += 1
|
||||
continue
|
||||
|
||||
# 更新进度
|
||||
@@ -214,12 +228,15 @@ class TransferChain(ChainBase):
|
||||
if not file_meta:
|
||||
logger.error(f"{file_path} 无法识别有效信息")
|
||||
err_msgs.append(f"{file_path} 无法识别有效信息")
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 自定义识别
|
||||
if formaterHandler:
|
||||
# 开始集、结束集、PART
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.stem)
|
||||
begin_ep, end_ep, part = formaterHandler.split_episode(file_path.name)
|
||||
if begin_ep is not None:
|
||||
file_meta.begin_episode = begin_ep
|
||||
file_meta.part = part
|
||||
@@ -237,7 +254,7 @@ class TransferChain(ChainBase):
|
||||
# 新增转移失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
meta=file_meta,
|
||||
download_hash=download_hash
|
||||
)
|
||||
@@ -246,6 +263,9 @@ class TransferChain(ChainBase):
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
@@ -257,31 +277,17 @@ class TransferChain(ChainBase):
|
||||
|
||||
logger.info(f"{file_path.name} 识别为:{file_mediainfo.type.value} {file_mediainfo.title_year}")
|
||||
|
||||
# 电视剧没有集无法转移
|
||||
if file_mediainfo.type == MediaType.TV and not file_meta.episode:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:未识别到集数")
|
||||
err_msgs.append(f"{file_path.name} 未识别到集数")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo
|
||||
)
|
||||
# 发送消息
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 入库失败!",
|
||||
text=f"原因:未识别到集数",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
continue
|
||||
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=file_mediainfo)
|
||||
|
||||
# 获取集数据
|
||||
if file_mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=file_mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 获取下载hash
|
||||
if not download_hash:
|
||||
download_file = self.downloadhis.get_file_by_fullpath(file_path_str)
|
||||
if download_file:
|
||||
@@ -292,7 +298,8 @@ class TransferChain(ChainBase):
|
||||
mediainfo=file_mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=transfer_type,
|
||||
target=target)
|
||||
target=target,
|
||||
episodes_info=episodes_info)
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return False, "文件转移模块运行失败"
|
||||
@@ -303,7 +310,7 @@ class TransferChain(ChainBase):
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
@@ -316,6 +323,9 @@ class TransferChain(ChainBase):
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=file_mediainfo.get_message_image()
|
||||
))
|
||||
# 计数
|
||||
processed_num += 1
|
||||
fail_num += 1
|
||||
continue
|
||||
|
||||
# 汇总信息
|
||||
@@ -339,7 +349,7 @@ class TransferChain(ChainBase):
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=settings.TRANSFER_TYPE,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=file_mediainfo,
|
||||
@@ -347,7 +357,9 @@ class TransferChain(ChainBase):
|
||||
)
|
||||
# 刮削单个文件
|
||||
if settings.SCRAP_METADATA:
|
||||
self.scrape_metadata(path=transferinfo.target_path, mediainfo=file_mediainfo)
|
||||
self.scrape_metadata(path=transferinfo.target_path,
|
||||
mediainfo=file_mediainfo,
|
||||
transfer_type=transfer_type)
|
||||
# 更新进度
|
||||
processed_num += 1
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
@@ -355,8 +367,7 @@ class TransferChain(ChainBase):
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 目录或文件转移完成
|
||||
self.progress.update(value=100,
|
||||
text=f"所有文件转移完成,正在执行后续处理 ...",
|
||||
self.progress.update(text=f"{trans_path} 转移完成,正在执行后续处理 ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
# 执行后续处理
|
||||
@@ -383,10 +394,16 @@ class TransferChain(ChainBase):
|
||||
'mediainfo': media,
|
||||
'transferinfo': transfer_info
|
||||
})
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"成功 {total_num - len(err_msgs)} 个,失败 {len(err_msgs)} 个")
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
# 结束进度
|
||||
logger.info(f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个")
|
||||
|
||||
self.progress.update(value=100,
|
||||
text=f"{path} 转移完成,共 {total_num} 个文件,"
|
||||
f"失败 {fail_num} 个,跳过 {skip_num} 个",
|
||||
key=ProgressKey.FileTransfer)
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
return True, "\n".join(err_msgs)
|
||||
|
||||
@@ -473,7 +490,7 @@ class TransferChain(ChainBase):
|
||||
def re_transfer(self, logid: int,
|
||||
mtype: MediaType = None, tmdbid: int = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据历史记录,重新识别转移,只处理对应的src目录
|
||||
根据历史记录,重新识别转移,只支持简单条件
|
||||
:param logid: 历史记录ID
|
||||
:param mtype: 媒体类型
|
||||
:param tmdbid: TMDB ID
|
||||
@@ -483,7 +500,7 @@ class TransferChain(ChainBase):
|
||||
if not history:
|
||||
logger.error(f"历史记录不存在,ID:{logid}")
|
||||
return False, "历史记录不存在"
|
||||
# 没有下载记录,按源目录路径重新转移
|
||||
# 按源目录路径重新转移
|
||||
src_path = Path(history.src)
|
||||
if not src_path.exists():
|
||||
return False, f"源目录不存在:{src_path}"
|
||||
@@ -523,9 +540,10 @@ class TransferChain(ChainBase):
|
||||
season: int = None,
|
||||
transfer_type: str = None,
|
||||
epformat: EpisodeFormat = None,
|
||||
min_filesize: int = 0) -> Tuple[bool, Union[str, list]]:
|
||||
min_filesize: int = 0,
|
||||
force: bool = False) -> Tuple[bool, Union[str, list]]:
|
||||
"""
|
||||
手动转移
|
||||
手动转移,支持复杂条件,带进度显示
|
||||
:param in_path: 源文件路径
|
||||
:param target: 目标路径
|
||||
:param tmdbid: TMDB ID
|
||||
@@ -534,6 +552,7 @@ class TransferChain(ChainBase):
|
||||
:param transfer_type: 转移类型
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param force: 是否强制转移
|
||||
"""
|
||||
logger.info(f"手动转移:{in_path} ...")
|
||||
|
||||
@@ -555,7 +574,8 @@ class TransferChain(ChainBase):
|
||||
target=target,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize
|
||||
min_filesize=min_filesize,
|
||||
force=force
|
||||
)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
@@ -570,7 +590,8 @@ class TransferChain(ChainBase):
|
||||
transfer_type=transfer_type,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
min_filesize=min_filesize)
|
||||
min_filesize=min_filesize,
|
||||
force=force)
|
||||
return state, errmsg
|
||||
|
||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
@@ -600,13 +621,15 @@ class TransferChain(ChainBase):
|
||||
def delete_files(path: Path):
|
||||
"""
|
||||
删除转移后的文件以及空目录
|
||||
:param path: 文件路径
|
||||
"""
|
||||
logger.info(f"开始删除文件以及空目录:{path} ...")
|
||||
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
|
||||
@@ -18,7 +19,6 @@ from app.schemas import Notification
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class CommandChian(ChainBase):
|
||||
@@ -138,7 +138,7 @@ class Command(metaclass=Singleton):
|
||||
"data": {}
|
||||
},
|
||||
"/restart": {
|
||||
"func": SystemUtils.restart,
|
||||
"func": SystemChain(self._db).restart,
|
||||
"description": "重启系统",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
@@ -175,10 +175,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"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Tuple
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
@@ -272,7 +272,7 @@ class MediaInfo:
|
||||
初始化媒信息
|
||||
"""
|
||||
|
||||
def __directors_actors(tmdbinfo: dict):
|
||||
def __directors_actors(tmdbinfo: dict) -> Tuple[List[dict], List[dict]]:
|
||||
"""
|
||||
查询导演和演员
|
||||
:param tmdbinfo: TMDB元数据
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
获取所有插件信息
|
||||
|
||||
@@ -74,6 +74,16 @@ class DownloadHistoryOper(DbOper):
|
||||
"""
|
||||
DownloadFiles.delete_by_fullpath(self._db, fullpath)
|
||||
|
||||
def get_hash_by_fullpath(self, fullpath: str) -> str:
|
||||
"""
|
||||
按fullpath查询下载文件记录hash
|
||||
:param fullpath: 数据key
|
||||
"""
|
||||
fileinfo: DownloadFiles = DownloadFiles.get_by_fullpath(self._db, fullpath)
|
||||
if fileinfo:
|
||||
return fileinfo.download_hash
|
||||
return ""
|
||||
|
||||
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
|
||||
"""
|
||||
分页查询下载历史
|
||||
@@ -98,3 +108,11 @@ class DownloadHistoryOper(DbOper):
|
||||
season=season,
|
||||
episode=episode,
|
||||
tmdbid=tmdbid)
|
||||
|
||||
def list_by_user_date(self, date: str, userid: str = None) -> List[DownloadHistory]:
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
return DownloadHistory.list_by_user_date(db=self._db,
|
||||
date=date,
|
||||
userid=userid)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -35,6 +35,12 @@ class DownloadHistory(Base):
|
||||
torrent_description = Column(String)
|
||||
# 种子站点
|
||||
torrent_site = Column(String)
|
||||
# 下载用户
|
||||
userid = Column(String)
|
||||
# 下载渠道
|
||||
channel = Column(String)
|
||||
# 创建时间
|
||||
date = Column(String)
|
||||
# 附加信息
|
||||
note = Column(String)
|
||||
|
||||
@@ -90,6 +96,19 @@ class DownloadHistory(Base):
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
@staticmethod
|
||||
def list_by_user_date(db: Session, date: str, userid: str = None):
|
||||
"""
|
||||
查询某用户某时间之后的下载历史
|
||||
"""
|
||||
if userid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date,
|
||||
DownloadHistory.userid == userid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.date < date).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
|
||||
class DownloadFiles(Base):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
# 开始集数
|
||||
|
||||
@@ -65,6 +65,10 @@ class TransferHistory(Base):
|
||||
def get_by_src(db: Session, src: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.src == src).first()
|
||||
|
||||
@staticmethod
|
||||
def list_by_hash(db: Session, download_hash: str):
|
||||
return db.query(TransferHistory).filter(TransferHistory.download_hash == download_hash).all()
|
||||
|
||||
@staticmethod
|
||||
def statistic(db: Session, days: int = 7):
|
||||
"""
|
||||
|
||||
@@ -36,6 +36,13 @@ class TransferHistoryOper(DbOper):
|
||||
"""
|
||||
return TransferHistory.get_by_src(self._db, src)
|
||||
|
||||
def list_by_hash(self, download_hash: str) -> List[TransferHistory]:
|
||||
"""
|
||||
按种子hash查询转移记录
|
||||
:param download_hash: 种子hash
|
||||
"""
|
||||
return TransferHistory.list_by_hash(self._db, download_hash)
|
||||
|
||||
def add(self, **kwargs) -> TransferHistory:
|
||||
"""
|
||||
新增转移历史
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class NfoReader:
|
||||
@@ -8,6 +9,9 @@ class NfoReader:
|
||||
self.tree = ET.parse(xml_file_path)
|
||||
self.root = self.tree.getroot()
|
||||
|
||||
def get_element_value(self, element_path):
|
||||
def get_element_value(self, element_path) -> Optional[str]:
|
||||
element = self.root.find(element_path)
|
||||
return element.text if element is not None else None
|
||||
|
||||
def get_elements(self, element_path) -> List[ET.Element]:
|
||||
return self.root.findall(element_path)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
42
app/log.py
42
app/log.py
@@ -1,28 +1,12 @@
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
# logger
|
||||
logger = logging.getLogger()
|
||||
if settings.DEBUG:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 创建终端输出Handler
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# 创建文件输出Handler
|
||||
file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log',
|
||||
mode='w',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
# 日志级别颜色
|
||||
level_name_colors = {
|
||||
logging.DEBUG: lambda level_name: click.style(str(level_name), fg="cyan"),
|
||||
logging.INFO: lambda level_name: click.style(str(level_name), fg="green"),
|
||||
@@ -34,20 +18,40 @@ level_name_colors = {
|
||||
}
|
||||
|
||||
|
||||
# 定义日志输出格式
|
||||
class CustomFormatter(logging.Formatter):
|
||||
"""
|
||||
定义日志输出格式
|
||||
"""
|
||||
|
||||
def format(self, record):
|
||||
seperator = " " * (8 - len(record.levelname))
|
||||
record.leveltext = level_name_colors[record.levelno](record.levelname + ":") + seperator
|
||||
if record.filename == "__init__.py":
|
||||
record.filename = Path(record.pathname).parent.name
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# DEBUG
|
||||
logger = logging.getLogger()
|
||||
if settings.DEBUG:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 终端日志
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
console_formatter = CustomFormatter("%(leveltext)s%(filename)s - %(message)s")
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件日志
|
||||
file_handler = RotatingFileHandler(filename=settings.LOG_PATH / 'moviepilot.log',
|
||||
mode='w',
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
encoding='utf-8')
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_formater = CustomFormatter("【%(levelname)s】%(asctime)s - %(filename)s - %(message)s")
|
||||
file_handler.setFormatter(file_formater)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
97
app/main.py
97
app/main.py
@@ -1,10 +1,21 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
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 +55,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 +146,8 @@ def shutdown_server():
|
||||
DisplayHelper().stop()
|
||||
# 停止定时服务
|
||||
Scheduler().stop()
|
||||
# 停止前端服务
|
||||
stop_frontend()
|
||||
|
||||
|
||||
@App.on_event("startup")
|
||||
@@ -66,7 +155,7 @@ def start_module():
|
||||
"""
|
||||
启动模块
|
||||
"""
|
||||
# 虚伪显示
|
||||
# 虚拟显示
|
||||
DisplayHelper()
|
||||
# 站点管理
|
||||
SitesHelper()
|
||||
@@ -80,12 +169,16 @@ def start_module():
|
||||
Command()
|
||||
# 初始化路由
|
||||
init_routers()
|
||||
# 启动前端服务
|
||||
start_frontend()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 启动托盘
|
||||
start_tray()
|
||||
# 初始化数据库
|
||||
init_db()
|
||||
# 更新数据库
|
||||
update_db()
|
||||
# 启动服务
|
||||
# 启动API服务
|
||||
Server.run()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
@@ -10,11 +11,11 @@ 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
|
||||
|
||||
|
||||
class DoubanModule(_ModuleBase):
|
||||
|
||||
doubanapi: DoubanApi = None
|
||||
scraper: DoubanScraper = None
|
||||
|
||||
@@ -34,6 +35,271 @@ class DoubanModule(_ModuleBase):
|
||||
:param doubanid: 豆瓣ID
|
||||
:return: 豆瓣信息
|
||||
"""
|
||||
"""
|
||||
{
|
||||
"rating": {
|
||||
"count": 287365,
|
||||
"max": 10,
|
||||
"star_count": 3.5,
|
||||
"value": 6.6
|
||||
},
|
||||
"lineticket_url": "",
|
||||
"controversy_reason": "",
|
||||
"pubdate": [
|
||||
"2021-10-29(中国大陆)"
|
||||
],
|
||||
"last_episode_number": null,
|
||||
"interest_control_info": null,
|
||||
"pic": {
|
||||
"large": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp",
|
||||
"normal": "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2707553644.webp"
|
||||
},
|
||||
"vendor_count": 6,
|
||||
"body_bg_color": "f4f5f9",
|
||||
"is_tv": false,
|
||||
"head_info": null,
|
||||
"album_no_interact": false,
|
||||
"ticket_price_info": "",
|
||||
"webisode_count": 0,
|
||||
"year": "2021",
|
||||
"card_subtitle": "2021 / 英国 美国 / 动作 惊悚 冒险 / 凯瑞·福永 / 丹尼尔·克雷格 蕾雅·赛杜",
|
||||
"forum_info": null,
|
||||
"webisode": null,
|
||||
"id": "20276229",
|
||||
"gallery_topic_count": 0,
|
||||
"languages": [
|
||||
"英语",
|
||||
"法语",
|
||||
"意大利语",
|
||||
"俄语",
|
||||
"西班牙语"
|
||||
],
|
||||
"genres": [
|
||||
"动作",
|
||||
"惊悚",
|
||||
"冒险"
|
||||
],
|
||||
"review_count": 926,
|
||||
"title": "007:无暇赴死",
|
||||
"intro": "世界局势波诡云谲,再度出山的邦德(丹尼尔·克雷格 饰)面临有史以来空前的危机,传奇特工007的故事在本片中达到高潮。新老角色集结亮相,蕾雅·赛杜回归,二度饰演邦女郎玛德琳。系列最恐怖反派萨芬(拉米·马雷克 饰)重磅登场,毫不留情地展示了自己狠辣的一面,不仅揭开了玛德琳身上隐藏的秘密,还酝酿着危及数百万人性命的阴谋,幽灵党的身影也似乎再次浮出水面。半路杀出的新00号特工(拉什纳·林奇 饰)与神秘女子(安娜·德·阿玛斯 饰)看似与邦德同阵作战,但其真实目的依然成谜。关乎邦德生死的新仇旧怨接踵而至,暗潮汹涌之下他能否拯救世界?",
|
||||
"interest_cmt_earlier_tip_title": "发布于上映前",
|
||||
"has_linewatch": true,
|
||||
"ugc_tabs": [
|
||||
{
|
||||
"source": "reviews",
|
||||
"type": "review",
|
||||
"title": "影评"
|
||||
},
|
||||
{
|
||||
"source": "forum_topics",
|
||||
"type": "forum",
|
||||
"title": "讨论"
|
||||
}
|
||||
],
|
||||
"forum_topic_count": 857,
|
||||
"ticket_promo_text": "",
|
||||
"webview_info": {},
|
||||
"is_released": true,
|
||||
"actors": [
|
||||
{
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"interest": null,
|
||||
"vendor_icons": [
|
||||
"https://img9.doubanio.com/f/frodo/fbc90f355fc45d5d2056e0d88c697f9414b56b44/pics/vendors/tencent.png",
|
||||
"https://img2.doubanio.com/f/frodo/8286b9b5240f35c7e59e1b1768cd2ccf0467cde5/pics/vendors/migu_video.png",
|
||||
"https://img9.doubanio.com/f/frodo/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc/pics/vendors/bilibili.png"
|
||||
],
|
||||
"episodes_count": 0,
|
||||
"color_scheme": {
|
||||
"is_dark": true,
|
||||
"primary_color_light": "868ca5",
|
||||
"_base_color": [
|
||||
0.6333333333333333,
|
||||
0.18867924528301885,
|
||||
0.20784313725490197
|
||||
],
|
||||
"secondary_color": "f4f5f9",
|
||||
"_avg_color": [
|
||||
0.059523809523809625,
|
||||
0.09790209790209795,
|
||||
0.5607843137254902
|
||||
],
|
||||
"primary_color_dark": "676c7f"
|
||||
},
|
||||
"type": "movie",
|
||||
"null_rating_reason": "",
|
||||
"linewatches": [
|
||||
{
|
||||
"url": "http://v.youku.com/v_show/id_XNTIwMzM2NDg5Mg==.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900",
|
||||
"source": {
|
||||
"literal": "youku",
|
||||
"pic": "https://img1.doubanio.com/img/files/file-1432869267.png",
|
||||
"name": "优酷视频"
|
||||
},
|
||||
"source_uri": "youku://play?vid=XNTIwMzM2NDg5Mg==&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900",
|
||||
"free": false
|
||||
},
|
||||
],
|
||||
"info_url": "https://www.douban.com/doubanapp//h5/movie/20276229/desc",
|
||||
"tags": [],
|
||||
"durations": [
|
||||
"163分钟"
|
||||
],
|
||||
"comment_count": 97204,
|
||||
"cover": {
|
||||
"description": "",
|
||||
"author": {
|
||||
"loc": {
|
||||
"id": "108288",
|
||||
"name": "北京",
|
||||
"uid": "beijing"
|
||||
},
|
||||
"kind": "user",
|
||||
"name": "雨落下",
|
||||
"reg_time": "2020-08-11 16:22:48",
|
||||
"url": "https://www.douban.com/people/221011676/",
|
||||
"uri": "douban://douban.com/user/221011676",
|
||||
"id": "221011676",
|
||||
"avatar_side_icon_type": 3,
|
||||
"avatar_side_icon_id": "234",
|
||||
"avatar": "https://img2.doubanio.com/icon/up221011676-2.jpg",
|
||||
"is_club": false,
|
||||
"type": "user",
|
||||
"avatar_side_icon": "https://img2.doubanio.com/view/files/raw/file-1683625971.png",
|
||||
"uid": "221011676"
|
||||
},
|
||||
"url": "https://movie.douban.com/photos/photo/2707553644/",
|
||||
"image": {
|
||||
"large": {
|
||||
"url": "https://img9.doubanio.com/view/photo/l/public/p2707553644.webp",
|
||||
"width": 1082,
|
||||
"height": 1600,
|
||||
"size": 0
|
||||
},
|
||||
"raw": null,
|
||||
"small": {
|
||||
"url": "https://img9.doubanio.com/view/photo/s/public/p2707553644.webp",
|
||||
"width": 405,
|
||||
"height": 600,
|
||||
"size": 0
|
||||
},
|
||||
"normal": {
|
||||
"url": "https://img9.doubanio.com/view/photo/m/public/p2707553644.webp",
|
||||
"width": 405,
|
||||
"height": 600,
|
||||
"size": 0
|
||||
},
|
||||
"is_animated": false
|
||||
},
|
||||
"uri": "douban://douban.com/photo/2707553644",
|
||||
"create_time": "2021-10-26 15:05:01",
|
||||
"position": 0,
|
||||
"owner_uri": "douban://douban.com/movie/20276229",
|
||||
"type": "photo",
|
||||
"id": "2707553644",
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/photo/2707553644/"
|
||||
},
|
||||
"cover_url": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp",
|
||||
"restrictive_icon_url": "",
|
||||
"header_bg_color": "676c7f",
|
||||
"is_douban_intro": false,
|
||||
"ticket_vendor_icons": [
|
||||
"https://img9.doubanio.com/view/dale-online/dale_ad/public/0589a62f2f2d7c2.jpg"
|
||||
],
|
||||
"honor_infos": [],
|
||||
"sharing_url": "https://movie.douban.com/subject/20276229/",
|
||||
"subject_collections": [],
|
||||
"wechat_timeline_share": "screenshot",
|
||||
"countries": [
|
||||
"英国",
|
||||
"美国"
|
||||
],
|
||||
"url": "https://movie.douban.com/subject/20276229/",
|
||||
"release_date": null,
|
||||
"original_title": "No Time to Die",
|
||||
"uri": "douban://douban.com/movie/20276229",
|
||||
"pre_playable_date": null,
|
||||
"episodes_info": "",
|
||||
"subtype": "movie",
|
||||
"directors": [
|
||||
{
|
||||
"name": "凯瑞·福永",
|
||||
"roles": [
|
||||
"导演",
|
||||
"制片人",
|
||||
"编剧",
|
||||
"摄影",
|
||||
"演员"
|
||||
],
|
||||
"title": "凯瑞·福永(同名)美国,加利福尼亚州,奥克兰影视演员",
|
||||
"url": "https://movie.douban.com/celebrity/1009531/",
|
||||
"user": null,
|
||||
"character": "导演",
|
||||
"uri": "douban://douban.com/celebrity/1009531?subject_id=27215222",
|
||||
"avatar": {
|
||||
"large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/600/h/3000/format/webp",
|
||||
"normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/200/h/300/format/webp"
|
||||
},
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1009531/",
|
||||
"type": "celebrity",
|
||||
"id": "1009531",
|
||||
"latin_name": "Cary Fukunaga"
|
||||
}
|
||||
],
|
||||
"is_show": false,
|
||||
"in_blacklist": false,
|
||||
"pre_release_desc": "",
|
||||
"video": null,
|
||||
"aka": [
|
||||
"007:生死有时(港)",
|
||||
"007:生死交战(台)",
|
||||
"007:间不容死",
|
||||
"邦德25",
|
||||
"007:没空去死(豆友译名)",
|
||||
"James Bond 25",
|
||||
"Never Dream of Dying",
|
||||
"Shatterhand"
|
||||
],
|
||||
"is_restrictive": false,
|
||||
"trailer": {
|
||||
"sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/movie/20276229/trailer%3Ftrailer_id%3D282585%26trailer_type%3DA",
|
||||
"video_url": "https://vt1.doubanio.com/202310011325/3b1f5827e91dde7826dc20930380dfc2/view/movie/M/402820585.mp4",
|
||||
"title": "中国预告片:终极决战版 (中文字幕)",
|
||||
"uri": "douban://douban.com/movie/20276229/trailer?trailer_id=282585&trailer_type=A",
|
||||
"cover_url": "https://img1.doubanio.com/img/trailer/medium/2712944408.jpg",
|
||||
"term_num": 0,
|
||||
"n_comments": 21,
|
||||
"create_time": "2021-11-01",
|
||||
"subject_title": "007:无暇赴死",
|
||||
"file_size": 10520074,
|
||||
"runtime": "00:42",
|
||||
"type": "A",
|
||||
"id": "282585",
|
||||
"desc": ""
|
||||
},
|
||||
"interest_cmt_earlier_tip_desc": "该短评的发布时间早于公开上映时间,作者可能通过其他渠道提前观看,请谨慎参考。其评分将不计入总评分。"
|
||||
}
|
||||
"""
|
||||
if not doubanid:
|
||||
return None
|
||||
logger.info(f"开始获取豆瓣信息:{doubanid} ...")
|
||||
@@ -103,6 +369,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]]:
|
||||
"""
|
||||
搜索媒体信息
|
||||
@@ -129,22 +405,59 @@ class DoubanModule(_ModuleBase):
|
||||
|
||||
return ret_medias
|
||||
|
||||
def __match(self, name: str, year: str, 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: 季号
|
||||
"""
|
||||
result = self.doubanapi.search(f"{name} {year or ''}")
|
||||
if imdbid:
|
||||
# 优先使用IMDBID查询
|
||||
logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...")
|
||||
result = self.doubanapi.imdbid(imdbid)
|
||||
if result:
|
||||
doubanid = result.get("id")
|
||||
if doubanid and not str(doubanid).isdigit():
|
||||
doubanid = re.search(r"\d+", doubanid).group(0)
|
||||
result["id"] = doubanid
|
||||
logger.info(f"{imdbid} 查询到豆瓣信息:{result.get('title')}")
|
||||
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"):
|
||||
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
|
||||
type_name = item_obj.get("type_name")
|
||||
if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]:
|
||||
continue
|
||||
title = item_obj.get("title")
|
||||
if mtype and mtype != type_name:
|
||||
continue
|
||||
if mtype == MediaType.TV and not season:
|
||||
season = 1
|
||||
item = item_obj.get("target")
|
||||
title = item.get("title")
|
||||
if not title:
|
||||
continue
|
||||
meta = MetaInfo(title)
|
||||
if meta.name == name and (not season or meta.begin_season == season):
|
||||
return item_obj
|
||||
if type_name == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = meta.begin_season or 1
|
||||
if meta.name == name \
|
||||
and ((not season and not meta.begin_season) or meta.begin_season == season) \
|
||||
and (not year or item.get('year') == year):
|
||||
logger.info(f"{name} 匹配到豆瓣信息:{item.get('id')} {item.get('title')}")
|
||||
return item
|
||||
return {}
|
||||
|
||||
def movie_top250(self, page: int = 1, count: int = 30) -> List[dict]:
|
||||
@@ -157,11 +470,12 @@ class DoubanModule(_ModuleBase):
|
||||
return []
|
||||
return infos.get("subject_collection_items")
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 传输类型
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "douban":
|
||||
@@ -173,14 +487,22 @@ class DoubanModule(_ModuleBase):
|
||||
if not meta.name:
|
||||
return
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.__match(name=mediainfo.title, year=mediainfo.year, season=meta.begin_season)
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
return
|
||||
# 查询豆瓣详情
|
||||
doubaninfo = self.douban_info(doubaninfo.get("id"))
|
||||
# 刮削路径
|
||||
scrape_path = path / path.name
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
file_path=scrape_path)
|
||||
file_path=scrape_path,
|
||||
transfer_type=transfer_type)
|
||||
else:
|
||||
# 目录下的所有文件
|
||||
for file in SystemUtils.list_files(path, settings.RMT_MEDIAEXT):
|
||||
@@ -192,16 +514,21 @@ class DoubanModule(_ModuleBase):
|
||||
if not meta.name:
|
||||
continue
|
||||
# 根据名称查询豆瓣数据
|
||||
doubaninfo = self.__match(name=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
doubaninfo = self.match_doubaninfo(name=mediainfo.title,
|
||||
imdbid=mediainfo.imdb_id,
|
||||
mtype=mediainfo.type.value,
|
||||
year=mediainfo.year,
|
||||
season=meta.begin_season)
|
||||
if not doubaninfo:
|
||||
logger.warn(f"未找到 {mediainfo.title} 的豆瓣信息")
|
||||
break
|
||||
# 查询豆瓣详情
|
||||
doubaninfo = self.douban_info(doubaninfo.get("id"))
|
||||
# 刮削
|
||||
self.scraper.gen_scraper_files(meta=meta,
|
||||
mediainfo=MediaInfo(douban_info=doubaninfo),
|
||||
file_path=file)
|
||||
file_path=file,
|
||||
transfer_type=transfer_type)
|
||||
except Exception as e:
|
||||
logger.error(f"刮削文件 {file} 失败,原因:{e}")
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from xml.dom import minidom
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DoubanScraper:
|
||||
|
||||
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo, file_path: Path):
|
||||
_transfer_type = settings.TRANSFER_TYPE
|
||||
|
||||
def gen_scraper_files(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
file_path: Path, transfer_type: str):
|
||||
"""
|
||||
生成刮削文件
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param file_path: 文件路径或者目录路径
|
||||
:param transfer_type: 转输类型
|
||||
"""
|
||||
|
||||
self._transfer_type = transfer_type
|
||||
|
||||
try:
|
||||
# 电影
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
@@ -154,31 +163,55 @@ class DoubanScraper:
|
||||
# 保存
|
||||
self.__save_nfo(doc, season_path.joinpath("season.nfo"))
|
||||
|
||||
@staticmethod
|
||||
def __save_image(url: str, file_path: Path):
|
||||
def __save_image(self, url: str, file_path: Path):
|
||||
"""
|
||||
下载图片并保存
|
||||
"""
|
||||
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:
|
||||
file_path.write_bytes(r.content)
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, r.content)
|
||||
else:
|
||||
file_path.write_bytes(r.content)
|
||||
logger.info(f"图片已保存:{file_path}")
|
||||
else:
|
||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||
except Exception as err:
|
||||
logger.error(f"{file_path.stem}图片下载失败:{err}")
|
||||
|
||||
@staticmethod
|
||||
def __save_nfo(doc, file_path: Path):
|
||||
def __save_nfo(self, doc, file_path: Path):
|
||||
"""
|
||||
保存NFO
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
file_path.write_bytes(xml_str)
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, xml_str)
|
||||
else:
|
||||
file_path.write_bytes(xml_str)
|
||||
logger.info(f"NFO文件已保存:{file_path}")
|
||||
|
||||
def __save_remove_file(self, out_file: Path, content: Union[str, bytes]):
|
||||
"""
|
||||
保存文件到远端
|
||||
"""
|
||||
temp_file = settings.TEMP_PATH / str(out_file)[1:]
|
||||
temp_file_dir = temp_file.parent
|
||||
if not temp_file_dir.exists():
|
||||
temp_file_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file.write_bytes(content)
|
||||
if self._transfer_type == 'rclone_move':
|
||||
SystemUtils.rclone_move(temp_file, out_file)
|
||||
elif self._transfer_type == 'rclone_copy':
|
||||
SystemUtils.rclone_copy(temp_file, out_file)
|
||||
|
||||
@@ -6,7 +6,6 @@ from app.core.context import MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.emby.emby import Emby
|
||||
from app.schemas import ExistMediaInfo, RefreshMediaItem, WebhookEventInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -28,7 +27,7 @@ class EmbyModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.emby.is_inactive():
|
||||
self.emby = Emby()
|
||||
self.emby.reconnect()
|
||||
|
||||
def user_authenticate(self, name: str, password: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -40,7 +39,7 @@ class EmbyModule(_ModuleBase):
|
||||
# Emby认证
|
||||
return self.emby.authenticate(name, password)
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -50,7 +49,7 @@ class EmbyModule(_ModuleBase):
|
||||
"""
|
||||
return self.emby.get_webhook_message(form, args)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[schemas.ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -62,25 +61,40 @@ class EmbyModule(_ModuleBase):
|
||||
movie = self.emby.get_iteminfo(itemid)
|
||||
if movie:
|
||||
logger.info(f"媒体库中已存在:{movie}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
movies = self.emby.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="emby",
|
||||
itemid=movie.item_id
|
||||
)
|
||||
movies = self.emby.get_movies(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"媒体库中已存在:{movies}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="emby",
|
||||
itemid=movies[0].item_id
|
||||
)
|
||||
else:
|
||||
tvs = self.emby.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
itemid, tvs = self.emby.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons=tvs,
|
||||
server="emby",
|
||||
itemid=itemid
|
||||
)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
@@ -90,7 +104,7 @@ class EmbyModule(_ModuleBase):
|
||||
:return: 成功或失败
|
||||
"""
|
||||
items = [
|
||||
RefreshMediaItem(
|
||||
schemas.RefreshMediaItem(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
type=mediainfo.type,
|
||||
@@ -105,13 +119,8 @@ class EmbyModule(_ModuleBase):
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.emby.get_medias_count()
|
||||
user_count = self.emby.get_user_count()
|
||||
return [schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=user_count or 0
|
||||
)]
|
||||
media_statistic.user_count = self.emby.get_user_count()
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
@@ -119,16 +128,7 @@ class EmbyModule(_ModuleBase):
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
librarys = self.emby.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
return [schemas.MediaServerLibrary(
|
||||
server="emby",
|
||||
id=library.get("id"),
|
||||
name=library.get("name"),
|
||||
type=library.get("type"),
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
return self.emby.get_librarys()
|
||||
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
@@ -136,21 +136,15 @@ class EmbyModule(_ModuleBase):
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
items = self.emby.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("library"),
|
||||
item_id=item.get("id"),
|
||||
item_type=item.get("type"),
|
||||
title=item.get("title"),
|
||||
original_title=item.get("original_title"),
|
||||
year=item.get("year"),
|
||||
tmdbid=int(item.get("tmdbid")) if item.get("tmdbid") else None,
|
||||
imdbid=item.get("imdbid"),
|
||||
tvdbid=item.get("tvdbid"),
|
||||
path=item.get("path"),
|
||||
)
|
||||
return self.emby.get_items(library_id)
|
||||
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
return self.emby.get_iteminfo(item_id)
|
||||
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
@@ -159,7 +153,7 @@ class EmbyModule(_ModuleBase):
|
||||
"""
|
||||
if server != "emby":
|
||||
return None
|
||||
seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
|
||||
_, seasoninfo = self.emby.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
return [schemas.MediaServerSeasonInfo(
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Union, Dict, Generator
|
||||
from typing import List, Optional, Union, Dict, Generator, Tuple
|
||||
|
||||
from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import RefreshMediaItem, WebhookEventInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class Emby(metaclass=Singleton):
|
||||
@@ -24,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:
|
||||
@@ -35,6 +34,13 @@ class Emby(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.user else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.user = self.get_user()
|
||||
self.folders = self.get_emby_folders()
|
||||
|
||||
def get_emby_folders(self) -> List[dict]:
|
||||
"""
|
||||
获取Emby媒体库路径列表
|
||||
@@ -71,7 +77,7 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接User/Views 出错:" + str(e))
|
||||
return []
|
||||
|
||||
def get_librarys(self):
|
||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
@@ -86,12 +92,15 @@ class Emby(metaclass=Singleton):
|
||||
library_type = MediaType.TV.value
|
||||
case _:
|
||||
continue
|
||||
libraries.append({
|
||||
"id": library.get("Id"),
|
||||
"name": library.get("Name"),
|
||||
"path": library.get("Path"),
|
||||
"type": library_type
|
||||
})
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
server="emby",
|
||||
id=library.get("Id"),
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
|
||||
def get_user(self, user_name: str = None) -> Optional[Union[str, int]]:
|
||||
@@ -193,59 +202,29 @@ class Emby(metaclass=Singleton):
|
||||
logger.error(f"连接Users/Query出错:" + str(e))
|
||||
return 0
|
||||
|
||||
def get_activity_log(self, num: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取Emby活动记录
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%semby/System/ActivityLog/Entries?api_key=%s&" % (self._host, self._apikey)
|
||||
ret_array = []
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
items = ret_json.get('Items')
|
||||
for item in items:
|
||||
if item.get("Type") == "AuthenticationSucceeded":
|
||||
event_type = "LG"
|
||||
event_date = StringUtils.get_time(item.get("Date"))
|
||||
event_str = "%s, %s" % (item.get("Name"), item.get("ShortOverview"))
|
||||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||||
ret_array.append(activity)
|
||||
if item.get("Type") in ["VideoPlayback", "VideoPlaybackStopped"]:
|
||||
event_type = "PL"
|
||||
event_date = StringUtils.get_time(item.get("Date"))
|
||||
event_str = item.get("Name")
|
||||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||||
ret_array.append(activity)
|
||||
else:
|
||||
logger.error(f"System/ActivityLog/Entries 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
|
||||
logger.error(f"连接System/ActivityLog/Entries出错:" + str(e))
|
||||
return []
|
||||
return ret_array[:num]
|
||||
|
||||
def get_medias_count(self) -> dict:
|
||||
def get_medias_count(self) -> schemas.Statistic:
|
||||
"""
|
||||
获得电影、电视剧、动漫媒体数量
|
||||
:return: MovieCount SeriesCount SongCount
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json()
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Counts出错:" + str(e))
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
|
||||
def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -256,7 +235,15 @@ class Emby(metaclass=Singleton):
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
req_url = "%semby/Items?IncludeItemTypes=Series&Fields=ProductionYear&StartIndex=0&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
||||
req_url = ("%semby/Items?"
|
||||
"IncludeItemTypes=Series"
|
||||
"&Fields=ProductionYear"
|
||||
"&StartIndex=0"
|
||||
"&Recursive=true"
|
||||
"&SearchTerm=%s"
|
||||
"&Limit=10"
|
||||
"&IncludeSearchTypes=false"
|
||||
"&api_key=%s") % (
|
||||
self._host, name, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
@@ -275,7 +262,7 @@ class Emby(metaclass=Singleton):
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: str = None,
|
||||
tmdb_id: int = None) -> Optional[List[dict]]:
|
||||
tmdb_id: int = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Emby中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
@@ -296,17 +283,28 @@ class Emby(metaclass=Singleton):
|
||||
ret_movies = []
|
||||
for res_item in res_items:
|
||||
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=res_item.get("ParentId"),
|
||||
item_id=res_item.get("Id"),
|
||||
item_type=res_item.get("Type"),
|
||||
title=res_item.get("Name"),
|
||||
original_title=res_item.get("OriginalTitle"),
|
||||
year=res_item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=res_item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=res_item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=res_item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(
|
||||
{'title': res_item.get('Name'), 'year': str(res_item.get('ProductionYear'))})
|
||||
ret_movies.append(mediaserver_item)
|
||||
continue
|
||||
if res_item.get('Name') == title and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
ret_movies.append(
|
||||
{'title': res_item.get('Name'), 'year': str(res_item.get('ProductionYear'))})
|
||||
if (mediaserver_item.title == title
|
||||
and (not year or str(mediaserver_item.year) == str(year))):
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
@@ -318,7 +316,8 @@ class Emby(metaclass=Singleton):
|
||||
title: str = None,
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None) -> Optional[Dict[int, list]]:
|
||||
season: int = None
|
||||
) -> Tuple[Optional[str], Optional[Dict[int, List[Dict[int, list]]]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Emby中的剧集列表
|
||||
:param item_id: Emby中的ID
|
||||
@@ -329,22 +328,21 @@ class Emby(metaclass=Singleton):
|
||||
:return: 每一季的已有集数
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
return None, None
|
||||
# 电视剧
|
||||
if not item_id:
|
||||
item_id = self.__get_emby_series_id_by_name(title, year)
|
||||
if item_id is None:
|
||||
return None
|
||||
return None, None
|
||||
if not item_id:
|
||||
return {}
|
||||
return None, {}
|
||||
# 验证tmdbid是否相同
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
item_tmdbid = (item_info.get("ProviderIds") or {}).get("Tmdb")
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(tmdb_id) != str(item_tmdbid):
|
||||
return {}
|
||||
# /Shows/Id/Episodes 查集的信息
|
||||
if tmdb_id and item_info.tmdbid:
|
||||
if str(tmdb_id) != str(item_info.tmdbid):
|
||||
return None, {}
|
||||
# 查集的信息
|
||||
if not season:
|
||||
season = ""
|
||||
try:
|
||||
@@ -352,7 +350,8 @@ class Emby(metaclass=Singleton):
|
||||
self._host, item_id, season, self._apikey)
|
||||
res_json = RequestUtils().get_res(req_url)
|
||||
if res_json:
|
||||
res_items = res_json.json().get("Items")
|
||||
tv_item = res_json.json()
|
||||
res_items = tv_item.get("Items")
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
season_index = res_item.get("ParentIndexNumber")
|
||||
@@ -367,11 +366,11 @@ class Emby(metaclass=Singleton):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
# 返回
|
||||
return season_episodes
|
||||
return tv_item.get("Id"), season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None
|
||||
return {}
|
||||
return None, None
|
||||
return None, {}
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -434,7 +433,7 @@ class Emby(metaclass=Singleton):
|
||||
return False
|
||||
return False
|
||||
|
||||
def refresh_library_by_items(self, items: List[RefreshMediaItem]) -> bool:
|
||||
def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> bool:
|
||||
"""
|
||||
按类型、名称、年份来刷新媒体库
|
||||
:param items: 已识别的需要刷新媒体库的媒体信息列表
|
||||
@@ -456,7 +455,7 @@ class Emby(metaclass=Singleton):
|
||||
return self.__refresh_emby_library_by_id(library_id)
|
||||
logger.info(f"Emby媒体库刷新完成")
|
||||
|
||||
def __get_emby_library_id_by_item(self, item: RefreshMediaItem) -> Optional[str]:
|
||||
def __get_emby_library_id_by_item(self, item: schemas.RefreshMediaItem) -> Optional[str]:
|
||||
"""
|
||||
根据媒体信息查询在哪个媒体库,返回要刷新的位置的ID
|
||||
:param item: {title, year, type, category, target_path}
|
||||
@@ -474,17 +473,18 @@ 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:
|
||||
# 匹配子目录
|
||||
subfolder_path = Path(subfolder.get("Path"))
|
||||
if item_path.is_relative_to(subfolder_path):
|
||||
return subfolder.get("Id")
|
||||
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")):
|
||||
@@ -492,31 +492,45 @@ class Emby(metaclass=Singleton):
|
||||
# 刷新根目录
|
||||
return "/"
|
||||
|
||||
def get_iteminfo(self, itemid: str) -> dict:
|
||||
def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取单个项目详情
|
||||
"""
|
||||
if not itemid:
|
||||
return {}
|
||||
return None
|
||||
if not self._host or not self._apikey:
|
||||
return {}
|
||||
return None
|
||||
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self.user, itemid, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="emby",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Id出错:" + str(e))
|
||||
return {}
|
||||
return None
|
||||
|
||||
def get_items(self, parent: str) -> Generator:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
if not parent:
|
||||
yield {}
|
||||
yield None
|
||||
if not self._host or not self._apikey:
|
||||
yield {}
|
||||
yield None
|
||||
req_url = "%semby/Users/%s/Items?ParentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
@@ -526,26 +540,15 @@ class Emby(metaclass=Singleton):
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
item_info = self.get_iteminfo(result.get("Id"))
|
||||
yield {"id": result.get("Id"),
|
||||
"library": item_info.get("ParentId"),
|
||||
"type": item_info.get("Type"),
|
||||
"title": item_info.get("Name"),
|
||||
"original_title": item_info.get("OriginalTitle"),
|
||||
"year": item_info.get("ProductionYear"),
|
||||
"tmdbid": item_info.get("ProviderIds", {}).get("Tmdb"),
|
||||
"imdbid": item_info.get("ProviderIds", {}).get("Imdb"),
|
||||
"tvdbid": item_info.get("ProviderIds", {}).get("Tvdb"),
|
||||
"path": item_info.get("Path"),
|
||||
"json": str(item_info)}
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(parent=result.get('Id')):
|
||||
yield item
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
yield {}
|
||||
yield None
|
||||
|
||||
def get_webhook_message(self, form: any, args: dict) -> Optional[WebhookEventInfo]:
|
||||
def get_webhook_message(self, form: any, args: dict) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Emby Webhook报文
|
||||
电影:
|
||||
@@ -797,8 +800,8 @@ class Emby(metaclass=Singleton):
|
||||
eventType = message.get('Event')
|
||||
if not eventType:
|
||||
return None
|
||||
logger.info(f"接收到emby webhook:{message}")
|
||||
eventItem = WebhookEventInfo(event=eventType, channel="emby")
|
||||
logger.debug(f"接收到emby webhook:{message}")
|
||||
eventItem = schemas.WebhookEventInfo(event=eventType, channel="emby")
|
||||
if message.get('Item'):
|
||||
if message.get('Item', {}).get('Type') == 'Episode':
|
||||
eventItem.item_type = "TV"
|
||||
@@ -864,16 +867,36 @@ class Emby(metaclass=Singleton):
|
||||
|
||||
def get_data(self, url: str) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中{HOST}、{APIKEY}、{USER}会被替换成实际的值
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
:param url: 请求地址
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = url.replace("{HOST}", self._host) \
|
||||
.replace("{APIKEY}", self._apikey) \
|
||||
.replace("{USER}", self.user)
|
||||
url = url.replace("[HOST]", self._host) \
|
||||
.replace("[APIKEY]", self._apikey) \
|
||||
.replace("[USER]", self.user)
|
||||
try:
|
||||
return RequestUtils().get_res(url=url)
|
||||
return RequestUtils(content_type="application/json").get_res(url=url)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Emby出错:" + str(e))
|
||||
return None
|
||||
|
||||
def post_data(self, url: str, data: str = None, headers: dict = None) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
:param url: 请求地址
|
||||
:param data: 请求数据
|
||||
:param headers: 请求头
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = url.replace("[HOST]", self._host) \
|
||||
.replace("[APIKEY]", self._apikey) \
|
||||
.replace("[USER]", self.user)
|
||||
try:
|
||||
return RequestUtils(
|
||||
headers=headers,
|
||||
).post_res(url=url, data=data)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Emby出错:" + str(e))
|
||||
return None
|
||||
|
||||
@@ -329,7 +329,11 @@ class FanartModule(_ModuleBase):
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
|
||||
else:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||
if mediainfo.tvdb_id:
|
||||
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 没有tvdbid,无法获取Fanart图片")
|
||||
return
|
||||
if not result or result.get('status') == 'error':
|
||||
logger.warn(f"没有获取到 {mediainfo.title_year} 的Fanart图片数据")
|
||||
return
|
||||
@@ -351,6 +355,7 @@ class FanartModule(_ModuleBase):
|
||||
# 季图片格式 seasonxx-poster
|
||||
image_name = f"season{str(image_season).rjust(2, '0')}-{image_name[6:]}"
|
||||
if not mediainfo.get_image(image_name):
|
||||
# 没有图片才设置
|
||||
mediainfo.set_image(image_name, image_obj.get('url'))
|
||||
|
||||
return mediainfo
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.schemas import TransferInfo, ExistMediaInfo
|
||||
from app.schemas import TransferInfo, ExistMediaInfo, TmdbEpisode
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
@@ -30,7 +30,8 @@ class FileTransferModule(_ModuleBase):
|
||||
pass
|
||||
|
||||
def transfer(self, path: Path, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transfer_type: str, target: Path = None) -> TransferInfo:
|
||||
transfer_type: str, target: Path = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> TransferInfo:
|
||||
"""
|
||||
文件转移
|
||||
:param path: 文件路径
|
||||
@@ -38,24 +39,27 @@ class FileTransferModule(_ModuleBase):
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移方式
|
||||
:param target: 目标路径
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
# 获取目标路径
|
||||
if not target:
|
||||
target = self.get_target_path(in_path=path)
|
||||
else:
|
||||
elif not target.exists() or target.is_file():
|
||||
# 目的路径不存在或者是文件时,找对应的媒体库目录
|
||||
target = self.get_library_path(target)
|
||||
if not target:
|
||||
logger.error("未找到媒体库目录,无法转移文件")
|
||||
return TransferInfo(success=False,
|
||||
path=path,
|
||||
message="未找到媒体库目录,无法转移文件")
|
||||
message="未找到媒体库目录")
|
||||
# 转移
|
||||
return self.transfer_media(in_path=path,
|
||||
in_meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type,
|
||||
target_dir=target)
|
||||
target_dir=target,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
@staticmethod
|
||||
def __transfer_command(file_item: Path, target_file: Path, transfer_type: str) -> int:
|
||||
@@ -77,6 +81,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)
|
||||
@@ -355,6 +365,7 @@ class FileTransferModule(_ModuleBase):
|
||||
mediainfo: MediaInfo,
|
||||
transfer_type: str,
|
||||
target_dir: Path,
|
||||
episodes_info: List[TmdbEpisode] = None
|
||||
) -> TransferInfo:
|
||||
"""
|
||||
识别并转移一个文件或者一个目录下的所有文件
|
||||
@@ -363,6 +374,7 @@ class FileTransferModule(_ModuleBase):
|
||||
:param mediainfo: 媒体信息
|
||||
:param target_dir: 媒体库根目录
|
||||
:param transfer_type: 文件转移方式
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:return: TransferInfo、错误信息
|
||||
"""
|
||||
# 检查目录路径
|
||||
@@ -371,10 +383,12 @@ 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} 目标路径不存在")
|
||||
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)
|
||||
@@ -390,6 +404,8 @@ class FileTransferModule(_ModuleBase):
|
||||
bluray_flag = SystemUtils.is_bluray_dir(in_path)
|
||||
if bluray_flag:
|
||||
logger.info(f"{in_path} 是蓝光原盘文件夹")
|
||||
# 原文件大小
|
||||
file_size = in_path.stat().st_size
|
||||
# 目的路径
|
||||
new_path = self.get_rename_path(
|
||||
path=target_dir,
|
||||
@@ -404,7 +420,7 @@ class FileTransferModule(_ModuleBase):
|
||||
if retcode != 0:
|
||||
logger.error(f"文件夹 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(success=False,
|
||||
message=f"文件夹 {in_path} 转移失败,错误码:{retcode}",
|
||||
message=f"错误码:{retcode}",
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
is_bluray=bluray_flag)
|
||||
@@ -414,21 +430,28 @@ class FileTransferModule(_ModuleBase):
|
||||
return TransferInfo(success=True,
|
||||
path=in_path,
|
||||
target_path=new_path,
|
||||
total_size=new_path.stat().st_size,
|
||||
total_size=file_size,
|
||||
is_bluray=bluray_flag)
|
||||
else:
|
||||
# 转移单个文件
|
||||
# 文件结束季为空
|
||||
in_meta.end_season = None
|
||||
if mediainfo.type == MediaType.TV:
|
||||
# 电视剧
|
||||
if in_meta.begin_episode is None:
|
||||
logger.warn(f"文件 {in_path} 转移失败:未识别到文件集数")
|
||||
return TransferInfo(success=False,
|
||||
message=f"未识别到文件集数",
|
||||
path=in_path,
|
||||
fail_list=[str(in_path)])
|
||||
|
||||
# 文件总季数为1
|
||||
if in_meta.total_season:
|
||||
in_meta.total_season = 1
|
||||
|
||||
# 文件不可能有多集
|
||||
if in_meta.total_episode > 2:
|
||||
in_meta.total_episode = 1
|
||||
in_meta.end_episode = None
|
||||
# 文件结束季为空
|
||||
in_meta.end_season = None
|
||||
# 文件总季数为1
|
||||
if in_meta.total_season:
|
||||
in_meta.total_season = 1
|
||||
# 文件不可能超过2集
|
||||
if in_meta.total_episode > 2:
|
||||
in_meta.total_episode = 1
|
||||
in_meta.end_episode = None
|
||||
|
||||
# 目的文件名
|
||||
new_file = self.get_rename_path(
|
||||
@@ -437,6 +460,7 @@ class FileTransferModule(_ModuleBase):
|
||||
rename_dict=self.__get_naming_dict(
|
||||
meta=in_meta,
|
||||
mediainfo=mediainfo,
|
||||
episodes_info=episodes_info,
|
||||
file_ext=in_path.suffix
|
||||
)
|
||||
)
|
||||
@@ -447,7 +471,8 @@ class FileTransferModule(_ModuleBase):
|
||||
if new_file.stat().st_size < in_path.stat().st_size:
|
||||
logger.info(f"目标文件已存在,但文件大小更小,将覆盖:{new_file}")
|
||||
overflag = True
|
||||
|
||||
# 原文件大小
|
||||
file_size = in_path.stat().st_size
|
||||
# 转移文件
|
||||
retcode = self.__transfer_file(file_item=in_path,
|
||||
new_file=new_file,
|
||||
@@ -456,7 +481,7 @@ class FileTransferModule(_ModuleBase):
|
||||
if retcode != 0:
|
||||
logger.error(f"文件 {in_path} 转移失败,错误码:{retcode}")
|
||||
return TransferInfo(success=False,
|
||||
message=f"文件 {in_path.name} 转移失败,错误码:{retcode}",
|
||||
message=f"错误码:{retcode}",
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
fail_list=[str(in_path)])
|
||||
@@ -466,19 +491,29 @@ class FileTransferModule(_ModuleBase):
|
||||
path=in_path,
|
||||
target_path=new_file,
|
||||
file_count=1,
|
||||
total_size=new_file.stat().st_size,
|
||||
total_size=file_size,
|
||||
is_bluray=False,
|
||||
file_list=[str(in_path)],
|
||||
file_list_new=[str(new_file)])
|
||||
|
||||
@staticmethod
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None) -> dict:
|
||||
def __get_naming_dict(meta: MetaBase, mediainfo: MediaInfo, file_ext: str = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> dict:
|
||||
"""
|
||||
根据媒体信息,返回Format字典
|
||||
:param meta: 文件元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param file_ext: 文件扩展名
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
"""
|
||||
# 获取集标题
|
||||
episode_title = None
|
||||
if meta.begin_episode and episodes_info:
|
||||
for episode in episodes_info:
|
||||
if episode.episode_number == meta.begin_episode:
|
||||
episode_title = episode.name
|
||||
break
|
||||
|
||||
return {
|
||||
# 标题
|
||||
"title": mediainfo.title,
|
||||
@@ -490,14 +525,16 @@ class FileTransferModule(_ModuleBase):
|
||||
"name": meta.name,
|
||||
# 年份
|
||||
"year": mediainfo.year or meta.year,
|
||||
# 资源类型
|
||||
"resourceType": meta.resource_type,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 版本
|
||||
"edition": meta.edition,
|
||||
# 分辨率
|
||||
"videoFormat": meta.resource_pix,
|
||||
# 制作组/字幕组
|
||||
"releaseGroup": meta.resource_team,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 视频编码
|
||||
"videoCodec": meta.video_encode,
|
||||
# 音频编码
|
||||
@@ -514,8 +551,12 @@ class FileTransferModule(_ModuleBase):
|
||||
"season_episode": "%s%s" % (meta.season, meta.episodes),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 剧集标题
|
||||
"episode_title": episode_title,
|
||||
# 文件后缀
|
||||
"fileExt": file_ext
|
||||
"fileExt": file_ext,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -536,7 +577,7 @@ class FileTransferModule(_ModuleBase):
|
||||
@staticmethod
|
||||
def get_library_path(path: Path):
|
||||
"""
|
||||
根据目录查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
根据文件路径查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
@@ -613,9 +654,10 @@ class FileTransferModule(_ModuleBase):
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
if mediainfo.type == MediaType.TV else settings.MOVIE_RENAME_FORMAT
|
||||
# 相对路径
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
rel_path = self.get_rename_path(
|
||||
template_string=rename_format,
|
||||
rename_dict=self.__get_naming_dict(meta=MetaInfo(mediainfo.title),
|
||||
rename_dict=self.__get_naming_dict(meta=meta,
|
||||
mediainfo=mediainfo)
|
||||
)
|
||||
# 取相对路径的第1层目录
|
||||
|
||||
@@ -70,16 +70,26 @@ 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\+"],
|
||||
"exclude": []
|
||||
},
|
||||
# SDR
|
||||
"SDR": {
|
||||
"include": [r"[\s.]+SDR[\s.]+"],
|
||||
"exclude": []
|
||||
},
|
||||
# 重编码
|
||||
"REMUX": {
|
||||
"include": [r'REMUX'],
|
||||
@@ -96,9 +106,24 @@ class FilterModule(_ModuleBase):
|
||||
},
|
||||
# 国语配音
|
||||
"CNVOI": {
|
||||
"include": [r'[国國][语語]配音|[国國]配'],
|
||||
"include": [r'[国國][语語]配音|[国國]配|[国國][语語]'],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
# 粤语配音
|
||||
"HKVOI": {
|
||||
"include": [r'粤语配音|粤语'],
|
||||
"exclude": []
|
||||
},
|
||||
# 60FPS
|
||||
"60FPS": {
|
||||
"include": [r'60fps'],
|
||||
"exclude": []
|
||||
},
|
||||
# 3D
|
||||
"3D": {
|
||||
"include": [r'3D'],
|
||||
"exclude": []
|
||||
},
|
||||
}
|
||||
|
||||
def init_module(self) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import List, Optional, Tuple, Union
|
||||
|
||||
from ruamel.yaml import CommentedMap
|
||||
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.core.context import TorrentInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.indexer.mtorrent import MTorrentSpider
|
||||
@@ -28,69 +28,71 @@ class IndexerModule(_ModuleBase):
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
return "INDEXER", "builtin"
|
||||
|
||||
def search_torrents(self, site: CommentedMap, mediainfo: MediaInfo = None,
|
||||
keyword: str = None, page: int = 0, area: str = "title") -> List[TorrentInfo]:
|
||||
def search_torrents(self, site: CommentedMap,
|
||||
keywords: List[str] = None,
|
||||
mtype: MediaType = None,
|
||||
page: int = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
搜索一个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param site: 站点
|
||||
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
|
||||
:param keywords: 搜索关键词列表
|
||||
:param mtype: 媒体类型
|
||||
:param page: 页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:return: 资源列表
|
||||
"""
|
||||
# 确认搜索的名字
|
||||
if keyword:
|
||||
search_word = keyword
|
||||
elif mediainfo:
|
||||
search_word = mediainfo.title
|
||||
else:
|
||||
search_word = None
|
||||
|
||||
if search_word \
|
||||
and site.get('language') == "en" \
|
||||
and StringUtils.is_chinese(search_word):
|
||||
# 不支持中文
|
||||
logger.warn(f"{site.get('name')} 不支持中文搜索")
|
||||
return []
|
||||
|
||||
# 去除搜索关键字中的特殊字符
|
||||
if search_word:
|
||||
search_word = StringUtils.clear(search_word, replace_word=" ", allow_space=True)
|
||||
if not keywords:
|
||||
# 浏览种子页
|
||||
keywords = [None]
|
||||
|
||||
# 开始索引
|
||||
result_array = []
|
||||
# 开始计时
|
||||
start_time = datetime.now()
|
||||
try:
|
||||
imdbid = mediainfo.imdb_id if mediainfo and area == "imdbid" else None
|
||||
if site.get('parser') == "TNodeSpider":
|
||||
error_flag, result_array = TNodeSpider(site).search(
|
||||
keyword=search_word,
|
||||
imdbid=imdbid,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "TorrentLeech":
|
||||
error_flag, result_array = TorrentLeech(site).search(
|
||||
keyword=search_word,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "mTorrent":
|
||||
error_flag, result_array = MTorrentSpider(site).search(
|
||||
keyword=search_word,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page
|
||||
)
|
||||
else:
|
||||
error_flag, result_array = self.__spider_search(
|
||||
keyword=search_word,
|
||||
imdbid=imdbid,
|
||||
indexer=site,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
page=page
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"{site.get('name')} 搜索出错:{err}")
|
||||
|
||||
# 搜索多个关键字
|
||||
for search_word in keywords:
|
||||
# 可能为关键字或ttxxxx
|
||||
if search_word \
|
||||
and site.get('language') == "en" \
|
||||
and StringUtils.is_chinese(search_word):
|
||||
# 不支持中文
|
||||
logger.warn(f"{site.get('name')} 不支持中文搜索")
|
||||
continue
|
||||
|
||||
# 去除搜索关键字中的特殊字符
|
||||
if search_word:
|
||||
search_word = StringUtils.clear(search_word, replace_word=" ", allow_space=True)
|
||||
|
||||
try:
|
||||
if site.get('parser') == "TNodeSpider":
|
||||
error_flag, result_array = TNodeSpider(site).search(
|
||||
keyword=search_word,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "TorrentLeech":
|
||||
error_flag, result_array = TorrentLeech(site).search(
|
||||
keyword=search_word,
|
||||
page=page
|
||||
)
|
||||
elif site.get('parser') == "mTorrent":
|
||||
error_flag, result_array = MTorrentSpider(site).search(
|
||||
keyword=search_word,
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
else:
|
||||
error_flag, result_array = self.__spider_search(
|
||||
search_word=search_word,
|
||||
indexer=site,
|
||||
mtype=mtype,
|
||||
page=page
|
||||
)
|
||||
# 有结果后停止
|
||||
if result_array:
|
||||
break
|
||||
except Exception as err:
|
||||
logger.error(f"{site.get('name')} 搜索出错:{err}")
|
||||
|
||||
# 索引花费的时间
|
||||
seconds = round((datetime.now() - start_time).seconds, 1)
|
||||
@@ -112,15 +114,13 @@ class IndexerModule(_ModuleBase):
|
||||
|
||||
@staticmethod
|
||||
def __spider_search(indexer: CommentedMap,
|
||||
keyword: str = None,
|
||||
imdbid: str = None,
|
||||
search_word: str = None,
|
||||
mtype: MediaType = None,
|
||||
page: int = 0) -> (bool, List[dict]):
|
||||
"""
|
||||
根据关键字搜索单个站点
|
||||
:param: indexer: 站点配置
|
||||
:param: keyword: 关键字
|
||||
:param: imdbid: imdbid
|
||||
:param: search_word: 关键字
|
||||
:param: page: 页码
|
||||
:param: mtype: 媒体类型
|
||||
:param: timeout: 超时时间
|
||||
@@ -128,8 +128,7 @@ class IndexerModule(_ModuleBase):
|
||||
"""
|
||||
_spider = TorrentSpider(indexer=indexer,
|
||||
mtype=mtype,
|
||||
keyword=keyword,
|
||||
imdbid=imdbid,
|
||||
keyword=search_word,
|
||||
page=page)
|
||||
|
||||
return _spider.is_error, _spider.get_torrents()
|
||||
|
||||
@@ -40,8 +40,6 @@ class TorrentSpider:
|
||||
referer: str = None
|
||||
# 搜索关键字
|
||||
keyword: str = None
|
||||
# 搜索IMDBID
|
||||
imdbid: str = None
|
||||
# 媒体类型
|
||||
mtype: MediaType = None
|
||||
# 搜索路径、方式配置
|
||||
@@ -68,7 +66,6 @@ class TorrentSpider:
|
||||
def __init__(self,
|
||||
indexer: CommentedMap,
|
||||
keyword: [str, list] = None,
|
||||
imdbid: str = None,
|
||||
page: int = 0,
|
||||
referer: str = None,
|
||||
mtype: MediaType = None):
|
||||
@@ -76,7 +73,6 @@ class TorrentSpider:
|
||||
设置查询参数
|
||||
:param indexer: 索引器
|
||||
:param keyword: 搜索关键字,如果数组则为批量搜索
|
||||
:param imdbid: IMDB ID
|
||||
:param page: 页码
|
||||
:param referer: Referer
|
||||
:param mtype: 媒体类型
|
||||
@@ -84,7 +80,6 @@ class TorrentSpider:
|
||||
if not indexer:
|
||||
return
|
||||
self.keyword = keyword
|
||||
self.imdbid = imdbid
|
||||
self.mtype = mtype
|
||||
self.indexerid = indexer.get('id')
|
||||
self.indexername = indexer.get('name')
|
||||
@@ -159,20 +154,17 @@ class TorrentSpider:
|
||||
# 搜索URL
|
||||
indexer_params = self.search.get("params") or {}
|
||||
if indexer_params:
|
||||
# 支持IMDBID时优先使用IMDBID搜索
|
||||
search_area = indexer_params.get("search_area") or 0
|
||||
if self.imdbid and search_area:
|
||||
search_word = self.imdbid
|
||||
else:
|
||||
search_word = self.keyword
|
||||
# 不启用IMDBID搜索时需要将search_area移除
|
||||
if search_area:
|
||||
indexer_params.pop('search_area')
|
||||
search_area = indexer_params.get('search_area')
|
||||
# search_area非0表示支持imdbid搜索
|
||||
if (search_area and
|
||||
(not self.keyword or not self.keyword.startswith('tt'))):
|
||||
# 支持imdbid搜索,但关键字不是imdbid时,不启用imdbid搜索
|
||||
indexer_params.pop('search_area')
|
||||
# 变量字典
|
||||
inputs_dict = {
|
||||
"keyword": search_word
|
||||
}
|
||||
# 查询参数
|
||||
# 查询参数,默认查询标题
|
||||
params = {
|
||||
"search_mode": search_mode,
|
||||
"search_area": 0,
|
||||
|
||||
@@ -49,16 +49,16 @@ class TNodeSpider:
|
||||
if csrf_token:
|
||||
self._token = csrf_token.group(1)
|
||||
|
||||
def search(self, keyword: str, imdbid: str = None, page: int = 0) -> Tuple[bool, List[dict]]:
|
||||
def search(self, keyword: str, page: int = 0) -> Tuple[bool, List[dict]]:
|
||||
if not self._token:
|
||||
logger.warn(f"{self._name} 未获取到token,无法搜索")
|
||||
return True, []
|
||||
search_type = "imdbid" if imdbid else "title"
|
||||
search_type = "imdbid" if (keyword and keyword.startswith('tt')) else "title"
|
||||
params = {
|
||||
"page": int(page) + 1,
|
||||
"size": self._size,
|
||||
"type": search_type,
|
||||
"keyword": imdbid or keyword or "",
|
||||
"keyword": keyword or "",
|
||||
"sorter": "id",
|
||||
"order": "desc",
|
||||
"tags": [],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple, Union, Any, List, Generator
|
||||
|
||||
@@ -7,7 +6,6 @@ from app.core.context import MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.jellyfin.jellyfin import Jellyfin
|
||||
from app.schemas import ExistMediaInfo, WebhookEventInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
@@ -26,7 +24,7 @@ class JellyfinModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.jellyfin.is_inactive():
|
||||
self.jellyfin = Jellyfin()
|
||||
self.jellyfin.reconnect()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
@@ -41,7 +39,7 @@ class JellyfinModule(_ModuleBase):
|
||||
# Jellyfin认证
|
||||
return self.jellyfin.authenticate(name, password)
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -51,7 +49,7 @@ class JellyfinModule(_ModuleBase):
|
||||
"""
|
||||
return self.jellyfin.get_webhook_message(body)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[schemas.ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -63,25 +61,38 @@ class JellyfinModule(_ModuleBase):
|
||||
movie = self.jellyfin.get_iteminfo(itemid)
|
||||
if movie:
|
||||
logger.info(f"媒体库中已存在:{movie}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="jellyfin",
|
||||
itemid=movie.item_id
|
||||
)
|
||||
movies = self.jellyfin.get_movies(title=mediainfo.title, year=mediainfo.year, tmdb_id=mediainfo.tmdb_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"媒体库中已存在:{movies}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="jellyfin",
|
||||
itemid=movies[0].item_id
|
||||
)
|
||||
else:
|
||||
tvs = self.jellyfin.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
itemid, tvs = self.jellyfin.get_tv_episodes(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons=tvs,
|
||||
server="jellyfin",
|
||||
itemid=itemid
|
||||
)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
@@ -97,13 +108,8 @@ class JellyfinModule(_ModuleBase):
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.jellyfin.get_medias_count()
|
||||
user_count = self.jellyfin.get_user_count()
|
||||
return [schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=user_count or 0
|
||||
)]
|
||||
media_statistic.user_count = self.jellyfin.get_user_count()
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
@@ -111,16 +117,7 @@ class JellyfinModule(_ModuleBase):
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
librarys = self.jellyfin.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
return [schemas.MediaServerLibrary(
|
||||
server="jellyfin",
|
||||
id=library.get("id"),
|
||||
name=library.get("name"),
|
||||
type=library.get("type"),
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
return self.jellyfin.get_librarys()
|
||||
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
@@ -128,21 +125,15 @@ class JellyfinModule(_ModuleBase):
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
items = self.jellyfin.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("library"),
|
||||
item_id=item.get("id"),
|
||||
item_type=item.get("type"),
|
||||
title=item.get("title"),
|
||||
original_title=item.get("original_title"),
|
||||
year=item.get("year"),
|
||||
tmdbid=item.get("tmdbid"),
|
||||
imdbid=item.get("imdbid"),
|
||||
tvdbid=item.get("tvdbid"),
|
||||
path=item.get("path"),
|
||||
)
|
||||
return self.jellyfin.get_items(library_id)
|
||||
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
return self.jellyfin.get_iteminfo(item_id)
|
||||
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
@@ -151,7 +142,7 @@ class JellyfinModule(_ModuleBase):
|
||||
"""
|
||||
if server != "jellyfin":
|
||||
return None
|
||||
seasoninfo = self.jellyfin.get_tv_episodes(item_id=item_id)
|
||||
_, seasoninfo = self.jellyfin.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
return [schemas.MediaServerSeasonInfo(
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import json
|
||||
import re
|
||||
from typing import List, Union, Optional, Dict, Generator
|
||||
from typing import List, Union, Optional, Dict, Generator, Tuple
|
||||
|
||||
from requests import Response
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import MediaType, WebhookEventInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class Jellyfin(metaclass=Singleton):
|
||||
@@ -22,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:
|
||||
@@ -33,6 +32,13 @@ class Jellyfin(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.user else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.user = self.get_user()
|
||||
self.serverid = self.get_server_id()
|
||||
|
||||
def __get_jellyfin_librarys(self) -> List[dict]:
|
||||
"""
|
||||
获取Jellyfin媒体库的信息
|
||||
@@ -66,12 +72,14 @@ class Jellyfin(metaclass=Singleton):
|
||||
library_type = MediaType.TV.value
|
||||
case _:
|
||||
continue
|
||||
libraries.append({
|
||||
"id": library.get("Id"),
|
||||
"name": library.get("Name"),
|
||||
"path": library.get("Path"),
|
||||
"type": library_type
|
||||
})
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
server="jellyfin",
|
||||
id=library.get("Id"),
|
||||
name=library.get("Name"),
|
||||
path=library.get("Path"),
|
||||
type=library_type
|
||||
))
|
||||
return libraries
|
||||
|
||||
def get_user_count(self) -> int:
|
||||
@@ -172,59 +180,29 @@ class Jellyfin(metaclass=Singleton):
|
||||
logger.error(f"连接System/Info出错:" + str(e))
|
||||
return None
|
||||
|
||||
def get_activity_log(self, num: int = 30) -> List[dict]:
|
||||
"""
|
||||
获取Jellyfin活动记录
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return []
|
||||
req_url = "%sSystem/ActivityLog/Entries?api_key=%s&Limit=%s" % (self._host, self._apikey, num)
|
||||
ret_array = []
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
ret_json = res.json()
|
||||
items = ret_json.get('Items')
|
||||
for item in items:
|
||||
if item.get("Type") == "SessionStarted":
|
||||
event_type = "LG"
|
||||
event_date = re.sub(r'\dZ', 'Z', item.get("Date"))
|
||||
event_str = "%s, %s" % (item.get("Name"), item.get("ShortOverview"))
|
||||
activity = {"type": event_type, "event": event_str,
|
||||
"date": StringUtils.get_time(event_date)}
|
||||
ret_array.append(activity)
|
||||
if item.get("Type") in ["VideoPlayback", "VideoPlaybackStopped"]:
|
||||
event_type = "PL"
|
||||
event_date = re.sub(r'\dZ', 'Z', item.get("Date"))
|
||||
activity = {"type": event_type, "event": item.get("Name"),
|
||||
"date": StringUtils.get_time(event_date)}
|
||||
ret_array.append(activity)
|
||||
else:
|
||||
logger.error(f"System/ActivityLog/Entries 未获取到返回数据")
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"连接System/ActivityLog/Entries出错:" + str(e))
|
||||
return []
|
||||
return ret_array
|
||||
|
||||
def get_medias_count(self) -> Optional[dict]:
|
||||
def get_medias_count(self) -> schemas.Statistic:
|
||||
"""
|
||||
获得电影、电视剧、动漫媒体数量
|
||||
:return: MovieCount SeriesCount SongCount
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
return schemas.Statistic()
|
||||
req_url = "%sItems/Counts?api_key=%s" % (self._host, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res:
|
||||
return res.json()
|
||||
result = res.json()
|
||||
return schemas.Statistic(
|
||||
movie_count=result.get("MovieCount") or 0,
|
||||
tv_count=result.get("SeriesCount") or 0,
|
||||
episode_count=result.get("EpisodeCount") or 0
|
||||
)
|
||||
else:
|
||||
logger.error(f"Items/Counts 未获取到返回数据")
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items/Counts出错:" + str(e))
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
|
||||
def __get_jellyfin_series_id_by_name(self, name: str, year: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -232,7 +210,8 @@ class Jellyfin(metaclass=Singleton):
|
||||
"""
|
||||
if not self._host or not self._apikey or not self.user:
|
||||
return None
|
||||
req_url = "%sUsers/%s/Items?api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true" % (
|
||||
req_url = ("%sUsers/%s/Items?"
|
||||
"api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true") % (
|
||||
self._host, self.user, self._apikey, name)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
@@ -251,7 +230,7 @@ class Jellyfin(metaclass=Singleton):
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
year: str = None,
|
||||
tmdb_id: int = None) -> Optional[List[dict]]:
|
||||
tmdb_id: int = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Jellyfin中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
@@ -261,7 +240,8 @@ class Jellyfin(metaclass=Singleton):
|
||||
"""
|
||||
if not self._host or not self._apikey or not self.user:
|
||||
return None
|
||||
req_url = "%sUsers/%s/Items?api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true" % (
|
||||
req_url = ("%sUsers/%s/Items?"
|
||||
"api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true") % (
|
||||
self._host, self.user, self._apikey, title)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
@@ -269,19 +249,30 @@ class Jellyfin(metaclass=Singleton):
|
||||
res_items = res.json().get("Items")
|
||||
if res_items:
|
||||
ret_movies = []
|
||||
for res_item in res_items:
|
||||
item_tmdbid = res_item.get("ProviderIds", {}).get("Tmdb")
|
||||
for item in res_items:
|
||||
item_tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
mediaserver_item = schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(item_tmdbid) if item_tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(item_tmdbid) != str(tmdb_id):
|
||||
continue
|
||||
else:
|
||||
ret_movies.append(
|
||||
{'title': res_item.get('Name'), 'year': str(res_item.get('ProductionYear'))})
|
||||
ret_movies.append(mediaserver_item)
|
||||
continue
|
||||
if res_item.get('Name') == title and (
|
||||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||||
ret_movies.append(
|
||||
{'title': res_item.get('Name'), 'year': str(res_item.get('ProductionYear'))})
|
||||
if mediaserver_item.title == title and (
|
||||
not year or str(mediaserver_item.year) == str(year)):
|
||||
ret_movies.append(mediaserver_item)
|
||||
return ret_movies
|
||||
except Exception as e:
|
||||
logger.error(f"连接Items出错:" + str(e))
|
||||
@@ -293,7 +284,7 @@ class Jellyfin(metaclass=Singleton):
|
||||
title: str = None,
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None) -> Optional[Dict[int, list]]:
|
||||
season: int = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
|
||||
"""
|
||||
根据标题和年份和季,返回Jellyfin中的剧集列表
|
||||
:param item_id: Jellyfin中的Id
|
||||
@@ -304,19 +295,20 @@ class Jellyfin(metaclass=Singleton):
|
||||
:return: 集号的列表
|
||||
"""
|
||||
if not self._host or not self._apikey or not self.user:
|
||||
return None
|
||||
return None, None
|
||||
# 查TVID
|
||||
if not item_id:
|
||||
item_id = self.__get_jellyfin_series_id_by_name(title, year)
|
||||
if item_id is None:
|
||||
return None
|
||||
return None, None
|
||||
if not item_id:
|
||||
return {}
|
||||
return None, {}
|
||||
# 验证tmdbid是否相同
|
||||
item_tmdbid = (self.get_iteminfo(item_id).get("ProviderIds") or {}).get("Tmdb")
|
||||
if tmdb_id and item_tmdbid:
|
||||
if str(tmdb_id) != str(item_tmdbid):
|
||||
return {}
|
||||
item_info = self.get_iteminfo(item_id)
|
||||
if item_info:
|
||||
if tmdb_id and item_info.tmdbid:
|
||||
if str(tmdb_id) != str(item_info.tmdbid):
|
||||
return None, {}
|
||||
if not season:
|
||||
season = ""
|
||||
try:
|
||||
@@ -324,7 +316,8 @@ class Jellyfin(metaclass=Singleton):
|
||||
self._host, item_id, season, self.user, self._apikey)
|
||||
res_json = RequestUtils().get_res(req_url)
|
||||
if res_json:
|
||||
res_items = res_json.json().get("Items")
|
||||
tv_info = res_json.json()
|
||||
res_items = tv_info.get("Items")
|
||||
# 返回的季集信息
|
||||
season_episodes = {}
|
||||
for res_item in res_items:
|
||||
@@ -339,11 +332,11 @@ class Jellyfin(metaclass=Singleton):
|
||||
if not season_episodes.get(season_index):
|
||||
season_episodes[season_index] = []
|
||||
season_episodes[season_index].append(episode_index)
|
||||
return season_episodes
|
||||
return tv_info.get('Id'), season_episodes
|
||||
except Exception as e:
|
||||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||||
return None
|
||||
return {}
|
||||
return None, None
|
||||
return None, {}
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -387,7 +380,7 @@ class Jellyfin(metaclass=Singleton):
|
||||
logger.error(f"连接Library/Refresh出错:" + str(e))
|
||||
return False
|
||||
|
||||
def get_webhook_message(self, body: any) -> Optional[WebhookEventInfo]:
|
||||
def get_webhook_message(self, body: any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Jellyfin报文
|
||||
{
|
||||
@@ -459,11 +452,11 @@ class Jellyfin(metaclass=Singleton):
|
||||
return None
|
||||
if not message:
|
||||
return None
|
||||
logger.info(f"接收到jellyfin webhook:{message}")
|
||||
logger.debug(f"接收到jellyfin webhook:{message}")
|
||||
eventType = message.get('NotificationType')
|
||||
if not eventType:
|
||||
return None
|
||||
eventItem = WebhookEventInfo(
|
||||
eventItem = schemas.WebhookEventInfo(
|
||||
event=eventType,
|
||||
channel="jellyfin"
|
||||
)
|
||||
@@ -499,32 +492,46 @@ class Jellyfin(metaclass=Singleton):
|
||||
|
||||
return eventItem
|
||||
|
||||
def get_iteminfo(self, itemid: str) -> dict:
|
||||
def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取单个项目详情
|
||||
"""
|
||||
if not itemid:
|
||||
return {}
|
||||
return None
|
||||
if not self._host or not self._apikey:
|
||||
return {}
|
||||
return None
|
||||
req_url = "%sUsers/%s/Items/%s?api_key=%s" % (
|
||||
self._host, self.user, itemid, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
if res and res.status_code == 200:
|
||||
return res.json()
|
||||
item = res.json()
|
||||
tmdbid = item.get("ProviderIds", {}).get("Tmdb")
|
||||
return schemas.MediaServerItem(
|
||||
server="jellyfin",
|
||||
library=item.get("ParentId"),
|
||||
item_id=item.get("Id"),
|
||||
item_type=item.get("Type"),
|
||||
title=item.get("Name"),
|
||||
original_title=item.get("OriginalTitle"),
|
||||
year=item.get("ProductionYear"),
|
||||
tmdbid=int(tmdbid) if tmdbid else None,
|
||||
imdbid=item.get("ProviderIds", {}).get("Imdb"),
|
||||
tvdbid=item.get("ProviderIds", {}).get("Tvdb"),
|
||||
path=item.get("Path")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
return {}
|
||||
return None
|
||||
|
||||
def get_items(self, parent: str) -> Generator:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
if not parent:
|
||||
yield {}
|
||||
yield None
|
||||
if not self._host or not self._apikey:
|
||||
yield {}
|
||||
yield None
|
||||
req_url = "%sUsers/%s/Items?parentId=%s&api_key=%s" % (self._host, self.user, parent, self._apikey)
|
||||
try:
|
||||
res = RequestUtils().get_res(req_url)
|
||||
@@ -534,37 +541,46 @@ class Jellyfin(metaclass=Singleton):
|
||||
if not result:
|
||||
continue
|
||||
if result.get("Type") in ["Movie", "Series"]:
|
||||
item_info = self.get_iteminfo(result.get("Id"))
|
||||
yield {"id": result.get("Id"),
|
||||
"library": item_info.get("ParentId"),
|
||||
"type": item_info.get("Type"),
|
||||
"title": item_info.get("Name"),
|
||||
"original_title": item_info.get("OriginalTitle"),
|
||||
"year": item_info.get("ProductionYear"),
|
||||
"tmdbid": item_info.get("ProviderIds", {}).get("Tmdb"),
|
||||
"imdbid": item_info.get("ProviderIds", {}).get("Imdb"),
|
||||
"tvdbid": item_info.get("ProviderIds", {}).get("Tvdb"),
|
||||
"path": item_info.get("Path"),
|
||||
"json": str(item_info)}
|
||||
yield self.get_iteminfo(result.get("Id"))
|
||||
elif "Folder" in result.get("Type"):
|
||||
for item in self.get_items(result.get("Id")):
|
||||
yield item
|
||||
except Exception as e:
|
||||
logger.error(f"连接Users/Items出错:" + str(e))
|
||||
yield {}
|
||||
yield None
|
||||
|
||||
def get_data(self, url: str) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中{HOST}、{APIKEY}、{USER}会被替换成实际的值
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
:param url: 请求地址
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = url.replace("{HOST}", self._host) \
|
||||
.replace("{APIKEY}", self._apikey) \
|
||||
.replace("{USER}", self.user)
|
||||
url = url.replace("[HOST]", self._host) \
|
||||
.replace("[APIKEY]", self._apikey) \
|
||||
.replace("[USER]", self.user)
|
||||
try:
|
||||
return RequestUtils().get_res(url=url)
|
||||
return RequestUtils(accept_type="application/json").get_res(url=url)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Jellyfin出错:" + str(e))
|
||||
return None
|
||||
|
||||
def post_data(self, url: str, data: str = None, headers: dict = None) -> Optional[Response]:
|
||||
"""
|
||||
自定义URL从媒体服务器获取数据,其中[HOST]、[APIKEY]、[USER]会被替换成实际的值
|
||||
:param url: 请求地址
|
||||
:param data: 请求数据
|
||||
:param headers: 请求头
|
||||
"""
|
||||
if not self._host or not self._apikey:
|
||||
return None
|
||||
url = url.replace("[HOST]", self._host) \
|
||||
.replace("[APIKEY]", self._apikey) \
|
||||
.replace("[USER]", self.user)
|
||||
try:
|
||||
return RequestUtils(
|
||||
headers=headers
|
||||
).post_res(url=url, data=data)
|
||||
except Exception as e:
|
||||
logger.error(f"连接Jellyfin出错:" + str(e))
|
||||
return None
|
||||
|
||||
@@ -6,12 +6,10 @@ from app.core.context import MediaInfo
|
||||
from app.log import logger
|
||||
from app.modules import _ModuleBase
|
||||
from app.modules.plex.plex import Plex
|
||||
from app.schemas import ExistMediaInfo, RefreshMediaItem, WebhookEventInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
class PlexModule(_ModuleBase):
|
||||
|
||||
plex: Plex = None
|
||||
|
||||
def init_module(self) -> None:
|
||||
@@ -29,9 +27,9 @@ class PlexModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.plex.is_inactive():
|
||||
self.plex = Plex()
|
||||
self.plex.reconnect()
|
||||
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[WebhookEventInfo]:
|
||||
def webhook_parser(self, body: Any, form: Any, args: Any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Webhook报文体
|
||||
:param body: 请求体
|
||||
@@ -41,7 +39,7 @@ class PlexModule(_ModuleBase):
|
||||
"""
|
||||
return self.plex.get_webhook_message(form)
|
||||
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[ExistMediaInfo]:
|
||||
def media_exists(self, mediainfo: MediaInfo, itemid: str = None) -> Optional[schemas.ExistMediaInfo]:
|
||||
"""
|
||||
判断媒体文件是否存在
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -53,29 +51,42 @@ class PlexModule(_ModuleBase):
|
||||
movie = self.plex.get_iteminfo(itemid)
|
||||
if movie:
|
||||
logger.info(f"媒体库中已存在:{movie}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="plex",
|
||||
itemid=movie.item_id
|
||||
)
|
||||
movies = self.plex.get_movies(title=mediainfo.title,
|
||||
original_title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
original_title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id)
|
||||
if not movies:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"媒体库中已存在:{movies}")
|
||||
return ExistMediaInfo(type=MediaType.MOVIE)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
server="plex",
|
||||
itemid=movies[0].item_id
|
||||
)
|
||||
else:
|
||||
tvs = self.plex.get_tv_episodes(title=mediainfo.title,
|
||||
original_title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
item_id, tvs = self.plex.get_tv_episodes(title=mediainfo.title,
|
||||
original_title=mediainfo.original_title,
|
||||
year=mediainfo.year,
|
||||
tmdb_id=mediainfo.tmdb_id,
|
||||
item_id=itemid)
|
||||
if not tvs:
|
||||
logger.info(f"{mediainfo.title_year} 在媒体库中不存在")
|
||||
return None
|
||||
else:
|
||||
logger.info(f"{mediainfo.title_year} 媒体库中已存在:{tvs}")
|
||||
return ExistMediaInfo(type=MediaType.TV, seasons=tvs)
|
||||
return schemas.ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons=tvs,
|
||||
server="plex",
|
||||
itemid=item_id
|
||||
)
|
||||
|
||||
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: Path) -> None:
|
||||
"""
|
||||
@@ -85,7 +96,7 @@ class PlexModule(_ModuleBase):
|
||||
:return: 成功或失败
|
||||
"""
|
||||
items = [
|
||||
RefreshMediaItem(
|
||||
schemas.RefreshMediaItem(
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
type=mediainfo.type,
|
||||
@@ -100,12 +111,8 @@ class PlexModule(_ModuleBase):
|
||||
媒体数量统计
|
||||
"""
|
||||
media_statistic = self.plex.get_medias_count()
|
||||
return [schemas.Statistic(
|
||||
movie_count=media_statistic.get("MovieCount") or 0,
|
||||
tv_count=media_statistic.get("SeriesCount") or 0,
|
||||
episode_count=media_statistic.get("EpisodeCount") or 0,
|
||||
user_count=1
|
||||
)]
|
||||
media_statistic.user_count = 1
|
||||
return [media_statistic]
|
||||
|
||||
def mediaserver_librarys(self, server: str) -> Optional[List[schemas.MediaServerLibrary]]:
|
||||
"""
|
||||
@@ -113,16 +120,7 @@ class PlexModule(_ModuleBase):
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
librarys = self.plex.get_librarys()
|
||||
if not librarys:
|
||||
return []
|
||||
return [schemas.MediaServerLibrary(
|
||||
server="plex",
|
||||
id=library.get("id"),
|
||||
name=library.get("name"),
|
||||
type=library.get("type"),
|
||||
path=library.get("path")
|
||||
) for library in librarys]
|
||||
return self.plex.get_librarys()
|
||||
|
||||
def mediaserver_items(self, server: str, library_id: str) -> Optional[Generator]:
|
||||
"""
|
||||
@@ -130,21 +128,15 @@ class PlexModule(_ModuleBase):
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
items = self.plex.get_items(library_id)
|
||||
for item in items:
|
||||
yield schemas.MediaServerItem(
|
||||
server="plex",
|
||||
library=item.get("library"),
|
||||
item_id=item.get("id"),
|
||||
item_type=item.get("type"),
|
||||
title=item.get("title"),
|
||||
original_title=item.get("original_title"),
|
||||
year=item.get("year"),
|
||||
tmdbid=item.get("tmdbid"),
|
||||
imdbid=item.get("imdbid"),
|
||||
tvdbid=item.get("tvdbid"),
|
||||
path=item.get("path"),
|
||||
)
|
||||
return self.plex.get_items(library_id)
|
||||
|
||||
def mediaserver_iteminfo(self, server: str, item_id: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
媒体库项目详情
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
return self.plex.get_iteminfo(item_id)
|
||||
|
||||
def mediaserver_tv_episodes(self, server: str,
|
||||
item_id: Union[str, int]) -> Optional[List[schemas.MediaServerSeasonInfo]]:
|
||||
@@ -153,7 +145,7 @@ class PlexModule(_ModuleBase):
|
||||
"""
|
||||
if server != "plex":
|
||||
return None
|
||||
seasoninfo = self.plex.get_tv_episodes(item_id=item_id)
|
||||
_, seasoninfo = self.plex.get_tv_episodes(item_id=item_id)
|
||||
if not seasoninfo:
|
||||
return []
|
||||
return [schemas.MediaServerSeasonInfo(
|
||||
|
||||
@@ -6,9 +6,10 @@ from urllib.parse import quote_plus
|
||||
from plexapi import media
|
||||
from plexapi.server import PlexServer
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import RefreshMediaItem, MediaType, WebhookEventInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
@@ -38,7 +39,18 @@ class Plex(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self._plex else False
|
||||
|
||||
def get_librarys(self):
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
try:
|
||||
self._plex = PlexServer(self._host, self._token)
|
||||
self._libraries = self._plex.library.sections()
|
||||
except Exception as e:
|
||||
self._plex = None
|
||||
logger.error(f"Plex服务器连接失败:{str(e)}")
|
||||
|
||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
@@ -58,81 +70,42 @@ class Plex(metaclass=Singleton):
|
||||
library_type = MediaType.TV.value
|
||||
case _:
|
||||
continue
|
||||
libraries.append({
|
||||
"id": library.key,
|
||||
"name": library.title,
|
||||
"path": library.locations,
|
||||
"type": library_type
|
||||
})
|
||||
libraries.append(
|
||||
schemas.MediaServerLibrary(
|
||||
id=library.key,
|
||||
name=library.title,
|
||||
path=library.locations,
|
||||
type=library_type
|
||||
)
|
||||
)
|
||||
return libraries
|
||||
|
||||
def get_activity_log(self, num: int = 30) -> Optional[List[dict]]:
|
||||
"""
|
||||
获取Plex活动记录
|
||||
"""
|
||||
if not self._plex:
|
||||
return []
|
||||
ret_array = []
|
||||
try:
|
||||
# type的含义: 1 电影 4 剧集单集 详见 plexapi/utils.py中SEARCHTYPES的定义
|
||||
# 根据最后播放时间倒序获取数据
|
||||
historys = self._plex.library.search(sort='lastViewedAt:desc', limit=num, type='1,4')
|
||||
for his in historys:
|
||||
# 过滤掉最后播放时间为空的
|
||||
if his.lastViewedAt:
|
||||
if his.type == "episode":
|
||||
event_title = "%s %s%s %s" % (
|
||||
his.grandparentTitle,
|
||||
"S" + str(his.parentIndex),
|
||||
"E" + str(his.index),
|
||||
his.title
|
||||
)
|
||||
event_str = "开始播放剧集 %s" % event_title
|
||||
else:
|
||||
event_title = "%s %s" % (
|
||||
his.title, "(" + str(his.year) + ")")
|
||||
event_str = "开始播放电影 %s" % event_title
|
||||
|
||||
event_type = "PL"
|
||||
event_date = his.lastViewedAt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||||
ret_array.append(activity)
|
||||
except Exception as e:
|
||||
logger.error(f"连接System/ActivityLog/Entries出错:" + str(e))
|
||||
return []
|
||||
if ret_array:
|
||||
ret_array = sorted(ret_array, key=lambda x: x['date'], reverse=True)
|
||||
return ret_array
|
||||
|
||||
def get_medias_count(self) -> dict:
|
||||
def get_medias_count(self) -> schemas.Statistic:
|
||||
"""
|
||||
获得电影、电视剧、动漫媒体数量
|
||||
:return: MovieCount SeriesCount SongCount
|
||||
"""
|
||||
if not self._plex:
|
||||
return {}
|
||||
return schemas.Statistic()
|
||||
sections = self._plex.library.sections()
|
||||
MovieCount = SeriesCount = SongCount = EpisodeCount = 0
|
||||
MovieCount = SeriesCount = EpisodeCount = 0
|
||||
for sec in sections:
|
||||
if sec.type == "movie":
|
||||
MovieCount += sec.totalSize
|
||||
if sec.type == "show":
|
||||
SeriesCount += sec.totalSize
|
||||
EpisodeCount += sec.totalViewSize(libtype='episode')
|
||||
if sec.type == "artist":
|
||||
SongCount += sec.totalSize
|
||||
return {
|
||||
"MovieCount": MovieCount,
|
||||
"SeriesCount": SeriesCount,
|
||||
"SongCount": SongCount,
|
||||
"EpisodeCount": EpisodeCount
|
||||
}
|
||||
return schemas.Statistic(
|
||||
movie_count=MovieCount,
|
||||
tv_count=SeriesCount,
|
||||
episode_count=EpisodeCount
|
||||
)
|
||||
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
def get_movies(self,
|
||||
title: str,
|
||||
original_title: str = None,
|
||||
year: str = None,
|
||||
tmdb_id: int = None) -> Optional[List[dict]]:
|
||||
tmdb_id: int = None) -> Optional[List[schemas.MediaServerItem]]:
|
||||
"""
|
||||
根据标题和年份,检查电影是否在Plex中存在,存在则返回列表
|
||||
:param title: 标题
|
||||
@@ -145,20 +118,43 @@ class Plex(metaclass=Singleton):
|
||||
return None
|
||||
ret_movies = []
|
||||
if year:
|
||||
movies = self._plex.library.search(title=title, year=year, libtype="movie")
|
||||
movies = self._plex.library.search(title=title,
|
||||
year=year,
|
||||
libtype="movie")
|
||||
# 根据原标题再查一遍
|
||||
if original_title and str(original_title) != str(title):
|
||||
movies.extend(self._plex.library.search(title=original_title, year=year, libtype="movie"))
|
||||
movies.extend(self._plex.library.search(title=original_title,
|
||||
year=year,
|
||||
libtype="movie"))
|
||||
else:
|
||||
movies = self._plex.library.search(title=title, libtype="movie")
|
||||
movies = self._plex.library.search(title=title,
|
||||
libtype="movie")
|
||||
if original_title and str(original_title) != str(title):
|
||||
movies.extend(self._plex.library.search(title=original_title, year=year, libtype="movie"))
|
||||
for movie in set(movies):
|
||||
movie_tmdbid = self.__get_ids(movie.guids).get("tmdb_id")
|
||||
if tmdb_id and movie_tmdbid:
|
||||
if str(movie_tmdbid) != str(tmdb_id):
|
||||
movies.extend(self._plex.library.search(title=original_title,
|
||||
libtype="movie"))
|
||||
for item in set(movies):
|
||||
ids = self.__get_ids(item.guids)
|
||||
if tmdb_id and ids['tmdb_id']:
|
||||
if str(ids['tmdb_id']) != str(tmdb_id):
|
||||
continue
|
||||
ret_movies.append({'title': movie.title, 'year': movie.year})
|
||||
path = None
|
||||
if item.locations:
|
||||
path = item.locations[0]
|
||||
ret_movies.append(
|
||||
schemas.MediaServerItem(
|
||||
server="plex",
|
||||
library=item.librarySectionID,
|
||||
item_id=item.key,
|
||||
item_type=item.type,
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids['tmdb_id'],
|
||||
imdbid=ids['imdb_id'],
|
||||
tvdbid=ids['tvdb_id'],
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
return ret_movies
|
||||
|
||||
def get_tv_episodes(self,
|
||||
@@ -167,7 +163,7 @@ class Plex(metaclass=Singleton):
|
||||
original_title: str = None,
|
||||
year: str = None,
|
||||
tmdb_id: int = None,
|
||||
season: int = None) -> Optional[Dict[int, list]]:
|
||||
season: int = None) -> Tuple[Optional[str], Optional[Dict[int, list]]]:
|
||||
"""
|
||||
根据标题、年份、季查询电视剧所有集信息
|
||||
:param item_id: 媒体ID
|
||||
@@ -179,22 +175,28 @@ class Plex(metaclass=Singleton):
|
||||
:return: 所有集的列表
|
||||
"""
|
||||
if not self._plex:
|
||||
return {}
|
||||
return None, {}
|
||||
if item_id:
|
||||
videos = self._plex.fetchItem(item_id)
|
||||
else:
|
||||
# 根据标题和年份模糊搜索,该结果不够准确
|
||||
videos = self._plex.library.search(title=title, year=year, libtype="show")
|
||||
if not videos and original_title and str(original_title) != str(title):
|
||||
videos = self._plex.library.search(title=original_title, year=year, libtype="show")
|
||||
videos = self._plex.library.search(title=title,
|
||||
year=year,
|
||||
libtype="show")
|
||||
if (not videos
|
||||
and original_title
|
||||
and str(original_title) != str(title)):
|
||||
videos = self._plex.library.search(title=original_title,
|
||||
year=year,
|
||||
libtype="show")
|
||||
if not videos:
|
||||
return {}
|
||||
return None, {}
|
||||
if isinstance(videos, list):
|
||||
videos = videos[0]
|
||||
video_tmdbid = self.__get_ids(videos.guids).get('tmdb_id')
|
||||
if tmdb_id and video_tmdbid:
|
||||
if str(video_tmdbid) != str(tmdb_id):
|
||||
return {}
|
||||
return None, {}
|
||||
episodes = videos.episodes()
|
||||
season_episodes = {}
|
||||
for episode in episodes:
|
||||
@@ -203,7 +205,7 @@ class Plex(metaclass=Singleton):
|
||||
if episode.seasonNumber not in season_episodes:
|
||||
season_episodes[episode.seasonNumber] = []
|
||||
season_episodes[episode.seasonNumber].append(episode.index)
|
||||
return season_episodes
|
||||
return videos.key, season_episodes
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -216,9 +218,11 @@ class Plex(metaclass=Singleton):
|
||||
return None
|
||||
try:
|
||||
if image_type == "Poster":
|
||||
images = self._plex.fetchItems('/library/metadata/%s/posters' % item_id, cls=media.Poster)
|
||||
images = self._plex.fetchItems('/library/metadata/%s/posters' % item_id,
|
||||
cls=media.Poster)
|
||||
else:
|
||||
images = self._plex.fetchItems('/library/metadata/%s/arts' % item_id, cls=media.Art)
|
||||
images = self._plex.fetchItems('/library/metadata/%s/arts' % item_id,
|
||||
cls=media.Art)
|
||||
for image in images:
|
||||
if hasattr(image, 'key') and image.key.startswith('http'):
|
||||
return image.key
|
||||
@@ -234,7 +238,7 @@ class Plex(metaclass=Singleton):
|
||||
return False
|
||||
return self._plex.library.update()
|
||||
|
||||
def refresh_library_by_items(self, items: List[RefreshMediaItem]) -> bool:
|
||||
def refresh_library_by_items(self, items: List[schemas.RefreshMediaItem]) -> bool:
|
||||
"""
|
||||
按路径刷新媒体库 item: target_path
|
||||
"""
|
||||
@@ -283,19 +287,34 @@ class Plex(metaclass=Singleton):
|
||||
logger.error(f"查找媒体库出错:{err}")
|
||||
return "", ""
|
||||
|
||||
def get_iteminfo(self, itemid: str) -> dict:
|
||||
def get_iteminfo(self, itemid: str) -> Optional[schemas.MediaServerItem]:
|
||||
"""
|
||||
获取单个项目详情
|
||||
"""
|
||||
if not self._plex:
|
||||
return {}
|
||||
return None
|
||||
try:
|
||||
item = self._plex.fetchItem(itemid)
|
||||
ids = self.__get_ids(item.guids)
|
||||
return {'ProviderIds': {'Tmdb': ids['tmdb_id'], 'Imdb': ids['imdb_id']}}
|
||||
path = None
|
||||
if item.locations:
|
||||
path = item.locations[0]
|
||||
return schemas.MediaServerItem(
|
||||
server="plex",
|
||||
library=item.librarySectionID,
|
||||
item_id=item.key,
|
||||
item_type=item.type,
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids['tmdb_id'],
|
||||
imdbid=ids['imdb_id'],
|
||||
tvdbid=ids['tvdb_id'],
|
||||
path=path,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"获取项目详情出错:{err}")
|
||||
return {}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __get_ids(guids: List[Any]) -> dict:
|
||||
@@ -326,9 +345,9 @@ class Plex(metaclass=Singleton):
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
if not parent:
|
||||
yield {}
|
||||
yield None
|
||||
if not self._plex:
|
||||
yield {}
|
||||
yield None
|
||||
try:
|
||||
section = self._plex.library.sectionByID(int(parent))
|
||||
if section:
|
||||
@@ -339,21 +358,24 @@ class Plex(metaclass=Singleton):
|
||||
path = None
|
||||
if item.locations:
|
||||
path = item.locations[0]
|
||||
yield {"id": item.key,
|
||||
"library": item.librarySectionID,
|
||||
"type": item.type,
|
||||
"title": item.title,
|
||||
"original_title": item.originalTitle,
|
||||
"year": item.year,
|
||||
"tmdbid": ids['tmdb_id'],
|
||||
"imdbid": ids['imdb_id'],
|
||||
"tvdbid": ids['tvdb_id'],
|
||||
"path": path}
|
||||
yield schemas.MediaServerItem(
|
||||
server="plex",
|
||||
library=item.librarySectionID,
|
||||
item_id=item.key,
|
||||
item_type=item.type,
|
||||
title=item.title,
|
||||
original_title=item.originalTitle,
|
||||
year=item.year,
|
||||
tmdbid=ids['tmdb_id'],
|
||||
imdbid=ids['imdb_id'],
|
||||
tvdbid=ids['tvdb_id'],
|
||||
path=path,
|
||||
)
|
||||
except Exception as err:
|
||||
logger.error(f"获取媒体库列表出错:{err}")
|
||||
yield {}
|
||||
yield None
|
||||
|
||||
def get_webhook_message(self, form: any) -> Optional[WebhookEventInfo]:
|
||||
def get_webhook_message(self, form: any) -> Optional[schemas.WebhookEventInfo]:
|
||||
"""
|
||||
解析Plex报文
|
||||
eventItem 字段的含义
|
||||
@@ -402,7 +424,7 @@ class Plex(metaclass=Singleton):
|
||||
"parentTitle": "Combat Shadow Fighting Saga / Great Prison Battle Saga",
|
||||
"originalTitle": "Baki Hanma",
|
||||
"contentRating": "TV-MA",
|
||||
"summary": "The world is shaken by news of a man taking down a monstrous elephant with his bare hands. Back in Japan, Baki is confronted by a knife-wielding child.",
|
||||
"summary": "The world is shaken by news",
|
||||
"index": 1,
|
||||
"parentIndex": 1,
|
||||
"audienceRating": 8.5,
|
||||
@@ -470,8 +492,8 @@ class Plex(metaclass=Singleton):
|
||||
eventType = message.get('event')
|
||||
if not eventType:
|
||||
return None
|
||||
logger.info(f"接收到plex webhook:{message}")
|
||||
eventItem = WebhookEventInfo(event=eventType, channel="plex")
|
||||
logger.debug(f"接收到plex webhook:{message}")
|
||||
eventItem = schemas.WebhookEventInfo(event=eventType, channel="plex")
|
||||
if message.get('Metadata'):
|
||||
if message.get('Metadata', {}).get('type') == 'episode':
|
||||
eventItem.item_type = "TV"
|
||||
@@ -484,14 +506,17 @@ class Plex(metaclass=Singleton):
|
||||
eventItem.season_id = message.get('Metadata', {}).get('parentIndex')
|
||||
eventItem.episode_id = message.get('Metadata', {}).get('index')
|
||||
|
||||
if message.get('Metadata', {}).get('summary') and len(message.get('Metadata', {}).get('summary')) > 100:
|
||||
if (message.get('Metadata', {}).get('summary')
|
||||
and len(message.get('Metadata', {}).get('summary')) > 100):
|
||||
eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
|
||||
else:
|
||||
eventItem.overview = message.get('Metadata', {}).get('summary')
|
||||
else:
|
||||
eventItem.item_type = "MOV" if message.get('Metadata', {}).get('type') == 'movie' else "SHOW"
|
||||
eventItem.item_type = "MOV" if message.get('Metadata',
|
||||
{}).get('type') == 'movie' else "SHOW"
|
||||
eventItem.item_name = "%s %s" % (
|
||||
message.get('Metadata', {}).get('title'), "(" + str(message.get('Metadata', {}).get('year')) + ")")
|
||||
message.get('Metadata', {}).get('title'),
|
||||
"(" + str(message.get('Metadata', {}).get('year')) + ")")
|
||||
eventItem.item_id = message.get('Metadata', {}).get('ratingKey')
|
||||
if len(message.get('Metadata', {}).get('summary')) > 100:
|
||||
eventItem.overview = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
|
||||
|
||||
@@ -34,7 +34,7 @@ class QbittorrentModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if self.qbittorrent.is_inactive():
|
||||
self.qbittorrent = Qbittorrent()
|
||||
self.qbittorrent.reconnect()
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
|
||||
@@ -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"),
|
||||
|
||||
@@ -35,6 +35,12 @@ class Qbittorrent(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.qbc else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.qbc = self.__login_qbittorrent()
|
||||
|
||||
def __login_qbittorrent(self) -> Optional[Client]:
|
||||
"""
|
||||
连接qbittorrent
|
||||
|
||||
@@ -158,10 +158,8 @@ class Telegram(metaclass=Singleton):
|
||||
title = re.sub(r"\s+", " ", title).strip()
|
||||
free = torrent.volume_factor
|
||||
seeder = f"{torrent.seeders}↑"
|
||||
description = torrent.description
|
||||
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
|
||||
f"_{description}_"
|
||||
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}"
|
||||
index += 1
|
||||
|
||||
if userid:
|
||||
|
||||
@@ -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:
|
||||
@@ -184,11 +187,12 @@ class TheMovieDbModule(_ModuleBase):
|
||||
|
||||
return [MediaInfo(tmdb_info=info) for info in results]
|
||||
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo) -> None:
|
||||
def scrape_metadata(self, path: Path, mediainfo: MediaInfo, transfer_type: str) -> None:
|
||||
"""
|
||||
刮削元数据
|
||||
:param path: 媒体文件路径
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param transfer_type: 转移类型
|
||||
:return: 成功或失败
|
||||
"""
|
||||
if settings.SCRAP_SOURCE != "themoviedb":
|
||||
@@ -199,12 +203,14 @@ class TheMovieDbModule(_ModuleBase):
|
||||
logger.info(f"开始刮削蓝光原盘:{path} ...")
|
||||
scrape_path = path / path.name
|
||||
self.scraper.gen_scraper_files(mediainfo=mediainfo,
|
||||
file_path=scrape_path)
|
||||
file_path=scrape_path,
|
||||
transfer_type=transfer_type)
|
||||
elif path.is_file():
|
||||
# 单个文件
|
||||
logger.info(f"开始刮削媒体库文件:{path} ...")
|
||||
self.scraper.gen_scraper_files(mediainfo=mediainfo,
|
||||
file_path=path)
|
||||
file_path=path,
|
||||
transfer_type=transfer_type)
|
||||
else:
|
||||
# 目录下的所有文件
|
||||
logger.info(f"开始刮削目录:{path} ...")
|
||||
@@ -212,7 +218,8 @@ class TheMovieDbModule(_ModuleBase):
|
||||
if not file:
|
||||
continue
|
||||
self.scraper.gen_scraper_files(mediainfo=mediainfo,
|
||||
file_path=file)
|
||||
file_path=file,
|
||||
transfer_type=transfer_type)
|
||||
logger.info(f"{path} 刮削完成")
|
||||
|
||||
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str, with_original_language: str,
|
||||
@@ -280,6 +287,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 +354,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]:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from xml.dom import minidom
|
||||
|
||||
from requests import RequestException
|
||||
@@ -12,21 +13,26 @@ from app.schemas.types import MediaType
|
||||
from app.utils.common import retry
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class TmdbScraper:
|
||||
tmdb = None
|
||||
_transfer_type = settings.TRANSFER_TYPE
|
||||
|
||||
def __init__(self, tmdb):
|
||||
self.tmdb = tmdb
|
||||
|
||||
def gen_scraper_files(self, mediainfo: MediaInfo, file_path: Path):
|
||||
def gen_scraper_files(self, mediainfo: MediaInfo, file_path: Path, transfer_type: str):
|
||||
"""
|
||||
生成刮削文件,包括NFO和图片,传入路径为文件路径
|
||||
:param mediainfo: 媒体信息
|
||||
:param file_path: 文件路径或者目录路径
|
||||
:param transfer_type: 传输类型
|
||||
"""
|
||||
|
||||
self._transfer_type = transfer_type
|
||||
|
||||
def __get_episode_detail(_seasoninfo: dict, _episode: int):
|
||||
"""
|
||||
根据季信息获取集的信息
|
||||
@@ -159,14 +165,16 @@ class TmdbScraper:
|
||||
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
|
||||
# 演员
|
||||
for actor in mediainfo.actors:
|
||||
# 获取中文名
|
||||
xactor = DomUtils.add_node(doc, root, "actor")
|
||||
DomUtils.add_node(doc, xactor, "name", actor.get("name") or "")
|
||||
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, "order", actor.get("order") if actor.get("order") is not None else "")
|
||||
DomUtils.add_node(doc, xactor, "tmdbid", actor.get("id") or "")
|
||||
DomUtils.add_node(doc, xactor, "thumb", actor.get('image'))
|
||||
DomUtils.add_node(doc, xactor, "profile", actor.get('profile'))
|
||||
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:
|
||||
@@ -241,7 +249,8 @@ class TmdbScraper:
|
||||
doc = minidom.Document()
|
||||
root = DomUtils.add_node(doc, doc, "season")
|
||||
# 添加时间
|
||||
DomUtils.add_node(doc, root, "dateadded", time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
DomUtils.add_node(doc, root, "dateadded",
|
||||
time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))
|
||||
# 简介
|
||||
xplot = DomUtils.add_node(doc, root, "plot")
|
||||
xplot.appendChild(doc.createCDATASection(seasoninfo.get("overview") or ""))
|
||||
@@ -253,7 +262,8 @@ class TmdbScraper:
|
||||
DomUtils.add_node(doc, root, "premiered", seasoninfo.get("air_date") or "")
|
||||
DomUtils.add_node(doc, root, "releasedate", seasoninfo.get("air_date") or "")
|
||||
# 发行年份
|
||||
DomUtils.add_node(doc, root, "year", seasoninfo.get("air_date")[:4] if seasoninfo.get("air_date") else "")
|
||||
DomUtils.add_node(doc, root, "year",
|
||||
seasoninfo.get("air_date")[:4] if seasoninfo.get("air_date") else "")
|
||||
# seasonnumber
|
||||
DomUtils.add_node(doc, root, "seasonnumber", str(season))
|
||||
# 保存
|
||||
@@ -317,12 +327,15 @@ 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"))
|
||||
|
||||
@staticmethod
|
||||
@retry(RequestException, logger=logger)
|
||||
def __save_image(url: str, file_path: Path):
|
||||
def __save_image(self, url: str, file_path: Path):
|
||||
"""
|
||||
下载图片并保存
|
||||
"""
|
||||
@@ -332,20 +345,41 @@ class TmdbScraper:
|
||||
logger.info(f"正在下载{file_path.stem}图片:{url} ...")
|
||||
r = RequestUtils().get_res(url=url, raise_exception=True)
|
||||
if r:
|
||||
file_path.write_bytes(r.content)
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, r.content)
|
||||
else:
|
||||
file_path.write_bytes(r.content)
|
||||
logger.info(f"图片已保存:{file_path}")
|
||||
else:
|
||||
logger.info(f"{file_path.stem}图片下载失败,请检查网络连通性")
|
||||
except RequestException as err:
|
||||
raise err
|
||||
except Exception as err:
|
||||
logger.error(f"{file_path.stem}图片下载失败:{err}")
|
||||
|
||||
@staticmethod
|
||||
def __save_nfo(doc, file_path: Path):
|
||||
def __save_nfo(self, doc, file_path: Path):
|
||||
"""
|
||||
保存NFO
|
||||
"""
|
||||
if file_path.exists():
|
||||
return
|
||||
xml_str = doc.toprettyxml(indent=" ", encoding="utf-8")
|
||||
file_path.write_bytes(xml_str)
|
||||
if self._transfer_type in ['rclone_move', 'rclone_copy']:
|
||||
self.__save_remove_file(file_path, xml_str)
|
||||
else:
|
||||
file_path.write_bytes(xml_str)
|
||||
logger.info(f"NFO文件已保存:{file_path}")
|
||||
|
||||
def __save_remove_file(self, out_file: Path, content: Union[str, bytes]):
|
||||
"""
|
||||
保存文件到远端
|
||||
"""
|
||||
temp_file = settings.TEMP_PATH / str(out_file)[1:]
|
||||
temp_file_dir = temp_file.parent
|
||||
if not temp_file_dir.exists():
|
||||
temp_file_dir.mkdir(parents=True, exist_ok=True)
|
||||
temp_file.write_bytes(content)
|
||||
if self._transfer_type == 'rclone_move':
|
||||
SystemUtils.rclone_move(temp_file, out_file)
|
||||
elif self._transfer_type == 'rclone_copy':
|
||||
SystemUtils.rclone_copy(temp_file, out_file)
|
||||
|
||||
@@ -1136,6 +1136,26 @@ class TmdbHelper:
|
||||
def get_person_detail(self, person_id: int) -> dict:
|
||||
"""
|
||||
获取人物详情
|
||||
{
|
||||
"adult": false,
|
||||
"also_known_as": [
|
||||
"Michael Chen",
|
||||
"Chen He",
|
||||
"陈赫"
|
||||
],
|
||||
"biography": "陈赫,xxx",
|
||||
"birthday": "1985-11-09",
|
||||
"deathday": null,
|
||||
"gender": 2,
|
||||
"homepage": "https://movie.douban.com/celebrity/1313841/",
|
||||
"id": 1397016,
|
||||
"imdb_id": "nm4369305",
|
||||
"known_for_department": "Acting",
|
||||
"name": "Chen He",
|
||||
"place_of_birth": "Fuzhou,Fujian Province,China",
|
||||
"popularity": 9.228,
|
||||
"profile_path": "/2Bk39zVuoHUNHtpZ7LVg7OgkDd4.jpg"
|
||||
}
|
||||
"""
|
||||
if not self.person:
|
||||
return {}
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
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__)
|
||||
|
||||
|
||||
class TMDb(object):
|
||||
_session = None
|
||||
TMDB_API_KEY = "TMDB_API_KEY"
|
||||
TMDB_LANGUAGE = "TMDB_LANGUAGE"
|
||||
TMDB_SESSION_ID = "TMDB_SESSION_ID"
|
||||
@@ -25,11 +26,18 @@ class TMDb(object):
|
||||
TMDB_DOMAIN = "TMDB_DOMAIN"
|
||||
REQUEST_CACHE_MAXSIZE = None
|
||||
|
||||
_req = None
|
||||
_session = None
|
||||
|
||||
def __init__(self, obj_cached=True, session=None):
|
||||
if self.__class__._session is None or session is not None:
|
||||
self.__class__._session = requests.Session() if session is None else session
|
||||
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
|
||||
self.obj_cached = obj_cached
|
||||
if os.environ.get(self.TMDB_LANGUAGE) is None:
|
||||
os.environ[self.TMDB_LANGUAGE] = "en-US"
|
||||
@@ -53,7 +61,7 @@ class TMDb(object):
|
||||
@property
|
||||
def domain(self):
|
||||
return os.environ.get(self.TMDB_DOMAIN)
|
||||
|
||||
|
||||
@property
|
||||
def proxies(self):
|
||||
proxy = os.environ.get(self.TMDB_PROXIES)
|
||||
@@ -130,13 +138,24 @@ 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):
|
||||
return requests.request(method, url, data=data, json=json, 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()
|
||||
|
||||
def _request_obj(self, action, params="", call_cached=True, method="GET", data=None, json=None, key=None):
|
||||
def _request_obj(self, action, params="", call_cached=True,
|
||||
method="GET", data=None, json=None, key=None):
|
||||
if self.api_key is None or self.api_key == "":
|
||||
raise TMDbException("No API key found.")
|
||||
|
||||
@@ -151,7 +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:
|
||||
req = self.__class__._session.request(method, url, data=data, json=json, 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
|
||||
|
||||
@@ -196,3 +218,7 @@ class TMDb(object):
|
||||
if key:
|
||||
return json.get(key)
|
||||
return json
|
||||
|
||||
def __del__(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
@@ -34,7 +34,7 @@ class TransmissionModule(_ModuleBase):
|
||||
"""
|
||||
# 定时重连
|
||||
if not self.transmission.is_inactive():
|
||||
self.transmission = Transmission()
|
||||
self.transmission.reconnect()
|
||||
|
||||
def download(self, content: Union[Path, str], download_dir: Path, cookie: str,
|
||||
episodes: Set[int] = None, category: str = None) -> Optional[Tuple[Optional[str], str]]:
|
||||
@@ -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,
|
||||
|
||||
@@ -56,6 +56,12 @@ class Transmission(metaclass=Singleton):
|
||||
return False
|
||||
return True if not self.trc else False
|
||||
|
||||
def reconnect(self):
|
||||
"""
|
||||
重连
|
||||
"""
|
||||
self.trc = self.__login_transmission()
|
||||
|
||||
def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None,
|
||||
tags: Union[str, list] = None) -> Tuple[List[Torrent], bool]:
|
||||
"""
|
||||
|
||||
551
app/plugins/autoclean/__init__.py
Normal file
551
app/plugins/autoclean/__init__.py
Normal file
@@ -0,0 +1,551 @@
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType, DownloadHistory
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class AutoClean(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "定时清理媒体库"
|
||||
# 插件描述
|
||||
plugin_desc = "定时清理用户下载的种子、源文件、媒体库文件。"
|
||||
# 插件图标
|
||||
plugin_icon = "clean.png"
|
||||
# 主题色
|
||||
plugin_color = "#3377ed"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "autoclean_"
|
||||
# 加载顺序
|
||||
plugin_order = 23
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_cron = None
|
||||
_type = None
|
||||
_onlyonce = False
|
||||
_notify = False
|
||||
_cleantype = None
|
||||
_cleanuser = None
|
||||
_cleandate = None
|
||||
_downloadhis = None
|
||||
_transferhis = None
|
||||
|
||||
# 定时器
|
||||
_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._onlyonce = config.get("onlyonce")
|
||||
self._notify = config.get("notify")
|
||||
self._cleantype = config.get("cleantype")
|
||||
self._cleanuser = config.get("cleanuser")
|
||||
self._cleandate = config.get("cleandate")
|
||||
|
||||
# 加载模块
|
||||
if self._enabled:
|
||||
self._downloadhis = DownloadHistoryOper(self.db)
|
||||
self._transferhis = TransferHistoryOper(self.db)
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self._cron:
|
||||
try:
|
||||
self._scheduler.add_job(func=self.__clean,
|
||||
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.__clean, 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,
|
||||
"cleantype": self._cleantype,
|
||||
"enabled": self._enabled,
|
||||
"cleanuser": self._cleanuser,
|
||||
"cleandate": self._cleandate,
|
||||
"notify": self._notify,
|
||||
})
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __clean(self):
|
||||
"""
|
||||
定时清理媒体库
|
||||
"""
|
||||
if not self._cleandate:
|
||||
logger.error("未配置清理媒体库时间,停止运行")
|
||||
return
|
||||
|
||||
# 清理日期
|
||||
current_time = datetime.now()
|
||||
days_ago = current_time - timedelta(days=int(self._cleandate))
|
||||
clean_date = days_ago.strftime("%Y-%m-%d")
|
||||
|
||||
# 查询用户清理日期之后的下载历史
|
||||
if not self._cleanuser:
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date)
|
||||
logger.info(f'获取到日期 {clean_date} 之后的下载历史 {len(downloadhis_list)} 条')
|
||||
|
||||
self.__clean_history(date=clean_date, downloadhis_list=downloadhis_list)
|
||||
else:
|
||||
for userid in str(self._cleanuser).split(","):
|
||||
downloadhis_list = self._downloadhis.list_by_user_date(date=clean_date,
|
||||
userid=userid)
|
||||
logger.info(
|
||||
f'获取到用户 {userid} 日期 {clean_date} 之后的下载历史 {len(downloadhis_list)} 条')
|
||||
self.__clean_history(date=clean_date, downloadhis_list=downloadhis_list, userid=userid)
|
||||
|
||||
def __clean_history(self, date: str, downloadhis_list: List[DownloadHistory], userid: str = None):
|
||||
"""
|
||||
清理下载历史、转移记录
|
||||
"""
|
||||
if not downloadhis_list:
|
||||
logger.warn(f"未获取到日期 {date} 之后的下载记录,停止运行")
|
||||
return
|
||||
|
||||
# 读取历史记录
|
||||
history = self.get_data('history') or []
|
||||
|
||||
# 创建一个字典来保存分组结果
|
||||
downloadhis_grouped_dict: Dict[tuple, List[DownloadHistory]] = defaultdict(list)
|
||||
# 遍历DownloadHistory对象列表
|
||||
for downloadhis in downloadhis_list:
|
||||
# 获取type和tmdbid的值
|
||||
dtype = downloadhis.type
|
||||
tmdbid = downloadhis.tmdbid
|
||||
|
||||
# 将DownloadHistory对象添加到对应分组的列表中
|
||||
downloadhis_grouped_dict[(dtype, tmdbid)].append(downloadhis)
|
||||
|
||||
# 输出分组结果
|
||||
for key, downloadhis_list in downloadhis_grouped_dict.items():
|
||||
logger.info(f"开始清理 {key}")
|
||||
|
||||
del_transferhis_cnt = 0
|
||||
del_media_name = downloadhis_list[0].title
|
||||
del_media_user = downloadhis_list[0].userid
|
||||
del_media_type = downloadhis_list[0].type
|
||||
del_media_year = downloadhis_list[0].year
|
||||
del_media_season = downloadhis_list[0].seasons
|
||||
del_media_episode = downloadhis_list[0].episodes
|
||||
del_image = downloadhis_list[0].image
|
||||
for downloadhis in downloadhis_list:
|
||||
if not downloadhis.download_hash:
|
||||
logger.debug(f'下载历史 {downloadhis.id} {downloadhis.title} 未获取到download_hash,跳过处理')
|
||||
continue
|
||||
# 根据hash获取转移记录
|
||||
transferhis_list = self._transferhis.list_by_hash(download_hash=downloadhis.download_hash)
|
||||
if not transferhis_list:
|
||||
logger.warn(f"下载历史 {downloadhis.download_hash} 未查询到转移记录,跳过处理")
|
||||
continue
|
||||
|
||||
for history in transferhis_list:
|
||||
# 册除媒体库文件
|
||||
if str(self._cleantype == "dest") or str(self._cleantype == "all"):
|
||||
TransferChain(self.db).delete_files(Path(history.dest))
|
||||
# 删除记录
|
||||
self._transferhis.delete(history.id)
|
||||
# 删除源文件
|
||||
if str(self._cleantype == "src") or str(self._cleantype == "all"):
|
||||
TransferChain(self.db).delete_files(Path(history.src))
|
||||
# 发送事件
|
||||
eventmanager.send_event(
|
||||
EventType.DownloadFileDeleted,
|
||||
{
|
||||
"src": history.src
|
||||
}
|
||||
)
|
||||
|
||||
# 累加删除数量
|
||||
del_transferhis_cnt += len(transferhis_list)
|
||||
|
||||
# 发送消息
|
||||
if self._notify:
|
||||
self.post_message(
|
||||
mtype=NotificationType.MediaServer,
|
||||
title="【定时清理媒体库任务完成】",
|
||||
text=f"清理媒体名称 {del_media_name}\n"
|
||||
f"下载媒体用户 {del_media_user}\n"
|
||||
f"删除历史记录 {del_transferhis_cnt}",
|
||||
userid=userid)
|
||||
|
||||
history.append({
|
||||
"type": del_media_type,
|
||||
"title": del_media_name,
|
||||
"year": del_media_year,
|
||||
"season": del_media_season,
|
||||
"episode": del_media_episode,
|
||||
"image": del_image,
|
||||
"del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time()))
|
||||
})
|
||||
|
||||
# 保存历史
|
||||
self.save_data("history", history)
|
||||
|
||||
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': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'notify',
|
||||
'label': '开启通知',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'placeholder': '0 0 ? ? ?'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'cleantype',
|
||||
'label': '清理方式',
|
||||
'items': [
|
||||
{'title': '媒体库文件', 'value': 'dest'},
|
||||
{'title': '源文件', 'value': 'src'},
|
||||
{'title': '所有文件', 'value': 'all'},
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cleandate',
|
||||
'label': '清理媒体日期',
|
||||
'placeholder': '清理多少天之前的下载记录(天)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cleanuser',
|
||||
'label': '清理下载用户',
|
||||
'placeholder': '多个用户,分割'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"onlyonce": False,
|
||||
"notify": False,
|
||||
"cleantype": "dest",
|
||||
"cron": "",
|
||||
"cleanuser": "",
|
||||
"cleandate": 30
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
"""
|
||||
# 查询同步详情
|
||||
historys = self.get_data('history')
|
||||
if not historys:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'text': '暂无数据',
|
||||
'props': {
|
||||
'class': 'text-center',
|
||||
}
|
||||
}
|
||||
]
|
||||
# 数据按时间降序排序
|
||||
historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True)
|
||||
# 拼装页面
|
||||
contents = []
|
||||
for history in historys:
|
||||
htype = history.get("type")
|
||||
title = history.get("title")
|
||||
year = history.get("year")
|
||||
season = history.get("season")
|
||||
episode = history.get("episode")
|
||||
image = history.get("image")
|
||||
del_time = history.get("del_time")
|
||||
|
||||
if season:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'年份:{year}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'季:{season}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'集:{episode}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
else:
|
||||
sub_contents = [
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'类型:{htype}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'标题:{title}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'年份:{year}'
|
||||
},
|
||||
{
|
||||
'component': 'VCardText',
|
||||
'props': {
|
||||
'class': 'pa-0 px-2'
|
||||
},
|
||||
'text': f'时间:{del_time}'
|
||||
}
|
||||
]
|
||||
|
||||
contents.append(
|
||||
{
|
||||
'component': 'VCard',
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'd-flex justify-space-start flex-nowrap flex-row',
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'div',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': image,
|
||||
'height': 120,
|
||||
'width': 80,
|
||||
'aspect-ratio': '2/3',
|
||||
'class': 'object-cover shadow ring-gray-500',
|
||||
'cover': True
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'div',
|
||||
'content': sub_contents
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
'props': {
|
||||
'class': 'grid gap-3 grid-info-card',
|
||||
},
|
||||
'content': contents
|
||||
}
|
||||
]
|
||||
|
||||
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))
|
||||
@@ -92,7 +92,7 @@ class AutoSignIn(_PluginBase):
|
||||
self._clean = config.get("clean")
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")]
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites()
|
||||
self._sign_sites = [site.get("id") for site in all_sites if site.get("id") in self._sign_sites]
|
||||
self._login_sites = [site.get("id") for site in all_sites if site.get("id") in self._login_sites]
|
||||
# 保存配置
|
||||
@@ -244,9 +244,13 @@ class AutoSignIn(_PluginBase):
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
# 站点的可选项
|
||||
site_options = [{"title": site.name, "value": site.id}
|
||||
for site in Site.list_order_by_pri(self.db)]
|
||||
# 站点的可选项(内置站点 + 自定义站点)
|
||||
customSites = self.__custom_sites()
|
||||
|
||||
site_options = ([{"title": site.name, "value": site.id}
|
||||
for site in Site.list_order_by_pri(self.db)]
|
||||
+ [{"title": site.get("name"), "value": site.get("id")}
|
||||
for site in customSites])
|
||||
return [
|
||||
{
|
||||
'component': 'VForm',
|
||||
@@ -452,6 +456,13 @@ class AutoSignIn(_PluginBase):
|
||||
"retry_keyword": "错误|失败"
|
||||
}
|
||||
|
||||
def __custom_sites(self) -> List[dict]:
|
||||
custom_sites = []
|
||||
custom_sites_config = self.get_config("CustomSites")
|
||||
if custom_sites_config and custom_sites_config.get("enabled"):
|
||||
custom_sites = custom_sites_config.get("sites")
|
||||
return custom_sites
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
"""
|
||||
拼装插件详情页面,需要返回页面配置,同时附带数据
|
||||
@@ -590,7 +601,7 @@ class AutoSignIn(_PluginBase):
|
||||
today_history = self.get_data(key=type + "-" + today)
|
||||
|
||||
# 查询所有站点
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")]
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites()
|
||||
# 过滤掉没有选中的站点
|
||||
if do_sites:
|
||||
do_sites = [site for site in all_sites if site.get("id") in do_sites]
|
||||
|
||||
@@ -420,8 +420,7 @@ class BestFilmVersion(_PluginBase):
|
||||
item_info_resp = Emby().get_iteminfo(itemid=data.get('Id'))
|
||||
else:
|
||||
item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id'))
|
||||
|
||||
logger.info(f'BestFilmVersion插件 item打印 {item_info_resp}')
|
||||
logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}')
|
||||
if not item_info_resp:
|
||||
continue
|
||||
|
||||
@@ -430,41 +429,35 @@ class BestFilmVersion(_PluginBase):
|
||||
continue
|
||||
|
||||
# 获取tmdb_id
|
||||
media_info_ids = item_info_resp.get('ExternalUrls')
|
||||
if not media_info_ids:
|
||||
tmdb_id = item_info_resp.tmdbid
|
||||
if not tmdb_id:
|
||||
continue
|
||||
for media_info_id in media_info_ids:
|
||||
if 'TheMovieDb' != media_info_id.get('Name'):
|
||||
continue
|
||||
tmdb_find_id = str(media_info_id.get('Url')).split('/')
|
||||
tmdb_find_id.reverse()
|
||||
tmdb_id = tmdb_find_id[0]
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbID:{tmdb_id}')
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.get('Name'))
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbid:{tmdb_id}')
|
||||
continue
|
||||
# 添加订阅
|
||||
self.subscribechain.add(mtype=MediaType.MOVIE,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
best_version=True,
|
||||
username="收藏洗版",
|
||||
exist_ok=True)
|
||||
# 加入缓存
|
||||
caches.append(data.get('Name'))
|
||||
# 存储历史记录
|
||||
if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]:
|
||||
history.append({
|
||||
"title": mediainfo.title,
|
||||
"type": mediainfo.type.value,
|
||||
"year": mediainfo.year,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"overview": mediainfo.overview,
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
})
|
||||
# 保存历史记录
|
||||
self.save_data('history', history)
|
||||
# 保存缓存
|
||||
@@ -474,7 +467,7 @@ class BestFilmVersion(_PluginBase):
|
||||
|
||||
def jellyfin_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
users_url = "{HOST}Users?&apikey={APIKEY}"
|
||||
users_url = "[HOST]Users?&apikey=[APIKEY]"
|
||||
users = self.get_users(Jellyfin().get_data(users_url))
|
||||
if not users:
|
||||
logger.info(f"bestfilmversion/users_url: {users_url}")
|
||||
@@ -482,7 +475,7 @@ class BestFilmVersion(_PluginBase):
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "{HOST}Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
"&SortOrder=Descending" \
|
||||
"&Filters=IsFavorite" \
|
||||
"&Recursive=true" \
|
||||
@@ -491,7 +484,7 @@ class BestFilmVersion(_PluginBase):
|
||||
"&ExcludeLocationTypes=Virtual" \
|
||||
"&EnableTotalRecordCount=false" \
|
||||
"&Limit=20" \
|
||||
"&apikey={APIKEY}"
|
||||
"&apikey=[APIKEY]"
|
||||
resp = self.get_items(Jellyfin().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
@@ -500,14 +493,14 @@ class BestFilmVersion(_PluginBase):
|
||||
|
||||
def emby_get_items(self) -> List[dict]:
|
||||
# 获取所有user
|
||||
get_users_url = "{HOST}Users?&api_key={APIKEY}"
|
||||
get_users_url = "[HOST]Users?&api_key=[APIKEY]"
|
||||
users = self.get_users(Emby().get_data(get_users_url))
|
||||
if not users:
|
||||
return []
|
||||
all_items = []
|
||||
for user in users:
|
||||
# 根据加入日期 降序排序
|
||||
url = "{HOST}emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \
|
||||
"&SortOrder=Descending" \
|
||||
"&Filters=IsFavorite" \
|
||||
"&Recursive=true" \
|
||||
@@ -515,7 +508,7 @@ class BestFilmVersion(_PluginBase):
|
||||
"&CollapseBoxSetItems=false" \
|
||||
"&ExcludeLocationTypes=Virtual" \
|
||||
"&EnableTotalRecordCount=false" \
|
||||
"&Limit=20&api_key={APIKEY}"
|
||||
"&Limit=20&api_key=[APIKEY]"
|
||||
resp = self.get_items(Emby().get_data(url))
|
||||
if not resp:
|
||||
continue
|
||||
@@ -634,52 +627,34 @@ class BestFilmVersion(_PluginBase):
|
||||
if not _is_lock:
|
||||
return
|
||||
try:
|
||||
|
||||
mediainfo: Optional[MediaInfo] = None
|
||||
if not data.tmdb_id:
|
||||
info = None
|
||||
if data.channel == 'jellyfin' and data.save_reason == 'UpdateUserRating' and data.item_favorite:
|
||||
if (data.channel == 'jellyfin'
|
||||
and data.save_reason == 'UpdateUserRating'
|
||||
and data.item_favorite):
|
||||
info = Jellyfin().get_iteminfo(itemid=data.item_id)
|
||||
elif data.channel == 'emby' and data.event == 'item.rate':
|
||||
info = Emby().get_iteminfo(itemid=data.item_id)
|
||||
elif data.channel == 'plex' and data.event == 'item.rate':
|
||||
info = Plex().get_iteminfo(itemid=data.item_id)
|
||||
logger.info(f'BestFilmVersion/webhook_message_action item打印:{info}')
|
||||
|
||||
logger.debug(f'BestFilmVersion/webhook_message_action item打印:{info}')
|
||||
if not info:
|
||||
return
|
||||
if info['Type'] not in ['Movie', 'MOV', 'movie']:
|
||||
if info.item_type not in ['Movie', 'MOV', 'movie']:
|
||||
return
|
||||
|
||||
# 获取tmdb_id
|
||||
media_info_ids = info.get('ExternalUrls')
|
||||
if not media_info_ids:
|
||||
return
|
||||
for media_info_id in media_info_ids:
|
||||
|
||||
if 'TheMovieDb' != media_info_id.get('Name'):
|
||||
continue
|
||||
|
||||
tmdb_find_id = str(media_info_id.get('Url')).split('/')
|
||||
tmdb_find_id.reverse()
|
||||
tmdb_id = tmdb_find_id[0]
|
||||
|
||||
mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}')
|
||||
return
|
||||
tmdb_id = info.tmdbid
|
||||
else:
|
||||
if data.channel == 'jellyfin' and (data.save_reason != 'UpdateUserRating' or not data.item_favorite):
|
||||
tmdb_id = data.tmdb_id
|
||||
if (data.channel == 'jellyfin'
|
||||
and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)):
|
||||
return
|
||||
if data.item_type not in ['Movie', 'MOV', 'movie']:
|
||||
return
|
||||
|
||||
mediainfo = self.chain.recognize_media(tmdbid=data.tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{data.tmdb_id}')
|
||||
return
|
||||
|
||||
# 识别媒体信息
|
||||
mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}')
|
||||
return
|
||||
# 读取缓存
|
||||
caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else []
|
||||
|
||||
@@ -49,6 +49,7 @@ class BrushFlow(_PluginBase):
|
||||
siteshelper = None
|
||||
siteoper = None
|
||||
torrents = None
|
||||
sites = None
|
||||
qb = None
|
||||
tr = None
|
||||
# 添加种子定时
|
||||
@@ -88,6 +89,7 @@ class BrushFlow(_PluginBase):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.siteoper = SiteOper()
|
||||
self.torrents = TorrentsChain()
|
||||
self.sites = SitesHelper()
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
@@ -115,11 +117,21 @@ class BrushFlow(_PluginBase):
|
||||
self._save_path = config.get("save_path")
|
||||
self._clear_task = config.get("clear_task")
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
self._brushsites = [site.get("id") for site in self.sites.get_indexers() if
|
||||
not site.get("public") and site.get("id") in self._brushsites]
|
||||
|
||||
# 保存配置
|
||||
self.__update_config()
|
||||
|
||||
if self._clear_task:
|
||||
# 清除统计数据
|
||||
self.save_data("statistic", {})
|
||||
# 清除种子记录
|
||||
self.save_data("torrents", {})
|
||||
# 关闭一次性开关
|
||||
self._clear_task = False
|
||||
self.__update_config()
|
||||
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
@@ -225,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"站点刷流服务启动,立即运行一次")
|
||||
@@ -729,12 +741,13 @@ class BrushFlow(_PluginBase):
|
||||
"enabled": False,
|
||||
"notify": True,
|
||||
"onlyonce": False,
|
||||
"clear_task": False,
|
||||
"freeleech": "free"
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
# 种子明细
|
||||
data_list = self.get_data("torrents") or {}
|
||||
torrents = self.get_data("torrents") or {}
|
||||
# 统计数据
|
||||
stattistic_data: Dict[str, dict] = self.get_data("statistic") or {
|
||||
"count": 0,
|
||||
@@ -742,7 +755,7 @@ class BrushFlow(_PluginBase):
|
||||
"uploaded": 0,
|
||||
"downloaded": 0,
|
||||
}
|
||||
if not data_list:
|
||||
if not torrents:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
@@ -753,7 +766,9 @@ class BrushFlow(_PluginBase):
|
||||
}
|
||||
]
|
||||
else:
|
||||
data_list = data_list.values()
|
||||
data_list = torrents.values()
|
||||
# 按time倒序排序
|
||||
data_list = sorted(data_list, key=lambda x: x.get("time") or 0, reverse=True)
|
||||
# 总上传量格式化
|
||||
total_upload = StringUtils.str_filesize(stattistic_data.get("uploaded") or 0)
|
||||
# 总下载量格式化
|
||||
@@ -845,7 +860,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/upload.png'
|
||||
'src': '/plugin_icon/upload.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -915,7 +930,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/download.png'
|
||||
'src': '/plugin_icon/download.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -985,7 +1000,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/seed.png'
|
||||
'src': '/plugin_icon/seed.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1055,7 +1070,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/delete.png'
|
||||
'src': '/plugin_icon/delete.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1109,7 +1124,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'thead',
|
||||
'props': {
|
||||
'class': 'text-no-wrap'
|
||||
'class': 'text-no-wrap'
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -1218,7 +1233,8 @@ class BrushFlow(_PluginBase):
|
||||
"seed_inactivetime": self._seed_inactivetime,
|
||||
"up_speed": self._up_speed,
|
||||
"dl_speed": self._dl_speed,
|
||||
"save_path": self._save_path
|
||||
"save_path": self._save_path,
|
||||
"clear_task": self._clear_task
|
||||
})
|
||||
|
||||
def brush(self):
|
||||
@@ -1285,10 +1301,10 @@ class BrushFlow(_PluginBase):
|
||||
else:
|
||||
end_size = 0
|
||||
if begin_size and not end_size \
|
||||
and torrent.size > float(begin_size) * 1024**3:
|
||||
and torrent.size > float(begin_size) * 1024 ** 3:
|
||||
continue
|
||||
elif begin_size and end_size \
|
||||
and not float(begin_size) * 1024**3 <= torrent.size <= float(end_size) * 1024**3:
|
||||
and not float(begin_size) * 1024 ** 3 <= torrent.size <= float(end_size) * 1024 ** 3:
|
||||
continue
|
||||
# 做种人数
|
||||
if self._seeder:
|
||||
@@ -1345,7 +1361,7 @@ class BrushFlow(_PluginBase):
|
||||
break
|
||||
# 保种体积(GB)
|
||||
if self._disksize \
|
||||
and (torrents_size + torrent.size) > float(self._disksize) * 1024**3:
|
||||
and (torrents_size + torrent.size) > float(self._disksize) * 1024 ** 3:
|
||||
logger.warn(f"当前做种体积 {StringUtils.str_filesize(torrents_size)} "
|
||||
f"已超过保种体积 {self._disksize},停止新增任务")
|
||||
break
|
||||
@@ -1365,8 +1381,10 @@ class BrushFlow(_PluginBase):
|
||||
"downloaded": 0,
|
||||
"uploaded": 0,
|
||||
"deleted": False,
|
||||
"time": time.time()
|
||||
}
|
||||
# 统计数据
|
||||
torrents_size += torrent.size
|
||||
statistic_info["count"] += 1
|
||||
# 发送消息
|
||||
self.__send_add_message(torrent)
|
||||
@@ -1767,19 +1785,22 @@ class BrushFlow(_PluginBase):
|
||||
"""
|
||||
发送删除种子的消息
|
||||
"""
|
||||
if self._notify:
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【刷流任务删种】",
|
||||
text=f"站点:{site_name}\n"
|
||||
f"标题:{torrent_title}\n"
|
||||
f"原因:{reason}"
|
||||
))
|
||||
if not self._notify:
|
||||
return
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.SiteMessage,
|
||||
title=f"【刷流任务删种】",
|
||||
text=f"站点:{site_name}\n"
|
||||
f"标题:{torrent_title}\n"
|
||||
f"原因:{reason}"
|
||||
))
|
||||
|
||||
def __send_add_message(self, torrent: TorrentInfo):
|
||||
"""
|
||||
发送添加下载的消息
|
||||
"""
|
||||
if not self._notify:
|
||||
return
|
||||
msg_text = ""
|
||||
if torrent.site_name:
|
||||
msg_text = f"站点:{torrent.site_name}"
|
||||
@@ -1819,25 +1840,29 @@ class BrushFlow(_PluginBase):
|
||||
|
||||
def __get_downloader_info(self) -> schemas.DownloaderInfo:
|
||||
"""
|
||||
获取下载器实时信息
|
||||
获取下载器实时信息(所有下载器)
|
||||
"""
|
||||
if self._downloader == "qbittorrent":
|
||||
# 调用Qbittorrent API查询实时信息
|
||||
ret_info = schemas.DownloaderInfo()
|
||||
|
||||
# Qbittorrent
|
||||
if self.qb:
|
||||
info = self.qb.transfer_info()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.get("dl_info_speed"),
|
||||
upload_speed=info.get("up_info_speed"),
|
||||
download_size=info.get("dl_info_data"),
|
||||
upload_size=info.get("up_info_data")
|
||||
)
|
||||
else:
|
||||
if info:
|
||||
ret_info.download_speed += info.get("dl_info_speed")
|
||||
ret_info.upload_speed += info.get("up_info_speed")
|
||||
ret_info.download_size += info.get("dl_info_data")
|
||||
ret_info.upload_size += info.get("up_info_data")
|
||||
|
||||
# Transmission
|
||||
if self.tr:
|
||||
info = self.tr.transfer_info()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.download_speed,
|
||||
upload_speed=info.upload_speed,
|
||||
download_size=info.current_stats.downloaded_bytes,
|
||||
upload_size=info.current_stats.uploaded_bytes
|
||||
)
|
||||
if info:
|
||||
ret_info.download_speed += info.download_speed
|
||||
ret_info.upload_speed += info.upload_speed
|
||||
ret_info.download_size += info.current_stats.downloaded_bytes
|
||||
ret_info.upload_size += info.current_stats.uploaded_bytes
|
||||
|
||||
return ret_info
|
||||
|
||||
def __get_downloading_count(self) -> int:
|
||||
"""
|
||||
@@ -1848,7 +1873,7 @@ class BrushFlow(_PluginBase):
|
||||
return 0
|
||||
torrents = downlader.get_downloading_torrents()
|
||||
return len(torrents) or 0
|
||||
|
||||
|
||||
@staticmethod
|
||||
def __get_pubminutes(pubdate: str) -> int:
|
||||
"""
|
||||
@@ -1860,8 +1885,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):
|
||||
"""
|
||||
退出插件
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import List, Tuple, Dict, Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
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.schemas import TransferInfo
|
||||
@@ -183,7 +183,7 @@ class ChineseSubFinder(_PluginBase):
|
||||
pass
|
||||
|
||||
@eventmanager.register(EventType.TransferComplete)
|
||||
def download(self, event):
|
||||
def download(self, event: Event):
|
||||
"""
|
||||
调用ChineseSubFinder下载字幕
|
||||
"""
|
||||
@@ -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))
|
||||
|
||||
@@ -725,9 +725,9 @@ class CloudflareSpeedTest(_PluginBase):
|
||||
new_entrys.append(host_entry)
|
||||
except Exception as err:
|
||||
err_hosts.append(host + "\n")
|
||||
logger.error(f"{host} 格式转换错误:{str(err)}")
|
||||
logger.error(f"[HOST] 格式转换错误:{str(err)}")
|
||||
# 推送实时消息
|
||||
self.systemmessage.put(f"{host} 格式转换错误:{str(err)}")
|
||||
self.systemmessage.put(f"[HOST] 格式转换错误:{str(err)}")
|
||||
|
||||
# 写入系统hosts
|
||||
if new_entrys:
|
||||
|
||||
@@ -199,9 +199,9 @@ class CustomHosts(_PluginBase):
|
||||
new_entrys.append(host_entry)
|
||||
except Exception as err:
|
||||
err_hosts.append(host + "\n")
|
||||
logger.error(f"{host} 格式转换错误:{str(err)}")
|
||||
logger.error(f"[HOST] 格式转换错误:{str(err)}")
|
||||
# 推送实时消息
|
||||
self.systemmessage.put(f"{host} 格式转换错误:{str(err)}")
|
||||
self.systemmessage.put(f"[HOST] 格式转换错误:{str(err)}")
|
||||
|
||||
# 写入系统hosts
|
||||
if new_entrys:
|
||||
@@ -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 '请检查权限'}")
|
||||
|
||||
250
app/plugins/customsites/__init__.py
Normal file
250
app/plugins/customsites/__init__.py
Normal file
@@ -0,0 +1,250 @@
|
||||
from typing import Any, List, Dict, Tuple
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import EventManager
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.log import logger
|
||||
from app.plugins import _PluginBase
|
||||
from app.schemas.types import EventType
|
||||
|
||||
|
||||
class CustomSites(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "自定义站点"
|
||||
# 插件描述
|
||||
plugin_desc = "增加自定义站点为签到和统计使用。"
|
||||
# 插件图标
|
||||
plugin_icon = "world.png"
|
||||
# 主题色
|
||||
plugin_color = "#9AC16C"
|
||||
# 插件版本
|
||||
plugin_version = "0.1"
|
||||
# 插件作者
|
||||
plugin_author = "lightolly"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/lightolly"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "customsites_"
|
||||
# 加载顺序
|
||||
plugin_order = 0
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 自定义站点起始 id
|
||||
site_id_base = 60000
|
||||
site_id_alloc = site_id_base
|
||||
|
||||
# 私有属性
|
||||
cookie_cloud: CookieCloudHelper = None
|
||||
|
||||
# 配置属性
|
||||
_enabled: bool = False
|
||||
"""
|
||||
{
|
||||
"id": "站点ID",
|
||||
"name": "站点名称",
|
||||
"url": "站点地址",
|
||||
"cookie": "站点Cookie",
|
||||
"ua": "User-Agent",
|
||||
"proxy": "是否使用代理",
|
||||
"render": "是否仿真",
|
||||
}
|
||||
"""
|
||||
_sites: list[Dict] = []
|
||||
"""
|
||||
格式
|
||||
站点名称|url|是否仿真
|
||||
"""
|
||||
_site_urls: str = ""
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.cookie_cloud = CookieCloudHelper(
|
||||
server=settings.COOKIECLOUD_HOST,
|
||||
key=settings.COOKIECLOUD_KEY,
|
||||
password=settings.COOKIECLOUD_PASSWORD
|
||||
)
|
||||
|
||||
del_sites = []
|
||||
sites = []
|
||||
new_site_urls = []
|
||||
# 配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled", False)
|
||||
self._sites = config.get("sites", [])
|
||||
self._site_urls = config.get("site_urls", "")
|
||||
|
||||
if not self._enabled:
|
||||
return
|
||||
|
||||
site_urls = self._site_urls.splitlines()
|
||||
# 只保留 匹配site_urls的 sites
|
||||
urls = [site_url.split('|')[1] for site_url in site_urls]
|
||||
for site in self._sites:
|
||||
if site.get("url") not in urls:
|
||||
del_sites.append(site)
|
||||
else:
|
||||
sites.append(site)
|
||||
|
||||
for item in site_urls:
|
||||
_, url, _ = item.split("|")
|
||||
if url in [site.get("url") for site in self._sites]:
|
||||
continue
|
||||
else:
|
||||
new_site_urls.append(item)
|
||||
|
||||
# 获取待分配的最大ID
|
||||
alloc_ids = [site.get("id") for site in self._sites if site.get("id")] + [self.site_id_base]
|
||||
self.site_id_alloc = max(alloc_ids) + 1
|
||||
|
||||
# 补全 site_id
|
||||
for item in new_site_urls:
|
||||
site_name, item, site_render = item.split("|")
|
||||
sites.append({
|
||||
"id": self.site_id_alloc,
|
||||
"name": site_name,
|
||||
"url": item,
|
||||
"render": True if site_render.upper() == 'Y' else False,
|
||||
"cookie": "",
|
||||
})
|
||||
self.site_id_alloc += 1
|
||||
self._sites = sites
|
||||
# 保存配置
|
||||
self.sync_cookie()
|
||||
self.__update_config()
|
||||
|
||||
# 通知站点删除
|
||||
for site in del_sites:
|
||||
self.delete_site(site.get("id"))
|
||||
logger.info(f"删除站点 {site.get('name')}")
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def __update_config(self):
|
||||
# 保存配置
|
||||
self.update_config(
|
||||
{
|
||||
"enabled": self._enabled,
|
||||
"sites": self._sites,
|
||||
"site_urls": self._site_urls
|
||||
}
|
||||
)
|
||||
|
||||
def __get_site_by_domain(self, domain):
|
||||
for site in self._sites:
|
||||
site_domain = urlparse(site.get("url")).netloc
|
||||
if site_domain.endswith(domain):
|
||||
return site
|
||||
return None
|
||||
|
||||
def sync_cookie(self):
|
||||
"""
|
||||
通过CookieCloud同步站点Cookie
|
||||
"""
|
||||
logger.info("开始同步CookieCloud站点 ...")
|
||||
cookies, msg = self.cookie_cloud.download()
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
return
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
for domain, cookie in cookies.items():
|
||||
# 获取站点信息
|
||||
site_info = self.__get_site_by_domain(domain)
|
||||
if site_info:
|
||||
# 更新站点Cookie
|
||||
logger.info(f"更新站点 {domain} Cookie ...")
|
||||
site_info.update({"cookie": cookie})
|
||||
_update_count += 1
|
||||
|
||||
# 处理完成
|
||||
ret_msg = f"更新了{_update_count}个站点,总{len(self._sites)}个站点"
|
||||
logger.info(f"自定义站点 Cookie同步成功:{ret_msg}")
|
||||
|
||||
@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': 6
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextarea',
|
||||
'props': {
|
||||
'model': 'site_urls',
|
||||
'label': '站点列表',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个站点,配置方式:\n'
|
||||
'站点名称|站点地址|是否仿真(Y/N)\n'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"site_urls": [],
|
||||
"sites": self._sites
|
||||
}
|
||||
|
||||
def get_page(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
def stop_service(self):
|
||||
"""
|
||||
退出插件
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def delete_site(site_id):
|
||||
"""
|
||||
删除站点通知
|
||||
"""
|
||||
# 插件站点删除
|
||||
EventManager().send_event(EventType.SiteDeleted,
|
||||
{
|
||||
"site_id": site_id
|
||||
})
|
||||
@@ -1,20 +1,22 @@
|
||||
import datetime
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from typing import List, Tuple, Dict, Any
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.observers.polling import PollingObserver
|
||||
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
@@ -74,9 +76,11 @@ class DirMonitor(_PluginBase):
|
||||
transferhis = None
|
||||
downloadhis = None
|
||||
transferchian = None
|
||||
tmdbchain = None
|
||||
_observer = []
|
||||
_enabled = False
|
||||
_notify = False
|
||||
_onlyonce = False
|
||||
# 模式 compatibility/fast
|
||||
_mode = "fast"
|
||||
# 转移方式
|
||||
@@ -85,22 +89,26 @@ class DirMonitor(_PluginBase):
|
||||
_exclude_keywords = ""
|
||||
# 存储源目录与目的目录关系
|
||||
_dirconf: Dict[str, Path] = {}
|
||||
# 存储源目录转移方式
|
||||
_transferconf: Dict[str, str] = {}
|
||||
_medias = {}
|
||||
# 退出事件
|
||||
_event = Event()
|
||||
_event = threading.Event()
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
self.transferhis = TransferHistoryOper(self.db)
|
||||
self.downloadhis = DownloadHistoryOper(self.db)
|
||||
self.transferchian = TransferChain(self.db)
|
||||
|
||||
self.tmdbchain = TmdbChain()
|
||||
# 清空配置
|
||||
self._dirconf = {}
|
||||
self._transferconf = {}
|
||||
|
||||
# 读取配置
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._notify = config.get("notify")
|
||||
self._onlyonce = config.get("onlyonce")
|
||||
self._mode = config.get("mode")
|
||||
self._transfer_type = config.get("transfer_type")
|
||||
self._monitor_dirs = config.get("monitor_dirs") or ""
|
||||
@@ -109,10 +117,13 @@ class DirMonitor(_PluginBase):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if self._enabled:
|
||||
if self._enabled or self._onlyonce:
|
||||
# 定时服务管理器
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
# 追加入库消息统一发送服务
|
||||
self._scheduler.add_job(self.send_msg, trigger='interval', seconds=30)
|
||||
|
||||
# 启动任务
|
||||
# 读取目录配置
|
||||
monitor_dirs = self._monitor_dirs.split("\n")
|
||||
if not monitor_dirs:
|
||||
return
|
||||
@@ -121,6 +132,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:
|
||||
@@ -130,53 +147,111 @@ 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
|
||||
|
||||
# 检查媒体库目录是不是下载目录的子目录
|
||||
try:
|
||||
if target_path.is_relative_to(Path(mon_path)):
|
||||
logger.warn(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
|
||||
self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(str(e))
|
||||
pass
|
||||
# 转移方式
|
||||
self._transferconf[mon_path] = _transfer_type
|
||||
|
||||
try:
|
||||
if self._mode == "compatibility":
|
||||
# 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
|
||||
observer = PollingObserver(timeout=10)
|
||||
else:
|
||||
# 内部处理系统操作类型选择最优解
|
||||
observer = Observer(timeout=10)
|
||||
self._observer.append(observer)
|
||||
observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True)
|
||||
observer.daemon = True
|
||||
observer.start()
|
||||
logger.info(f"{mon_path} 的目录监控服务启动")
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
if "inotify" in err_msg and "reached" in err_msg:
|
||||
logger.warn(
|
||||
f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
|
||||
+ """
|
||||
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
|
||||
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
|
||||
sudo sysctl -p
|
||||
""")
|
||||
else:
|
||||
logger.error(f"{mon_path} 启动目录监控失败:{err_msg}")
|
||||
self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}")
|
||||
# 启用目录监控
|
||||
if self._enabled:
|
||||
# 检查媒体库目录是不是下载目录的子目录
|
||||
try:
|
||||
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
|
||||
except Exception as e:
|
||||
logger.debug(str(e))
|
||||
pass
|
||||
|
||||
# 追加入库消息统一发送服务
|
||||
self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15)
|
||||
# 启动服务
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
try:
|
||||
if self._mode == "compatibility":
|
||||
# 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB
|
||||
observer = PollingObserver(timeout=10)
|
||||
else:
|
||||
# 内部处理系统操作类型选择最优解
|
||||
observer = Observer(timeout=10)
|
||||
self._observer.append(observer)
|
||||
observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True)
|
||||
observer.daemon = True
|
||||
observer.start()
|
||||
logger.info(f"{mon_path} 的目录监控服务启动")
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
if "inotify" in err_msg and "reached" in err_msg:
|
||||
logger.warn(
|
||||
f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:"
|
||||
+ """
|
||||
echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf
|
||||
echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf
|
||||
sudo sysctl -p
|
||||
""")
|
||||
else:
|
||||
logger.error(f"{mon_path} 启动目录监控失败:{err_msg}")
|
||||
self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}")
|
||||
|
||||
# 运行一次定时服务
|
||||
if self._onlyonce:
|
||||
logger.info("目录监控服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.sync_all, trigger='date',
|
||||
run_date=datetime.datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
|
||||
)
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
# 保存配置
|
||||
self.__update_config()
|
||||
|
||||
# 启动定时服务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __update_config(self):
|
||||
"""
|
||||
更新配置
|
||||
"""
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
"notify": self._notify,
|
||||
"onlyonce": self._onlyonce,
|
||||
"mode": self._mode,
|
||||
"transfer_type": self._transfer_type,
|
||||
"monitor_dirs": self._monitor_dirs,
|
||||
"exclude_keywords": self._exclude_keywords
|
||||
})
|
||||
|
||||
@eventmanager.register(EventType.DirectorySync)
|
||||
def remote_sync(self, event: Event):
|
||||
"""
|
||||
远程全量同步
|
||||
"""
|
||||
if event:
|
||||
self.post_message(channel=event.event_data.get("channel"),
|
||||
title="开始同步监控目录 ...",
|
||||
userid=event.event_data.get("user"))
|
||||
self.sync_all()
|
||||
if event:
|
||||
self.post_message(channel=event.event_data.get("channel"),
|
||||
title="监控目录同步完成!", userid=event.event_data.get("user"))
|
||||
|
||||
def sync_all(self):
|
||||
"""
|
||||
立即运行一次,全量同步目录中所有文件
|
||||
"""
|
||||
logger.info("开始全量同步监控目录 ...")
|
||||
# 遍历所有监控目录
|
||||
for mon_path in self._dirconf.keys():
|
||||
# 遍历目录下所有文件
|
||||
for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT):
|
||||
self.__handle_file(event_path=str(file_path), mon_path=mon_path)
|
||||
logger.info("全量同步监控目录完成!")
|
||||
|
||||
def event_handler(self, event, mon_path: str, text: str, event_path: str):
|
||||
"""
|
||||
@@ -188,222 +263,249 @@ class DirMonitor(_PluginBase):
|
||||
"""
|
||||
if not event.is_directory:
|
||||
# 文件发生变化
|
||||
file_path = Path(event_path)
|
||||
try:
|
||||
if not file_path.exists():
|
||||
logger.debug("文件%s:%s" % (text, event_path))
|
||||
self.__handle_file(event_path=event_path, mon_path=mon_path)
|
||||
|
||||
def __handle_file(self, event_path: str, mon_path: str):
|
||||
"""
|
||||
同步一个文件
|
||||
:param event_path: 事件文件路径
|
||||
:param mon_path: 监控目录
|
||||
"""
|
||||
file_path = Path(event_path)
|
||||
try:
|
||||
if not file_path.exists():
|
||||
return
|
||||
# 全程加锁
|
||||
with lock:
|
||||
transfer_history = self.transferhis.get_by_src(event_path)
|
||||
if transfer_history:
|
||||
logger.debug("文件已处理过:%s" % event_path)
|
||||
return
|
||||
|
||||
logger.debug("文件%s:%s" % (text, event_path))
|
||||
# 回收站及隐藏的文件不处理
|
||||
if event_path.find('/@Recycle/') != -1 \
|
||||
or event_path.find('/#recycle/') != -1 \
|
||||
or event_path.find('/.') != -1 \
|
||||
or event_path.find('/@eaDir') != -1:
|
||||
logger.debug(f"{event_path} 是回收站或隐藏的文件")
|
||||
return
|
||||
|
||||
# 全程加锁
|
||||
with lock:
|
||||
transfer_history = self.transferhis.get_by_src(event_path)
|
||||
if transfer_history:
|
||||
logger.debug("文件已处理过:%s" % event_path)
|
||||
return
|
||||
# 命中过滤关键字不处理
|
||||
if self._exclude_keywords:
|
||||
for keyword in self._exclude_keywords.split("\n"):
|
||||
if keyword and re.findall(keyword, event_path):
|
||||
logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
|
||||
return
|
||||
|
||||
# 回收站及隐藏的文件不处理
|
||||
if event_path.find('/@Recycle/') != -1 \
|
||||
or event_path.find('/#recycle/') != -1 \
|
||||
or event_path.find('/.') != -1 \
|
||||
or event_path.find('/@eaDir') != -1:
|
||||
logger.debug(f"{event_path} 是回收站或隐藏的文件")
|
||||
return
|
||||
# 整理屏蔽词不处理
|
||||
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
|
||||
logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
|
||||
return
|
||||
|
||||
# 命中过滤关键字不处理
|
||||
if self._exclude_keywords:
|
||||
for keyword in self._exclude_keywords.split("\n"):
|
||||
if keyword and re.findall(keyword, event_path):
|
||||
logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理")
|
||||
return
|
||||
# 不是媒体文件不处理
|
||||
if file_path.suffix not in settings.RMT_MEDIAEXT:
|
||||
logger.debug(f"{event_path} 不是媒体文件")
|
||||
return
|
||||
|
||||
# 整理屏蔽词不处理
|
||||
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
|
||||
if transfer_exclude_words:
|
||||
for keyword in transfer_exclude_words:
|
||||
if not keyword:
|
||||
continue
|
||||
if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE):
|
||||
logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理")
|
||||
return
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
if not file_meta.name:
|
||||
logger.error(f"{file_path.name} 无法识别有效信息")
|
||||
return
|
||||
|
||||
# 不是媒体文件不处理
|
||||
if file_path.suffix not in settings.RMT_MEDIAEXT:
|
||||
logger.debug(f"{event_path} 不是媒体文件")
|
||||
return
|
||||
# 判断是不是蓝光目录
|
||||
if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE):
|
||||
# 截取BDMV前面的路径
|
||||
event_path = event_path[:event_path.find("BDMV")]
|
||||
file_path = Path(event_path)
|
||||
|
||||
# 查询历史记录,已转移的不处理
|
||||
if self.transferhis.get_by_src(event_path):
|
||||
logger.info(f"{event_path} 已整理过")
|
||||
return
|
||||
# 查询历史记录,已转移的不处理
|
||||
if self.transferhis.get_by_src(event_path):
|
||||
logger.info(f"{event_path} 已整理过")
|
||||
return
|
||||
|
||||
# 元数据
|
||||
file_meta = MetaInfoPath(file_path)
|
||||
if not file_meta.name:
|
||||
logger.error(f"{file_path.name} 无法识别有效信息")
|
||||
return
|
||||
|
||||
# 查询转移目的目录
|
||||
target: Path = self._dirconf.get(mon_path)
|
||||
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{file_meta.name}')
|
||||
if self._notify:
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!"
|
||||
))
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=self._transfer_type,
|
||||
meta=file_meta
|
||||
)
|
||||
return
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
|
||||
|
||||
# 更新媒体图片
|
||||
self.chain.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 获取downloadhash
|
||||
download_hash = self.get_download_hash(src=str(file_path))
|
||||
|
||||
# 转移
|
||||
transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=self._transfer_type,
|
||||
target=target,
|
||||
meta=file_meta)
|
||||
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=self._transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
if self._notify:
|
||||
self.chain.post_message(Notification(
|
||||
title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!",
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=mediainfo.get_message_image()
|
||||
))
|
||||
return
|
||||
# 查询转移目的目录
|
||||
target: Path = self._dirconf.get(mon_path)
|
||||
# 查询转移方式
|
||||
transfer_type = self._transferconf.get(mon_path)
|
||||
# 根据父路径获取下载历史
|
||||
download_history = self.downloadhis.get_by_path(Path(event_path).parent)
|
||||
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.chain.recognize_media(meta=file_meta,
|
||||
tmdbid=download_history.tmdbid if download_history else None)
|
||||
if not mediainfo:
|
||||
logger.warn(f'未识别到媒体信息,标题:{file_meta.name}')
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
his = self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=self._transfer_type,
|
||||
mode=transfer_type,
|
||||
meta=file_meta
|
||||
)
|
||||
if self._notify:
|
||||
self.chain.post_message(Notification(
|
||||
mtype=NotificationType.Manual,
|
||||
title=f"{file_path.name} 未识别到媒体信息,无法入库!\n"
|
||||
f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。"
|
||||
))
|
||||
return
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title
|
||||
if not settings.SCRAP_FOLLOW_TMDB:
|
||||
transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}")
|
||||
|
||||
# 更新媒体图片
|
||||
self.chain.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 获取集数据
|
||||
if mediainfo.type == MediaType.TV:
|
||||
episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id,
|
||||
season=file_meta.begin_season or 1)
|
||||
else:
|
||||
episodes_info = None
|
||||
|
||||
# 获取downloadhash
|
||||
download_hash = self.get_download_hash(src=str(file_path))
|
||||
|
||||
# 转移
|
||||
transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo,
|
||||
path=file_path,
|
||||
transfer_type=transfer_type,
|
||||
target=target,
|
||||
meta=file_meta,
|
||||
episodes_info=episodes_info)
|
||||
|
||||
if not transferinfo:
|
||||
logger.error("文件转移模块运行失败")
|
||||
return
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
src_path=file_path,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
if self._notify:
|
||||
self.chain.post_message(Notification(
|
||||
title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!",
|
||||
text=f"原因:{transferinfo.message or '未知'}",
|
||||
image=mediainfo.get_message_image()
|
||||
))
|
||||
return
|
||||
|
||||
# 刮削单个文件
|
||||
if settings.SCRAP_METADATA:
|
||||
self.chain.scrape_metadata(path=transferinfo.target_path,
|
||||
mediainfo=mediainfo)
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
src_path=file_path,
|
||||
mode=transfer_type,
|
||||
download_hash=download_hash,
|
||||
meta=file_meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo
|
||||
)
|
||||
|
||||
"""
|
||||
{
|
||||
"title_year season": {
|
||||
"files": [
|
||||
{
|
||||
"path":,
|
||||
"mediainfo":,
|
||||
"file_meta":,
|
||||
"transferinfo":
|
||||
}
|
||||
],
|
||||
"time": "2023-08-24 23:23:23.332"
|
||||
}
|
||||
# 刮削单个文件
|
||||
if settings.SCRAP_METADATA:
|
||||
self.chain.scrape_metadata(path=transferinfo.target_path,
|
||||
mediainfo=mediainfo,
|
||||
transfer_type=transfer_type)
|
||||
|
||||
"""
|
||||
{
|
||||
"title_year season": {
|
||||
"files": [
|
||||
{
|
||||
"path":,
|
||||
"mediainfo":,
|
||||
"file_meta":,
|
||||
"transferinfo":
|
||||
}
|
||||
],
|
||||
"time": "2023-08-24 23:23:23.332"
|
||||
}
|
||||
"""
|
||||
# 发送消息汇总
|
||||
media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {}
|
||||
if media_list:
|
||||
media_files = media_list.get("files") or []
|
||||
if media_files:
|
||||
file_exists = False
|
||||
for file in media_files:
|
||||
if str(event_path) == file.get("path"):
|
||||
file_exists = True
|
||||
break
|
||||
if not file_exists:
|
||||
media_files.append({
|
||||
"path": event_path,
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
})
|
||||
else:
|
||||
media_files = [
|
||||
{
|
||||
"path": event_path,
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
}
|
||||
]
|
||||
media_list = {
|
||||
"files": media_files,
|
||||
"time": datetime.now()
|
||||
}
|
||||
else:
|
||||
media_list = {
|
||||
"files": [
|
||||
}
|
||||
"""
|
||||
# 发送消息汇总
|
||||
media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {}
|
||||
if media_list:
|
||||
media_files = media_list.get("files") or []
|
||||
if media_files:
|
||||
file_exists = False
|
||||
for file in media_files:
|
||||
if str(event_path) == file.get("path"):
|
||||
file_exists = True
|
||||
break
|
||||
if not file_exists:
|
||||
media_files.append({
|
||||
"path": event_path,
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
})
|
||||
else:
|
||||
media_files = [
|
||||
{
|
||||
"path": event_path,
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
}
|
||||
],
|
||||
"time": datetime.now()
|
||||
}
|
||||
self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
|
||||
]
|
||||
media_list = {
|
||||
"files": media_files,
|
||||
"time": datetime.datetime.now()
|
||||
}
|
||||
else:
|
||||
media_list = {
|
||||
"files": [
|
||||
{
|
||||
"path": event_path,
|
||||
"mediainfo": mediainfo,
|
||||
"file_meta": file_meta,
|
||||
"transferinfo": transferinfo
|
||||
}
|
||||
],
|
||||
"time": datetime.datetime.now()
|
||||
}
|
||||
self._medias[mediainfo.title_year + " " + file_meta.season] = media_list
|
||||
|
||||
# 汇总刷新媒体库
|
||||
if settings.REFRESH_MEDIASERVER:
|
||||
self.chain.refresh_mediaserver(mediainfo=mediainfo, file_path=transferinfo.target_path)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.TransferComplete, {
|
||||
'meta': file_meta,
|
||||
'mediainfo': mediainfo,
|
||||
'transferinfo': transferinfo
|
||||
})
|
||||
# 汇总刷新媒体库
|
||||
if settings.REFRESH_MEDIASERVER:
|
||||
self.chain.refresh_mediaserver(mediainfo=mediainfo, file_path=transferinfo.target_path)
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(EventType.TransferComplete, {
|
||||
'meta': file_meta,
|
||||
'mediainfo': mediainfo,
|
||||
'transferinfo': transferinfo
|
||||
})
|
||||
|
||||
# 移动模式删除空目录
|
||||
if self._transfer_type == "move":
|
||||
for file_dir in file_path.parents:
|
||||
if len(str(file_dir)) <= len(str(Path(mon_path))):
|
||||
# 重要,删除到监控目录为止
|
||||
break
|
||||
files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT)
|
||||
if not files:
|
||||
logger.warn(f"移动模式,删除空目录:{file_dir}")
|
||||
shutil.rmtree(file_dir, ignore_errors=True)
|
||||
# 移动模式删除空目录
|
||||
if transfer_type == "move":
|
||||
for file_dir in file_path.parents:
|
||||
if len(str(file_dir)) <= len(str(Path(mon_path))):
|
||||
# 重要,删除到监控目录为止
|
||||
break
|
||||
files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT)
|
||||
if not files:
|
||||
logger.warn(f"移动模式,删除空目录:{file_dir}")
|
||||
shutil.rmtree(file_dir, ignore_errors=True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
|
||||
except Exception as e:
|
||||
logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc()))
|
||||
|
||||
def send_msg(self):
|
||||
"""
|
||||
@@ -430,7 +532,7 @@ class DirMonitor(_PluginBase):
|
||||
file_meta = media_files[0].get("file_meta")
|
||||
mediainfo = media_files[0].get("mediainfo")
|
||||
# 判断最后更新时间距现在是已超过5秒,超过则发送消息
|
||||
if (datetime.now() - last_update_time).total_seconds() > 5:
|
||||
if (datetime.datetime.now() - last_update_time).total_seconds() > 5:
|
||||
# 发送通知
|
||||
if self._notify:
|
||||
|
||||
@@ -482,7 +584,17 @@ class DirMonitor(_PluginBase):
|
||||
|
||||
@staticmethod
|
||||
def get_command() -> List[Dict[str, Any]]:
|
||||
pass
|
||||
"""
|
||||
定义远程控制命令
|
||||
:return: 命令关键字、事件、描述、附带数据
|
||||
"""
|
||||
return [{
|
||||
"cmd": "/directory_sync",
|
||||
"event": EventType.DirectorySync,
|
||||
"desc": "目录监控同步",
|
||||
"category": "管理",
|
||||
"data": {}
|
||||
}]
|
||||
|
||||
def get_api(self) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
@@ -499,7 +611,7 @@ class DirMonitor(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -515,7 +627,7 @@ class DirMonitor(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -526,6 +638,22 @@ class DirMonitor(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -591,9 +719,11 @@ class DirMonitor(_PluginBase):
|
||||
'model': 'monitor_dirs',
|
||||
'label': '监控目录',
|
||||
'rows': 5,
|
||||
'placeholder': '每一行一个目录,支持两种配置方式:\n'
|
||||
'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n'
|
||||
'监控目录\n'
|
||||
'监控目录:转移目的目录(需同时在媒体库目录中配置该目的目录)'
|
||||
'监控目录#转移方式\n'
|
||||
'监控目录:转移目的目录\n'
|
||||
'监控目录:转移目的目录#转移方式'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -627,6 +757,7 @@ class DirMonitor(_PluginBase):
|
||||
], {
|
||||
"enabled": False,
|
||||
"notify": False,
|
||||
"onlyonce": False,
|
||||
"mode": "fast",
|
||||
"transfer_type": settings.TRANSFER_TYPE,
|
||||
"monitor_dirs": "",
|
||||
|
||||
@@ -4,6 +4,7 @@ import xml.dom.minidom
|
||||
from threading import Event
|
||||
from typing import Tuple, List, Dict, Any, Optional
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
@@ -100,11 +101,19 @@ class DoubanRank(_PluginBase):
|
||||
logger.error(f"豆瓣榜单订阅服务启动失败,错误信息:{str(e)}")
|
||||
self.systemmessage.put(f"豆瓣榜单订阅服务启动失败,错误信息:{str(e)}")
|
||||
else:
|
||||
self._scheduler.add_job(func=self.__refresh_rss,
|
||||
trigger=CronTrigger.from_crontab("0 8 * * *"),
|
||||
name="豆瓣榜单订阅")
|
||||
self._scheduler.add_job(func=self.__refresh_rss, trigger='date',
|
||||
run_date=datetime.datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
|
||||
)
|
||||
logger.info("豆瓣榜单订阅服务启动,周期:每天 08:00")
|
||||
|
||||
if self._onlyonce:
|
||||
logger.info("豆瓣榜单订阅服务启动,立即运行一次")
|
||||
self._scheduler.add_job(func=self.__refresh_rss, trigger='date',
|
||||
run_date=datetime.datetime.now(
|
||||
tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3)
|
||||
)
|
||||
|
||||
if self._onlyonce or self._clear:
|
||||
# 关闭一次性开关
|
||||
self._onlyonce = False
|
||||
|
||||
@@ -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)
|
||||
|
||||
324
app/plugins/downloadingmsg/__init__.py
Normal file
324
app/plugins/downloadingmsg/__init__.py
Normal file
@@ -0,0 +1,324 @@
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import settings
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.plugins import _PluginBase
|
||||
from typing import Any, List, Dict, Tuple, Optional, Union
|
||||
from app.log import logger
|
||||
from app.schemas import NotificationType, TransferTorrent, DownloadingTorrent
|
||||
from app.schemas.types import TorrentStatus, MessageChannel
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class DownloadingMsg(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "下载进度推送"
|
||||
# 插件描述
|
||||
plugin_desc = "定时推送正在下载进度。"
|
||||
# 插件图标
|
||||
plugin_icon = "downloadmsg.png"
|
||||
# 主题色
|
||||
plugin_color = "#3DE75D"
|
||||
# 插件版本
|
||||
plugin_version = "1.0"
|
||||
# 插件作者
|
||||
plugin_author = "thsrite"
|
||||
# 作者主页
|
||||
author_url = "https://github.com/thsrite"
|
||||
# 插件配置项ID前缀
|
||||
plugin_config_prefix = "downloading_"
|
||||
# 加载顺序
|
||||
plugin_order = 22
|
||||
# 可使用的用户级别
|
||||
auth_level = 2
|
||||
|
||||
# 私有属性
|
||||
_enabled = False
|
||||
# 任务执行间隔
|
||||
_seconds = None
|
||||
_type = None
|
||||
_adminuser = None
|
||||
_downloadhis = None
|
||||
|
||||
# 定时器
|
||||
_scheduler: Optional[BackgroundScheduler] = None
|
||||
|
||||
def init_plugin(self, config: dict = None):
|
||||
# 停止现有任务
|
||||
self.stop_service()
|
||||
|
||||
if config:
|
||||
self._enabled = config.get("enabled")
|
||||
self._seconds = config.get("seconds") or 300
|
||||
self._type = config.get("type") or 'admin'
|
||||
self._adminuser = config.get("adminuser")
|
||||
|
||||
# 加载模块
|
||||
if self._enabled:
|
||||
self._downloadhis = DownloadHistoryOper(self.db)
|
||||
# 定时服务
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
|
||||
if self._seconds:
|
||||
try:
|
||||
self._scheduler.add_job(func=self.__downloading,
|
||||
trigger='interval',
|
||||
seconds=int(self._seconds),
|
||||
name="下载进度推送")
|
||||
except Exception as err:
|
||||
logger.error(f"定时任务配置错误:{err}")
|
||||
|
||||
# 启动任务
|
||||
if self._scheduler.get_jobs():
|
||||
self._scheduler.print_jobs()
|
||||
self._scheduler.start()
|
||||
|
||||
def __downloading(self):
|
||||
"""
|
||||
定时推送正在下载进度
|
||||
"""
|
||||
# 正在下载种子
|
||||
torrents = DownloadChain(self.db).list_torrents(status=TorrentStatus.DOWNLOADING)
|
||||
if not torrents:
|
||||
logger.info("当前没有正在下载的任务!")
|
||||
return
|
||||
# 推送用户
|
||||
if self._type == "admin" or self._type == "both":
|
||||
if not self._adminuser:
|
||||
logger.error("未配置管理员用户")
|
||||
return
|
||||
|
||||
for userid in str(self._adminuser).split(","):
|
||||
self.__send_msg(torrents=torrents, userid=userid)
|
||||
|
||||
if self._type == "user" or self._type == "both":
|
||||
user_torrents = {}
|
||||
# 根据正在下载种子hash获取下载历史
|
||||
for torrent in torrents:
|
||||
downloadhis = self._downloadhis.get_by_hash(download_hash=torrent.hash)
|
||||
if not downloadhis:
|
||||
logger.warn(f"种子 {torrent.hash} 未获取到MoviePilot下载历史,无法推送下载进度")
|
||||
continue
|
||||
if not downloadhis.userid:
|
||||
logger.debug(f"种子 {torrent.hash} 未获取到下载用户记录,无法推送下载进度")
|
||||
continue
|
||||
user_torrent = user_torrents.get(downloadhis.userid) or []
|
||||
user_torrent.append(torrent)
|
||||
user_torrents[downloadhis.userid] = user_torrent
|
||||
|
||||
if not user_torrents or not user_torrents.keys():
|
||||
logger.warn("未获取到用户下载记录,无法推送下载进度")
|
||||
return
|
||||
|
||||
# 推送用户下载任务进度
|
||||
for userid in list(user_torrents.keys()):
|
||||
if not userid:
|
||||
continue
|
||||
# 如果用户是管理员,无需重复推送
|
||||
if (self._type == "admin" or self._type == "both") and self._adminuser and userid in str(
|
||||
self._adminuser).split(","):
|
||||
logger.debug("管理员已推送")
|
||||
continue
|
||||
|
||||
user_torrent = user_torrents.get(userid)
|
||||
if not user_torrent:
|
||||
logger.warn(f"未获取到用户 {userid} 下载任务")
|
||||
continue
|
||||
self.__send_msg(torrents=user_torrent,
|
||||
userid=userid)
|
||||
|
||||
if self._type == "all":
|
||||
self.__send_msg(torrents=torrents)
|
||||
|
||||
def __send_msg(self, torrents: Optional[List[Union[TransferTorrent, DownloadingTorrent]]], userid: str = None):
|
||||
"""
|
||||
发送消息
|
||||
"""
|
||||
title = f"共 {len(torrents)} 个任务正在下载:"
|
||||
messages = []
|
||||
index = 1
|
||||
channel_value = None
|
||||
for torrent in torrents:
|
||||
year = None
|
||||
name = None
|
||||
se = None
|
||||
ep = None
|
||||
# 先查询下载记录,没有再识别
|
||||
downloadhis = self._downloadhis.get_by_hash(download_hash=torrent.hash)
|
||||
if downloadhis:
|
||||
name = downloadhis.title
|
||||
year = downloadhis.year
|
||||
se = downloadhis.seasons
|
||||
ep = downloadhis.episodes
|
||||
if not channel_value:
|
||||
channel_value = downloadhis.channel
|
||||
else:
|
||||
try:
|
||||
context = MediaChain().recognize_by_title(title=torrent.title)
|
||||
if not context or not context.media_info:
|
||||
continue
|
||||
media_info = context.media_info
|
||||
year = media_info.year
|
||||
name = media_info.title
|
||||
if media_info.number_of_seasons:
|
||||
se = f"S{str(media_info.number_of_seasons).rjust(2, '0')}"
|
||||
if media_info.number_of_episodes:
|
||||
ep = f"E{str(media_info.number_of_episodes).rjust(2, '0')}"
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
# 拼装标题
|
||||
if year:
|
||||
media_name = "%s (%s) %s%s" % (name, year, se, ep)
|
||||
elif name:
|
||||
media_name = "%s %s%s" % (name, se, ep)
|
||||
else:
|
||||
media_name = torrent.title
|
||||
|
||||
if not self._adminuser or userid not in str(self._adminuser).split(","):
|
||||
# 下载用户发送精简消息
|
||||
messages.append(f"{index}. {media_name} {round(torrent.progress, 1)}%")
|
||||
else:
|
||||
messages.append(f"{index}. {media_name}\n"
|
||||
f"{torrent.title} "
|
||||
f"{StringUtils.str_filesize(torrent.size)} "
|
||||
f"{round(torrent.progress, 1)}%")
|
||||
index += 1
|
||||
|
||||
# 用户消息渠道
|
||||
if channel_value:
|
||||
channel = next(
|
||||
(channel for channel in MessageChannel.__members__.values() if channel.value == channel_value), None)
|
||||
else:
|
||||
channel = None
|
||||
self.post_message(mtype=NotificationType.Download,
|
||||
channel=channel,
|
||||
title=title,
|
||||
text="\n".join(messages),
|
||||
userid=userid)
|
||||
|
||||
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
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'enabled',
|
||||
'label': '启用插件',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VRow',
|
||||
'content': [
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'seconds',
|
||||
'label': '执行间隔',
|
||||
'placeholder': '单位(秒)'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'adminuser',
|
||||
'label': '管理员用户',
|
||||
'placeholder': '多个用户,分割'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'type',
|
||||
'label': '推送类型',
|
||||
'items': [
|
||||
{'title': '管理员', 'value': 'admin'},
|
||||
{'title': '下载用户', 'value': 'user'},
|
||||
{'title': '管理员和下载用户', 'value': 'both'},
|
||||
{'title': '所有用户', 'value': 'all'}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
], {
|
||||
"enabled": False,
|
||||
"seconds": 300,
|
||||
"adminuser": "",
|
||||
"type": "admin"
|
||||
}
|
||||
|
||||
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))
|
||||
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 []
|
||||
|
||||
|
||||
@@ -390,7 +390,7 @@ class LibraryScraper(_PluginBase):
|
||||
|
||||
# 刮削单个文件
|
||||
if scrap_metadata:
|
||||
self.chain.scrape_metadata(path=file, mediainfo=mediainfo)
|
||||
self.chain.scrape_metadata(path=file, mediainfo=mediainfo, transfer_type=settings.TRANSFER_TYPE)
|
||||
|
||||
@staticmethod
|
||||
def __get_tmdbid_from_nfo(file_path: Path):
|
||||
|
||||
@@ -25,9 +25,9 @@ from app.schemas.types import NotificationType, EventType, MediaType
|
||||
|
||||
class MediaSyncDel(_PluginBase):
|
||||
# 插件名称
|
||||
plugin_name = "媒体库同步删除"
|
||||
plugin_name = "媒体文件同步删除"
|
||||
# 插件描述
|
||||
plugin_desc = "媒体库删除媒体后同步删除历史记录、源文件和下载任务。"
|
||||
plugin_desc = "同步删除历史记录、源文件和下载任务。"
|
||||
# 插件图标
|
||||
plugin_icon = "mediasyncdel.png"
|
||||
# 主题色
|
||||
@@ -187,9 +187,9 @@ class MediaSyncDel(_PluginBase):
|
||||
'component': 'VSelect',
|
||||
'props': {
|
||||
'model': 'sync_type',
|
||||
'label': '同步方式',
|
||||
'label': '媒体库同步方式',
|
||||
'items': [
|
||||
{'title': 'webhook', 'value': 'webhook'},
|
||||
{'title': 'Webhook', 'value': 'webhook'},
|
||||
{'title': '日志', 'value': 'log'},
|
||||
{'title': 'Scripter X', 'value': 'plugin'}
|
||||
]
|
||||
@@ -208,7 +208,7 @@ class MediaSyncDel(_PluginBase):
|
||||
'component': 'VTextField',
|
||||
'props': {
|
||||
'model': 'cron',
|
||||
'label': '执行周期',
|
||||
'label': '日志检查周期',
|
||||
'placeholder': '5位cron表达式,留空自动'
|
||||
}
|
||||
}
|
||||
@@ -246,7 +246,7 @@ class MediaSyncDel(_PluginBase):
|
||||
'props': {
|
||||
'model': 'library_path',
|
||||
'rows': '2',
|
||||
'label': '媒体库路径',
|
||||
'label': '媒体库路径映射',
|
||||
'placeholder': '媒体服务器路径:MoviePilot路径(一行一个)'
|
||||
}
|
||||
}
|
||||
@@ -266,11 +266,11 @@ class MediaSyncDel(_PluginBase):
|
||||
{
|
||||
'component': 'VAlert',
|
||||
'props': {
|
||||
'text': '同步方式分为webhook、日志同步和Scripter X。'
|
||||
'webhook需要Emby4.8.0.45及以上开启媒体删除的webhook'
|
||||
'(建议使用媒体库刮削插件覆盖元数据重新刮削剧集路径)。'
|
||||
'日志同步需要配置执行周期,默认30分钟执行一次。'
|
||||
'Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。'
|
||||
'text': '媒体库同步方式分为Webhook、日志同步和Scripter X:'
|
||||
'1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook。'
|
||||
'2、日志同步需要配置检查周期,默认30分钟执行一次。'
|
||||
'3、Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。'
|
||||
'4、启用该插件后,非媒体服务器触发的源文件删除,也会同步处理下载器中的下载任务。'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -673,6 +673,8 @@ class MediaSyncDel(_PluginBase):
|
||||
paths = self._library_path.split("\n")
|
||||
for path in paths:
|
||||
sub_paths = path.split(":")
|
||||
if len(sub_paths) < 2:
|
||||
continue
|
||||
media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
|
||||
|
||||
# 删除电影
|
||||
@@ -739,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")
|
||||
# 媒体名称 蜀山战纪
|
||||
@@ -765,6 +771,8 @@ class MediaSyncDel(_PluginBase):
|
||||
paths = self._library_path.split("\n")
|
||||
for path in paths:
|
||||
sub_paths = path.split(":")
|
||||
if len(sub_paths) < 2:
|
||||
continue
|
||||
media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/')
|
||||
|
||||
# 获取删除的记录
|
||||
@@ -877,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):
|
||||
"""
|
||||
@@ -1005,184 +1013,247 @@ class MediaSyncDel(_PluginBase):
|
||||
if not isinstance(torrents, list):
|
||||
torrents = [torrents]
|
||||
|
||||
# 删除辅种历史中与本下载器相同的辅种记录
|
||||
if str(downloader) == str(download):
|
||||
for torrent in torrents:
|
||||
handle_cnt += 1
|
||||
if str(download) == "qbittorrent":
|
||||
# 删除辅种
|
||||
if action_flag == "del":
|
||||
logger.info(f"删除辅种:{downloader} - {torrent}")
|
||||
self.qb.delete_torrents(delete_file=True,
|
||||
ids=torrent)
|
||||
# 暂停辅种
|
||||
if action_flag == "stop":
|
||||
self.qb.stop_torrents(torrent)
|
||||
logger.info(f"辅种:{downloader} - {torrent} 暂停")
|
||||
else:
|
||||
# 删除辅种
|
||||
if action_flag == "del":
|
||||
logger.info(f"删除辅种:{downloader} - {torrent}")
|
||||
self.tr.delete_torrents(delete_file=True,
|
||||
ids=torrent)
|
||||
# 暂停辅种
|
||||
if action_flag == "stop":
|
||||
self.tr.stop_torrents(torrent)
|
||||
logger.info(f"辅种:{downloader} - {torrent} 暂停")
|
||||
# 删除本下载器辅种历史
|
||||
if action_flag == "del":
|
||||
del history
|
||||
break
|
||||
# 删除辅种历史
|
||||
for torrent in torrents:
|
||||
handle_cnt += 1
|
||||
if str(download) == "qbittorrent":
|
||||
# 删除辅种
|
||||
if action_flag == "del":
|
||||
logger.info(f"删除辅种:{downloader} - {torrent}")
|
||||
self.qb.delete_torrents(delete_file=True,
|
||||
ids=torrent)
|
||||
# 暂停辅种
|
||||
if action_flag == "stop":
|
||||
self.qb.stop_torrents(torrent)
|
||||
logger.info(f"辅种:{downloader} - {torrent} 暂停")
|
||||
else:
|
||||
# 删除辅种
|
||||
if action_flag == "del":
|
||||
logger.info(f"删除辅种:{downloader} - {torrent}")
|
||||
self.tr.delete_torrents(delete_file=True,
|
||||
ids=torrent)
|
||||
# 暂停辅种
|
||||
if action_flag == "stop":
|
||||
self.tr.stop_torrents(torrent)
|
||||
logger.info(f"辅种:{downloader} - {torrent} 暂停")
|
||||
# 删除本下载器辅种历史
|
||||
if action_flag == "del":
|
||||
del history
|
||||
break
|
||||
|
||||
# 更新辅种历史
|
||||
self.save_data(key=history_key,
|
||||
value=seed_history,
|
||||
plugin_id=plugin_id)
|
||||
|
||||
if seed_history:
|
||||
self.save_data(key=history_key,
|
||||
value=seed_history,
|
||||
plugin_id=plugin_id)
|
||||
else:
|
||||
self.del_data(key=history_key,
|
||||
plugin_id=plugin_id)
|
||||
return handle_cnt
|
||||
|
||||
@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
|
||||
|
||||
@@ -1202,21 +1273,23 @@ class MediaSyncDel(_PluginBase):
|
||||
except Exception as e:
|
||||
logger.error("退出插件失败:%s" % str(e))
|
||||
|
||||
@eventmanager.register(EventType.MediaDeleted)
|
||||
def remote_sync_del(self, event: Event):
|
||||
@eventmanager.register(EventType.DownloadFileDeleted)
|
||||
def downloadfile_del_sync(self, event: Event):
|
||||
"""
|
||||
媒体库同步删除
|
||||
下载文件删除处理事件
|
||||
"""
|
||||
if event:
|
||||
logger.info("收到命令,开始执行媒体库同步删除 ...")
|
||||
self.post_message(channel=event.event_data.get("channel"),
|
||||
title="开始媒体库同步删除 ...",
|
||||
userid=event.event_data.get("user"))
|
||||
self.sync_del_by_log()
|
||||
|
||||
if event:
|
||||
self.post_message(channel=event.event_data.get("channel"),
|
||||
title="媒体库同步删除完成!", userid=event.event_data.get("user"))
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data
|
||||
src = event_data.get("src")
|
||||
if not src:
|
||||
return
|
||||
# 查询下载hash
|
||||
download_hash = self._downloadhis.get_hash_by_fullpath(src)
|
||||
if download_hash:
|
||||
self.handle_torrent(src=src, torrent_hash=download_hash)
|
||||
else:
|
||||
logger.warn(f"未查询到文件 {src} 对应的下载记录")
|
||||
|
||||
@staticmethod
|
||||
def get_tmdbimage_url(path: str, prefix="w500"):
|
||||
|
||||
@@ -325,6 +325,9 @@ class MessageForward(_PluginBase):
|
||||
logger.info(f"转发消息 {title} 成功")
|
||||
return True
|
||||
else:
|
||||
if ret_json.get('errcode') == 81013:
|
||||
return False
|
||||
|
||||
logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}")
|
||||
if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014:
|
||||
logger.info("token已过期,正在重新刷新token重试")
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
@@ -138,42 +139,61 @@ class NAStoolSync(_PluginBase):
|
||||
plugin_key = history[2]
|
||||
plugin_value = history[3]
|
||||
|
||||
# 处理下载器映射
|
||||
if self._downloader:
|
||||
downloaders = self._downloader.split("\n")
|
||||
for downloader in downloaders:
|
||||
if not downloader:
|
||||
continue
|
||||
sub_downloaders = downloader.split(":")
|
||||
if not str(sub_downloaders[0]).isdigit():
|
||||
logger.error(f"下载器映射配置错误:NAStool下载器id 应为数字!")
|
||||
continue
|
||||
# 替换转种记录
|
||||
if str(plugin_id) == "TorrentTransfer":
|
||||
keys = str(plugin_key).split("-")
|
||||
if keys[0].isdigit() and int(keys[0]) == int(sub_downloaders[0]):
|
||||
# 替换key
|
||||
plugin_key = plugin_key.replace(keys[0], sub_downloaders[1])
|
||||
# 替换转种记录
|
||||
if str(plugin_id) == "TorrentTransfer":
|
||||
keys = str(plugin_key).split("-")
|
||||
|
||||
# 替换value
|
||||
if isinstance(plugin_value, str):
|
||||
plugin_value = json.loads(plugin_value)
|
||||
if str(plugin_value.get("to_download")).isdigit() and int(
|
||||
plugin_value.get("to_download")) == int(sub_downloaders[0]):
|
||||
plugin_value["to_download"] = sub_downloaders[1]
|
||||
# 1-2cd5d6fe32dca4e39a3e9f10961bfbdb00437e91
|
||||
if len(keys) == 2 and keys[0].isdigit():
|
||||
mp_downloader = self.__get_target_downloader(int(keys[0]))
|
||||
# 替换key
|
||||
plugin_key = mp_downloader + "-" + keys[1]
|
||||
|
||||
# 替换辅种记录
|
||||
if str(plugin_id) == "IYUUAutoSeed":
|
||||
if isinstance(plugin_value, str):
|
||||
plugin_value = json.loads(plugin_value)
|
||||
if not isinstance(plugin_value, list):
|
||||
plugin_value = [plugin_value]
|
||||
for value in plugin_value:
|
||||
if not str(value.get("downloader")).isdigit():
|
||||
continue
|
||||
if str(value.get("downloader")).isdigit() and int(value.get("downloader")) == int(
|
||||
sub_downloaders[0]):
|
||||
value["downloader"] = sub_downloaders[1]
|
||||
# 替换value
|
||||
"""
|
||||
{
|
||||
"to_download":2,
|
||||
"to_download_id":"2cd5d6fe32dca4e39a3e9f10961bfbdb00437e91",
|
||||
"delete_source":true
|
||||
}
|
||||
"""
|
||||
if isinstance(plugin_value, str):
|
||||
plugin_value: dict = json.loads(plugin_value)
|
||||
if isinstance(plugin_value, dict):
|
||||
if str(plugin_value.get("to_download")).isdigit():
|
||||
to_downloader = self.__get_target_downloader(int(plugin_value.get("to_download")))
|
||||
plugin_value["to_download"] = to_downloader
|
||||
|
||||
# 替换辅种记录
|
||||
elif str(plugin_id) == "IYUUAutoSeed":
|
||||
"""
|
||||
[
|
||||
{
|
||||
"downloader":"2",
|
||||
"torrents":[
|
||||
"a18aa62abab42613edba15e7dbad0d729d8500da",
|
||||
"e494f372316bbfd8572da80138a6ef4c491d5991",
|
||||
"cc2bbc1e654d8fc0f83297f6cd36a38805aa2864",
|
||||
"68aec0db3aa7fe28a887e5e41a0d0d5bc284910f",
|
||||
"f02962474287e11441e34e40b8326ddf28d034f6"
|
||||
]
|
||||
},
|
||||
{
|
||||
"downloader":"2",
|
||||
"torrents":[
|
||||
"4f042003ce90519e1aadd02b76f51c0c0711adb3"
|
||||
]
|
||||
}
|
||||
]
|
||||
"""
|
||||
if isinstance(plugin_value, str):
|
||||
plugin_value: list = json.loads(plugin_value)
|
||||
if not isinstance(plugin_value, list):
|
||||
plugin_value = [plugin_value]
|
||||
for value in plugin_value:
|
||||
if str(value.get("downloader")).isdigit():
|
||||
downloader = self.__get_target_downloader(int(value.get("downloader")))
|
||||
value["downloader"] = downloader
|
||||
|
||||
self._plugindata.save(plugin_id=plugin_id,
|
||||
key=plugin_key,
|
||||
@@ -187,6 +207,24 @@ class NAStoolSync(_PluginBase):
|
||||
|
||||
logger.info(f"插件记录已同步完成。总耗时 {(end_time - start_time).seconds} 秒")
|
||||
|
||||
def __get_target_downloader(self, download_id: int):
|
||||
"""
|
||||
获取NAStool下载器id对应的Moviepilot下载器
|
||||
"""
|
||||
# 处理下载器映射
|
||||
if self._downloader:
|
||||
downloaders = self._downloader.split("\n")
|
||||
for downloader in downloaders:
|
||||
if not downloader:
|
||||
continue
|
||||
sub_downloaders = downloader.split(":")
|
||||
if not str(sub_downloaders[0]).isdigit():
|
||||
logger.error(f"下载器映射配置错误:NAStool下载器id 应为数字!")
|
||||
continue
|
||||
if int(sub_downloaders[0]) == download_id:
|
||||
return str(sub_downloaders[1])
|
||||
return download_id
|
||||
|
||||
def sync_download_history(self, download_history):
|
||||
"""
|
||||
导入下载记录
|
||||
@@ -213,6 +251,7 @@ class NAStoolSync(_PluginBase):
|
||||
mtorrent = history[9]
|
||||
mdesc = history[10]
|
||||
msite = history[11]
|
||||
mdate = history[12]
|
||||
|
||||
# 处理站点映射
|
||||
if self._site:
|
||||
@@ -234,7 +273,9 @@ class NAStoolSync(_PluginBase):
|
||||
download_hash=mdownload_hash,
|
||||
torrent_name=mtorrent,
|
||||
torrent_description=mdesc,
|
||||
torrent_site=msite
|
||||
torrent_site=msite,
|
||||
userid=settings.SUPERUSER,
|
||||
date=mdate
|
||||
)
|
||||
cnt += 1
|
||||
if cnt % 100 == 0:
|
||||
@@ -358,7 +399,8 @@ class NAStoolSync(_PluginBase):
|
||||
DOWNLOAD_ID,
|
||||
TORRENT,
|
||||
DESC,
|
||||
SITE
|
||||
SITE,
|
||||
DATE
|
||||
FROM
|
||||
DOWNLOAD_HISTORY
|
||||
WHERE
|
||||
|
||||
1019
app/plugins/personmeta/__init__.py
Normal file
1019
app/plugins/personmeta/__init__.py
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user