mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-05-09 18:32:40 +08:00
Compare commits
233 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 |
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" ]
|
||||
|
||||
140
README.md
140
README.md
@@ -4,8 +4,6 @@
|
||||
|
||||
# 仅用于学习交流使用,请勿在任何国内平台宣传该项目!
|
||||
|
||||
Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
发布频道:https://t.me/moviepilot_channel
|
||||
|
||||
## 主要特性
|
||||
@@ -15,77 +13,88 @@ Docker:https://hub.docker.com/r/jxxghp/moviepilot
|
||||
|
||||
## 安装
|
||||
|
||||
1. **安装CookieCloud插件**
|
||||
### 1. **安装CookieCloud插件**
|
||||
|
||||
站点信息需要通过CookieCloud同步获取,因此需要安装CookieCloud插件,将浏览器中的站点Cookie数据同步到云端后再同步到MoviePilot使用。 插件下载地址请点击 [这里](https://github.com/easychen/CookieCloud/releases)。
|
||||
|
||||
2. **安装CookieCloud服务端(可选)**
|
||||
### 2. **安装CookieCloud服务端(可选)**
|
||||
|
||||
MoviePilot内置了公共CookieCloud服务器,如果需要自建服务,可参考 [CookieCloud](https://github.com/easychen/CookieCloud) 项目进行搭建,docker镜像请点击 [这里](https://hub.docker.com/r/easychen/cookiecloud)。
|
||||
|
||||
**声明:** 本项目不会收集用户敏感数据,Cookie同步也是基于CookieCloud项目实现,非本项目提供的能力。技术角度上CookieCloud采用端到端加密,在个人不泄露`用户KEY`和`端对端加密密码`的情况下第三方无法窃取任何用户信息(包括服务器持有者)。如果你不放心,可以不使用公共服务或者不使用本项目,但如果使用后发生了任何信息泄露与本项目无关!
|
||||
|
||||
3. **安装配套管理软件**
|
||||
### 3. **安装配套管理软件**
|
||||
|
||||
MoviePilot需要配套下载器和媒体服务器配合使用。
|
||||
- 下载器支持:qBittorrent、Transmission,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,推荐使用QB。
|
||||
- 媒体服务器支持:Jellyfin、Emby、Plex,推荐使用Emby。
|
||||
|
||||
4. **安装MoviePilot**
|
||||
### 4. **安装MoviePilot**
|
||||
|
||||
目前仅提供docker镜像,点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
- Docker镜像
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
点击 [这里](https://hub.docker.com/r/jxxghp/moviepilot) 或执行命令:
|
||||
|
||||
```shell
|
||||
docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
- Windows
|
||||
|
||||
下载 [MoviePilot.exe](https://github.com/jxxghp/MoviePilot/releases),双击运行后自动生成配置文件目录。
|
||||
|
||||
## 配置
|
||||
|
||||
项目的所有配置均通过环境变量进行设置,部分环境建立容器后会自动显示待配置项,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
项目的所有配置均通过环境变量进行设置,支持两种配置方式:
|
||||
- 在Docker环境变量部分或Wdinows系统环境变量中进行参数配置,如未自动显示配置项则需要手动增加对应环境变量。
|
||||
- 下载 [app.env](https://github.com/jxxghp/MoviePilot/raw/main/config/app.env) 配置文件,修改好配置后放置到配置文件映射路径根目录,配置项可根据说明自主增减。
|
||||
|
||||
配置文件映射路径:`/config`
|
||||
配置文件映射路径:`/config`,配置项生效优先级:环境变量 > env文件 > 默认值,**部分参数如路径映射、站点认证、权限端口、时区等必须通过环境变量进行配置**。
|
||||
|
||||
> ❗号标识的为必填项,其它为可选项,可选项可删除配置变量从而使用默认值。
|
||||
|
||||
### 1. **基础设置**
|
||||
|
||||
- **PUID**:运行程序用户的`uid`,默认`0`
|
||||
- **PGID**:运行程序用户的`gid`,默认`0`
|
||||
- **UMASK**:掩码权限,默认`000`,可以考虑设置为`022`
|
||||
- **MOVIEPILOT_AUTO_UPDATE**:重启更新,`true`/`false`,默认`true` **注意:如果出现网络问题可以配置`PROXY_HOST`,具体看下方`PROXY_HOST`解释**
|
||||
- **MOVIEPILOT_AUTO_UPDATE_DEV**:重启时更新到未发布的开发版本代码,`true`/`false`,默认`false`
|
||||
- **NGINX_PORT:** WEB服务端口,默认`3000`,可自行修改,不能与API服务端口冲突
|
||||
- **PORT:** API服务端口,默认`3001`,可自行修改,不能与WEB服务端口冲突
|
||||
- **SUPERUSER:** 超级管理员用户名,默认`admin`,安装后使用该用户登录后台管理界面
|
||||
- **SUPERUSER_PASSWORD:** 超级管理员初始密码,默认`password`,建议修改为复杂密码
|
||||
- **API_TOKEN:** API密钥,默认`moviepilot`,在媒体服务器Webhook、微信回调等地址配置中需要加上`?token=`该值,建议修改为复杂字符串
|
||||
- **PROXY_HOST:** 网络代理(可选),访问themoviedb或者重启更新需要使用代理访问,格式为`http(s)://ip:port`
|
||||
- **❗NGINX_PORT:** 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`设置项:
|
||||
|
||||
@@ -102,21 +111,29 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **TELEGRAM_TOKEN:** Telegram Bot Token
|
||||
- **TELEGRAM_CHAT_ID:** Telegram Chat ID
|
||||
- **TELEGRAM_USERS:** Telegram 用户ID,多个使用,分隔,只有用户ID在列表中才可以使用Bot,如未设置则均可以使用Bot
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单
|
||||
- **TELEGRAM_ADMINS:** Telegram 管理员ID,多个使用,分隔,只有管理员才可以操作Bot菜单,如未设置则均可以操作菜单(可选)
|
||||
|
||||
- `slack`设置项:
|
||||
|
||||
- **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token
|
||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`(可选)
|
||||
|
||||
- `synologychat`设置项:
|
||||
|
||||
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
||||
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
||||
|
||||
|
||||
- **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||
---
|
||||
- **❗DOWNLOAD_PATH:** 下载保存目录,**注意:需要将`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`设置项:
|
||||
|
||||
@@ -131,9 +148,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
- **TR_USER:** transmission用户名
|
||||
- **TR_PASSWORD:** transmission密码
|
||||
|
||||
- **DOWNLOADER_MONITOR:** 下载器监控,`true`/`false`,默认为`true`,开启后下载完成时才会自动整理入库
|
||||
|
||||
- **MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
---
|
||||
- **REFRESH_MEDIASERVER:** 入库后是否刷新媒体服务器,`true`/`false`,默认`true`
|
||||
- **❗MEDIASERVER:** 媒体服务器,支持`emby`/`jellyfin`/`plex`,同时开启多个使用`,`分隔。还需要配置对应媒体服务器的环境变量,非对应媒体服务器的变量可删除,推荐使用`emby`
|
||||
|
||||
- `emby`设置项:
|
||||
|
||||
@@ -156,9 +173,9 @@ docker pull jxxghp/moviepilot:latest
|
||||
|
||||
### 2. **用户认证**
|
||||
|
||||
- **AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数(**仅能通过环境变量配置**)
|
||||
|
||||
`MoviePilot`需要认证后才能使用,配置`AUTH_SITE`后,需要根据下表配置对应站点的认证参数。
|
||||
- **❗AUTH_SITE:** 认证站点,支持`iyuu`/`hhclub`/`audiences`/`hddolby`/`zmpt`/`freefarm`/`hdfans`/`wintersakura`/`leaves`/`1ptba`/`icc2022`/`ptlsp`/`xingtan`
|
||||
|
||||
| 站点 | 参数 |
|
||||
|:------------:|:-----------------------------------------------------:|
|
||||
@@ -174,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. **进阶配置**
|
||||
@@ -194,6 +212,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
> `edition`: 版本(资源类型+特效)
|
||||
> `videoFormat`: 分辨率
|
||||
> `releaseGroup`: 制作组/字幕组
|
||||
> `customization`: 自定义占位符
|
||||
> `videoCodec`: 视频编码
|
||||
> `audioCodec`: 音频编码
|
||||
> `tmdbid`: TMDBID
|
||||
@@ -223,9 +242,7 @@ docker pull jxxghp/moviepilot:latest
|
||||
```
|
||||
|
||||
|
||||
### 3. **过滤规则**
|
||||
|
||||
在`设定`-`规则`中设定,规则说明:
|
||||
### 3. **优先级规则**
|
||||
|
||||
- 仅支持使用内置规则进行排列组合,内置规则有:`蓝光原盘`、`4K`、`1080P`、`中文字幕`、`特效字幕`、`H265`、`H264`、`杜比`、`HDR`、`REMUX`、`WEB-DL`、`免费`、`国语配音` 等
|
||||
- 符合任一层级规则的资源将被标识选中,匹配成功的层级做为该资源的优先级,排越前面优先级超高
|
||||
@@ -242,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;
|
||||
@@ -255,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 = []
|
||||
|
||||
@@ -11,7 +11,6 @@ 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()
|
||||
@@ -90,23 +89,3 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
|
||||
# 删除记录
|
||||
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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
"""
|
||||
@@ -387,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)
|
||||
|
||||
@@ -74,8 +74,7 @@ class DownloadChain(ChainBase):
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image(),
|
||||
userid=userid))
|
||||
image=mediainfo.get_message_image()))
|
||||
|
||||
def download_torrent(self, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
@@ -787,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,
|
||||
@@ -795,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(
|
||||
|
||||
@@ -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
|
||||
@@ -41,8 +40,8 @@ class TransferChain(ChainBase):
|
||||
self.downloadhis = DownloadHistoryOper(self._db)
|
||||
self.transferhis = TransferHistoryOper(self._db)
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain(self._db)
|
||||
self.tmdbchain = TmdbChain(self._db)
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def process(self) -> bool:
|
||||
@@ -237,7 +236,7 @@ class TransferChain(ChainBase):
|
||||
# 自定义识别
|
||||
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
|
||||
@@ -358,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,
|
||||
@@ -489,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
|
||||
@@ -499,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}"
|
||||
@@ -539,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
|
||||
@@ -550,6 +552,7 @@ class TransferChain(ChainBase):
|
||||
:param transfer_type: 转移类型
|
||||
:param epformat: 剧集格式
|
||||
:param min_filesize: 最小文件大小(MB)
|
||||
:param force: 是否强制转移
|
||||
"""
|
||||
logger.info(f"手动转移:{in_path} ...")
|
||||
|
||||
@@ -571,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
|
||||
@@ -586,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,
|
||||
@@ -622,8 +627,9 @@ class TransferChain(ChainBase):
|
||||
if not path.exists():
|
||||
return
|
||||
if path.is_file():
|
||||
# 删除文件、nfo、jpg
|
||||
files = glob.glob(f"{Path(path.parent).joinpath(path.stem)}*")
|
||||
# 删除文件、nfo、jpg等同名文件
|
||||
pattern = path.stem.replace('[', '?').replace(']', '?')
|
||||
files = path.parent.glob(f"{pattern}.*")
|
||||
for file in files:
|
||||
Path(file).unlink()
|
||||
logger.warn(f"文件 {path} 已删除")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import importlib
|
||||
import traceback
|
||||
from threading import Thread, Event
|
||||
from typing import Any, Union, Dict
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -10,16 +10,13 @@ class EventManager(metaclass=Singleton):
|
||||
事件管理器
|
||||
"""
|
||||
|
||||
# 事件队列
|
||||
_eventQueue: Queue = None
|
||||
# 事件响应函数字典
|
||||
_handlers: dict = {}
|
||||
|
||||
def __init__(self):
|
||||
# 事件队列
|
||||
self._eventQueue = Queue()
|
||||
# 事件响应函数字典
|
||||
self._handlers = {}
|
||||
# 已禁用的事件响应
|
||||
self._disabled_handlers = []
|
||||
|
||||
def get_event(self):
|
||||
"""
|
||||
@@ -27,11 +24,21 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
try:
|
||||
event = self._eventQueue.get(block=True, timeout=1)
|
||||
handlerList = self._handlers.get(event.event_type)
|
||||
return event, handlerList or []
|
||||
handlerList = self._handlers.get(event.event_type) or []
|
||||
if handlerList:
|
||||
# 去除掉被禁用的事件响应
|
||||
handlerList = [handler for handler in handlerList
|
||||
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
|
||||
return event, handlerList
|
||||
except Empty:
|
||||
return None, []
|
||||
|
||||
def check(self, etype: EventType):
|
||||
"""
|
||||
检查事件是否存在响应
|
||||
"""
|
||||
return etype.value in self._handlers
|
||||
|
||||
def add_event_listener(self, etype: EventType, handler: type):
|
||||
"""
|
||||
注册事件处理
|
||||
@@ -45,18 +52,21 @@ class EventManager(metaclass=Singleton):
|
||||
handlerList.append(handler)
|
||||
logger.debug(f"Event Registed:{etype.value} - {handler}")
|
||||
|
||||
def remove_event_listener(self, etype: EventType, handler: type):
|
||||
def disable_events_hander(self, class_name: str):
|
||||
"""
|
||||
移除监听器的处理函数
|
||||
标记对应类事件处理为不可用
|
||||
"""
|
||||
try:
|
||||
handlerList = self._handlers[etype.value]
|
||||
if handler in handlerList[:]:
|
||||
handlerList.remove(handler)
|
||||
if not handlerList:
|
||||
del self._handlers[etype.value]
|
||||
except KeyError:
|
||||
pass
|
||||
if class_name not in self._disabled_handlers:
|
||||
self._disabled_handlers.append(class_name)
|
||||
logger.debug(f"Event Disabled:{class_name}")
|
||||
|
||||
def enable_events_hander(self, class_name: str):
|
||||
"""
|
||||
标记对应类事件处理为可用
|
||||
"""
|
||||
if class_name in self._disabled_handlers:
|
||||
self._disabled_handlers.remove(class_name)
|
||||
logger.debug(f"Event Enabled:{class_name}")
|
||||
|
||||
def send_event(self, etype: EventType, data: dict = None):
|
||||
"""
|
||||
|
||||
47
app/core/meta/customization.py
Normal file
47
app/core/meta/customization.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import regex as re
|
||||
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class CustomizationMatcher(metaclass=Singleton):
|
||||
"""
|
||||
识别自定义占位符
|
||||
"""
|
||||
customization = None
|
||||
custom_separator = None
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.customization = None
|
||||
self.custom_separator = None
|
||||
|
||||
def match(self, title=None):
|
||||
"""
|
||||
:param title: 资源标题或文件名
|
||||
:return: 匹配结果
|
||||
"""
|
||||
if not title:
|
||||
return ""
|
||||
if not self.customization:
|
||||
# 自定义占位符
|
||||
customization = self.systemconfig.get(SystemConfigKey.Customization)
|
||||
if not customization:
|
||||
return ""
|
||||
if isinstance(customization, str):
|
||||
customization = customization.replace("\n", ";").replace("|", ";").strip(";").split(";")
|
||||
self.customization = "|".join([f"({item})" for item in customization])
|
||||
|
||||
customization_re = re.compile(r"%s" % self.customization)
|
||||
# 处理重复多次的情况,保留先后顺序(按添加自定义占位符的顺序)
|
||||
unique_customization = {}
|
||||
for item in re.findall(customization_re, title):
|
||||
if not isinstance(item, tuple):
|
||||
item = (item,)
|
||||
for i in range(len(item)):
|
||||
if item[i] and unique_customization.get(item[i]) is None:
|
||||
unique_customization[item[i]] = i
|
||||
unique_customization = list(dict(sorted(unique_customization.items(), key=lambda x: x[1])).keys())
|
||||
separator = self.custom_separator or "@"
|
||||
return separator.join(unique_customization)
|
||||
@@ -1,6 +1,7 @@
|
||||
import re
|
||||
import zhconv
|
||||
import anitopy
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -144,6 +145,8 @@ class MetaAnime(MetaBase):
|
||||
self.resource_team = \
|
||||
ReleaseGroupsMatcher().match(title=original_title) or \
|
||||
anitopy_info_origin.get("release_group") or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
# 视频编码
|
||||
self.video_encode = anitopy_info.get("video_term")
|
||||
if isinstance(self.video_encode, list):
|
||||
|
||||
@@ -51,6 +51,8 @@ class MetaBase(object):
|
||||
resource_pix: Optional[str] = None
|
||||
# 识别的制作组/字幕组
|
||||
resource_team: Optional[str] = None
|
||||
# 识别的自定义占位符
|
||||
customization: Optional[str] = None
|
||||
# 视频编码
|
||||
video_encode: Optional[str] = None
|
||||
# 音频编码
|
||||
@@ -85,6 +87,17 @@ class MetaBase(object):
|
||||
return self.cn_name
|
||||
return ""
|
||||
|
||||
@name.setter
|
||||
def name(self, name: str):
|
||||
"""
|
||||
设置名称
|
||||
"""
|
||||
if StringUtils.is_all_chinese(name):
|
||||
self.cn_name = name
|
||||
else:
|
||||
self.en_name = name
|
||||
self.cn_name = None
|
||||
|
||||
def init_subtitle(self, title_text: str):
|
||||
"""
|
||||
副标题识别
|
||||
@@ -492,6 +505,9 @@ class MetaBase(object):
|
||||
# 制作组/字幕组
|
||||
if not self.resource_team:
|
||||
self.resource_team = meta.resource_team
|
||||
# 自定义占位符
|
||||
if not self.customization:
|
||||
self.customization = meta.customization
|
||||
# 特效
|
||||
if not self.resource_effect:
|
||||
self.resource_effect = meta.resource_effect
|
||||
|
||||
@@ -2,6 +2,7 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta.customization import CustomizationMatcher
|
||||
from app.core.meta.metabase import MetaBase
|
||||
from app.core.meta.releasegroup import ReleaseGroupsMatcher
|
||||
from app.utils.string import StringUtils
|
||||
@@ -130,6 +131,8 @@ class MetaVideo(MetaBase):
|
||||
self.part = None
|
||||
# 制作组/字幕组
|
||||
self.resource_team = ReleaseGroupsMatcher().match(title=original_title) or None
|
||||
# 自定义占位符
|
||||
self.customization = CustomizationMatcher().match(title=original_title) or None
|
||||
|
||||
def __fix_name(self, name: str):
|
||||
if not name:
|
||||
|
||||
@@ -61,8 +61,7 @@ class WordsMatcher(metaclass=Singleton):
|
||||
|
||||
if state:
|
||||
appley_words.append(word)
|
||||
else:
|
||||
logger.debug(f"自定义识别词替换失败:{message}")
|
||||
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import traceback
|
||||
from typing import List, Any, Dict, Tuple
|
||||
|
||||
from app.core.event import eventmanager
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
@@ -58,6 +59,8 @@ class PluginManager(metaclass=Singleton):
|
||||
self._plugins[plugin_id] = plugin
|
||||
# 未安装的不加载
|
||||
if plugin_id not in installed_plugins:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_events_hander(plugin_id)
|
||||
continue
|
||||
# 生成实例
|
||||
plugin_obj = plugin()
|
||||
@@ -66,6 +69,8 @@ class PluginManager(metaclass=Singleton):
|
||||
# 存储运行实例
|
||||
self._running_plugins[plugin_id] = plugin_obj
|
||||
logger.info(f"Plugin Loaded:{plugin_id}")
|
||||
# 设置事件注册状态可用
|
||||
eventmanager.enable_events_hander(plugin_id)
|
||||
except Exception as err:
|
||||
logger.error(f"加载插件 {plugin_id} 出错:{err} - {traceback.format_exc()}")
|
||||
|
||||
@@ -177,6 +182,12 @@ class PluginManager(metaclass=Singleton):
|
||||
return None
|
||||
return getattr(self._running_plugins[pid], method)(*args, **kwargs)
|
||||
|
||||
def get_plugin_ids(self) -> List[str]:
|
||||
"""
|
||||
获取所有插件ID
|
||||
"""
|
||||
return list(self._plugins.keys())
|
||||
|
||||
def get_plugin_apps(self) -> List[dict]:
|
||||
"""
|
||||
获取所有插件信息
|
||||
|
||||
@@ -39,7 +39,7 @@ def update_db():
|
||||
更新数据库
|
||||
"""
|
||||
db_location = settings.CONFIG_PATH / 'user.db'
|
||||
script_location = settings.ROOT_PATH / 'alembic'
|
||||
script_location = settings.ROOT_PATH / 'database'
|
||||
try:
|
||||
alembic_cfg = Config()
|
||||
alembic_cfg.set_main_option('script_location', str(script_location))
|
||||
|
||||
@@ -37,6 +37,12 @@ class Subscribe(Base):
|
||||
include = Column(String)
|
||||
# 排除
|
||||
exclude = Column(String)
|
||||
# 质量
|
||||
quality = Column(String)
|
||||
# 分辨率
|
||||
resolution = Column(String)
|
||||
# 特效
|
||||
effect = Column(String)
|
||||
# 总集数
|
||||
total_episode = Column(Integer)
|
||||
# 开始集数
|
||||
|
||||
@@ -2,12 +2,15 @@ from pyvirtualdisplay import Display
|
||||
|
||||
from app.log import logger
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class DisplayHelper(metaclass=Singleton):
|
||||
_display: Display = None
|
||||
|
||||
def __init__(self):
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
try:
|
||||
self._display = Display(visible=False, size=(1024, 768))
|
||||
self._display.start()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -78,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]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
@@ -93,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]]:
|
||||
@@ -200,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]:
|
||||
"""
|
||||
@@ -263,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)
|
||||
@@ -282,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: 标题
|
||||
@@ -303,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))
|
||||
@@ -325,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
|
||||
@@ -336,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:
|
||||
@@ -359,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")
|
||||
@@ -374,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]:
|
||||
"""
|
||||
@@ -441,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: 已识别的需要刷新媒体库的媒体信息列表
|
||||
@@ -463,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}
|
||||
@@ -481,8 +473,8 @@ class Emby(metaclass=Singleton):
|
||||
return None
|
||||
# 查找需要刷新的媒体库ID
|
||||
item_path = Path(item.target_path)
|
||||
# 匹配子目录
|
||||
for folder in self.folders:
|
||||
# 匹配子目录
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
try:
|
||||
# 匹配子目录
|
||||
@@ -492,38 +484,53 @@ class Emby(metaclass=Singleton):
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
# 如果找不到,只要路径中有分类目录名就命中
|
||||
for subfolder in folder.get("SubFolders"):
|
||||
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
|
||||
subfolder.get("Path")):
|
||||
return folder.get("Id")
|
||||
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")):
|
||||
return folder.get("Id")
|
||||
# 刷新根目录
|
||||
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)
|
||||
@@ -533,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报文
|
||||
电影:
|
||||
@@ -804,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"
|
||||
@@ -871,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
|
||||
|
||||
@@ -45,7 +45,8 @@ class FileTransferModule(_ModuleBase):
|
||||
# 获取目标路径
|
||||
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("未找到媒体库目录,无法转移文件")
|
||||
@@ -80,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)
|
||||
@@ -376,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)
|
||||
@@ -395,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,
|
||||
@@ -419,7 +430,7 @@ 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:
|
||||
# 转移单个文件
|
||||
@@ -460,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,
|
||||
@@ -479,7 +491,7 @@ 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)])
|
||||
@@ -542,7 +554,9 @@ class FileTransferModule(_ModuleBase):
|
||||
# 剧集标题
|
||||
"episode_title": episode_title,
|
||||
# 文件后缀
|
||||
"fileExt": file_ext
|
||||
"fileExt": file_ext,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -563,7 +577,7 @@ class FileTransferModule(_ModuleBase):
|
||||
@staticmethod
|
||||
def get_library_path(path: Path):
|
||||
"""
|
||||
根据目录查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
根据文件路径查询其所在的媒体库目录,查询不到的返回输入目录
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
@@ -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'],
|
||||
@@ -98,7 +108,22 @@ class FilterModule(_ModuleBase):
|
||||
"CNVOI": {
|
||||
"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": [],
|
||||
|
||||
@@ -6,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
|
||||
|
||||
|
||||
@@ -40,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: 请求体
|
||||
@@ -50,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: 识别的媒体信息
|
||||
@@ -62,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:
|
||||
"""
|
||||
@@ -96,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]]:
|
||||
"""
|
||||
@@ -110,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]:
|
||||
"""
|
||||
@@ -127,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]]:
|
||||
@@ -150,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:
|
||||
@@ -73,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:
|
||||
@@ -179,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]:
|
||||
"""
|
||||
@@ -239,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)
|
||||
@@ -258,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: 标题
|
||||
@@ -268,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)
|
||||
@@ -276,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))
|
||||
@@ -300,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
|
||||
@@ -311,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:
|
||||
@@ -331,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:
|
||||
@@ -346,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]:
|
||||
"""
|
||||
@@ -394,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报文
|
||||
{
|
||||
@@ -466,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"
|
||||
)
|
||||
@@ -506,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)
|
||||
@@ -541,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:
|
||||
@@ -31,7 +29,7 @@ class PlexModule(_ModuleBase):
|
||||
if not self.plex.is_inactive():
|
||||
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
|
||||
|
||||
|
||||
@@ -49,7 +50,7 @@ class Plex(metaclass=Singleton):
|
||||
self._plex = None
|
||||
logger.error(f"Plex服务器连接失败:{str(e)}")
|
||||
|
||||
def get_librarys(self):
|
||||
def get_librarys(self) -> List[schemas.MediaServerLibrary]:
|
||||
"""
|
||||
获取媒体服务器所有媒体库列表
|
||||
"""
|
||||
@@ -69,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: 标题
|
||||
@@ -156,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,
|
||||
@@ -178,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
|
||||
@@ -190,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:
|
||||
@@ -214,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]:
|
||||
"""
|
||||
@@ -227,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
|
||||
@@ -245,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
|
||||
"""
|
||||
@@ -294,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:
|
||||
@@ -337,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:
|
||||
@@ -350,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 字段的含义
|
||||
@@ -413,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,
|
||||
@@ -481,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"
|
||||
@@ -495,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] + "..."
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,22 +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.string import StringUtils
|
||||
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):
|
||||
"""
|
||||
根据季信息获取集的信息
|
||||
@@ -122,25 +127,8 @@ class TmdbScraper:
|
||||
except Exception as e:
|
||||
logger.error(f"{file_path} 刮削失败:{e}")
|
||||
|
||||
def __get_chinese_name(self, person: dict):
|
||||
"""
|
||||
获取TMDB别名中的中文名
|
||||
"""
|
||||
if not person.get("id"):
|
||||
return ""
|
||||
try:
|
||||
personinfo = self.tmdb.get_person_detail(person.get("id"))
|
||||
if personinfo:
|
||||
also_known_as = personinfo.get("also_known_as") or []
|
||||
if also_known_as:
|
||||
for name in also_known_as:
|
||||
if name and StringUtils.is_chinese(name):
|
||||
return name
|
||||
except Exception as err:
|
||||
logger.error(f"获取人物中文名失败:{err}")
|
||||
return person.get("name") or ""
|
||||
|
||||
def __gen_common_nfo(self, mediainfo: MediaInfo, doc, root):
|
||||
@staticmethod
|
||||
def __gen_common_nfo(mediainfo: MediaInfo, doc, root):
|
||||
"""
|
||||
生成公共NFO
|
||||
"""
|
||||
@@ -173,19 +161,20 @@ class TmdbScraper:
|
||||
xoutline.appendChild(doc.createCDATASection(mediainfo.overview or ""))
|
||||
# 导演
|
||||
for director in mediainfo.directors:
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(director)
|
||||
xdirector = DomUtils.add_node(doc, root, "director", cn_name)
|
||||
xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "")
|
||||
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
|
||||
# 演员
|
||||
for actor in mediainfo.actors:
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(actor)
|
||||
xactor = DomUtils.add_node(doc, root, "actor")
|
||||
DomUtils.add_node(doc, xactor, "name", cn_name)
|
||||
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, "tmdbid", actor.get("id") or "")
|
||||
DomUtils.add_node(doc, xactor, "thumb",
|
||||
f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{actor.get('profile_path')}")
|
||||
DomUtils.add_node(doc, xactor, "profile",
|
||||
f"https://www.themoviedb.org/person/{actor.get('id')}")
|
||||
# 风格
|
||||
genres = mediainfo.genres or []
|
||||
for genre in genres:
|
||||
@@ -260,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 ""))
|
||||
@@ -272,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))
|
||||
# 保存
|
||||
@@ -326,26 +317,25 @@ class TmdbScraper:
|
||||
directors = episodeinfo.get("crew") or []
|
||||
for director in directors:
|
||||
if director.get("known_for_department") == "Directing":
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(director)
|
||||
xdirector = DomUtils.add_node(doc, root, "director", cn_name)
|
||||
xdirector = DomUtils.add_node(doc, root, "director", director.get("name") or "")
|
||||
xdirector.setAttribute("tmdbid", str(director.get("id") or ""))
|
||||
# 演员
|
||||
actors = episodeinfo.get("guest_stars") or []
|
||||
for actor in actors:
|
||||
if actor.get("known_for_department") == "Acting":
|
||||
# 获取中文名
|
||||
cn_name = self.__get_chinese_name(actor)
|
||||
xactor = DomUtils.add_node(doc, root, "actor")
|
||||
DomUtils.add_node(doc, xactor, "name", cn_name)
|
||||
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):
|
||||
"""
|
||||
下载图片并保存
|
||||
"""
|
||||
@@ -355,7 +345,10 @@ 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}图片下载失败,请检查网络连通性")
|
||||
@@ -364,13 +357,29 @@ class TmdbScraper:
|
||||
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)
|
||||
|
||||
@@ -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,9 +26,15 @@ 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
|
||||
@@ -54,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)
|
||||
@@ -131,9 +138,18 @@ class TMDb(object):
|
||||
os.environ[self.TMDB_CACHE_ENABLED] = str(cache)
|
||||
|
||||
@lru_cache(maxsize=REQUEST_CACHE_MAXSIZE)
|
||||
def cached_request(self, method, url, data, json):
|
||||
return requests.request(method, url, data=data, json=json,
|
||||
timeout=self._timeout, proxies=self.proxies)
|
||||
def cached_request(self, method, url, data, json,
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d')):
|
||||
"""
|
||||
缓存请求,时间默认1天
|
||||
"""
|
||||
return self.request(method, url, data, json)
|
||||
|
||||
def request(self, method, url, data, json):
|
||||
if method == "GET":
|
||||
return self._req.get_res(url, params=data, json=json)
|
||||
else:
|
||||
return self._req.post_res(url, data=data, json=json)
|
||||
|
||||
def cache_clear(self):
|
||||
return self.cached_request.cache_clear()
|
||||
@@ -154,8 +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,
|
||||
timeout=self._timeout, proxies=self.proxies)
|
||||
req = self.request(method, url, data, json)
|
||||
|
||||
if req is None:
|
||||
raise TMDbException("Failed to establish a new connection: no response from the server.")
|
||||
|
||||
headers = req.headers
|
||||
|
||||
@@ -200,3 +218,7 @@ class TMDb(object):
|
||||
if key:
|
||||
return json.get(key)
|
||||
return json
|
||||
|
||||
def __del__(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
@@ -211,6 +211,8 @@ class TransmissionModule(_ModuleBase):
|
||||
下载器信息
|
||||
"""
|
||||
info = self.transmission.transfer_info()
|
||||
if not info:
|
||||
return schemas.DownloaderInfo()
|
||||
return schemas.DownloaderInfo(
|
||||
download_speed=info.download_speed,
|
||||
upload_speed=info.upload_speed,
|
||||
|
||||
@@ -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,6 +117,13 @@ 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", {})
|
||||
@@ -228,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"站点刷流服务启动,立即运行一次")
|
||||
@@ -738,7 +747,7 @@ class BrushFlow(_PluginBase):
|
||||
|
||||
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,
|
||||
@@ -746,7 +755,7 @@ class BrushFlow(_PluginBase):
|
||||
"uploaded": 0,
|
||||
"downloaded": 0,
|
||||
}
|
||||
if not data_list:
|
||||
if not torrents:
|
||||
return [
|
||||
{
|
||||
'component': 'div',
|
||||
@@ -757,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)
|
||||
# 总下载量格式化
|
||||
@@ -849,7 +860,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/upload.png'
|
||||
'src': '/plugin_icon/upload.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -919,7 +930,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/download.png'
|
||||
'src': '/plugin_icon/download.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -989,7 +1000,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/seed.png'
|
||||
'src': '/plugin_icon/seed.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1059,7 +1070,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/delete.png'
|
||||
'src': '/plugin_icon/delete.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1113,7 +1124,7 @@ class BrushFlow(_PluginBase):
|
||||
{
|
||||
'component': 'thead',
|
||||
'props': {
|
||||
'class': 'text-no-wrap'
|
||||
'class': 'text-no-wrap'
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -1290,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:
|
||||
@@ -1350,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
|
||||
@@ -1370,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)
|
||||
@@ -1860,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:
|
||||
"""
|
||||
@@ -1872,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):
|
||||
"""
|
||||
退出插件
|
||||
|
||||
@@ -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,12 +1,12 @@
|
||||
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
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -79,6 +80,7 @@ class DirMonitor(_PluginBase):
|
||||
_observer = []
|
||||
_enabled = False
|
||||
_notify = False
|
||||
_onlyonce = False
|
||||
# 模式 compatibility/fast
|
||||
_mode = "fast"
|
||||
# 转移方式
|
||||
@@ -87,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.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 ""
|
||||
@@ -111,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
|
||||
@@ -123,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:
|
||||
@@ -132,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):
|
||||
"""
|
||||
@@ -190,230 +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)
|
||||
|
||||
# 获取集数据
|
||||
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=self._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=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):
|
||||
"""
|
||||
@@ -440,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:
|
||||
|
||||
@@ -492,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
|
||||
@@ -509,7 +611,7 @@ class DirMonitor(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -525,7 +627,7 @@ class DirMonitor(_PluginBase):
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 6
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
@@ -536,6 +638,22 @@ class DirMonitor(_PluginBase):
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'component': 'VCol',
|
||||
'props': {
|
||||
'cols': 12,
|
||||
'md': 4
|
||||
},
|
||||
'content': [
|
||||
{
|
||||
'component': 'VSwitch',
|
||||
'props': {
|
||||
'model': 'onlyonce',
|
||||
'label': '立即运行一次',
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -601,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'
|
||||
'监控目录:转移目的目录#转移方式'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -637,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)
|
||||
|
||||
@@ -117,7 +117,7 @@ class DownloadingMsg(_PluginBase):
|
||||
if not userid:
|
||||
continue
|
||||
# 如果用户是管理员,无需重复推送
|
||||
if self._type == "admin" or self._type == "both" and self._adminuser and userid in str(
|
||||
if (self._type == "admin" or self._type == "both") and self._adminuser and userid in str(
|
||||
self._adminuser).split(","):
|
||||
logger.debug("管理员已推送")
|
||||
continue
|
||||
@@ -156,7 +156,7 @@ class DownloadingMsg(_PluginBase):
|
||||
channel_value = downloadhis.channel
|
||||
else:
|
||||
try:
|
||||
context = MediaChain(self.db).recognize_by_title(title=torrent.title)
|
||||
context = MediaChain().recognize_by_title(title=torrent.title)
|
||||
if not context or not context.media_info:
|
||||
continue
|
||||
media_info = context.media_info
|
||||
@@ -177,10 +177,14 @@ class DownloadingMsg(_PluginBase):
|
||||
else:
|
||||
media_name = torrent.title
|
||||
|
||||
messages.append(f"{index}. {media_name}\n"
|
||||
f"{torrent.title} "
|
||||
f"{StringUtils.str_filesize(torrent.size)} "
|
||||
f"{round(torrent.progress, 1)}%")
|
||||
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
|
||||
|
||||
# 用户消息渠道
|
||||
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1207,8 +1278,6 @@ class MediaSyncDel(_PluginBase):
|
||||
"""
|
||||
下载文件删除处理事件
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
if not event:
|
||||
return
|
||||
event_data = event.event_data
|
||||
|
||||
@@ -139,43 +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):
|
||||
_value: dict = json.loads(plugin_value)
|
||||
elif isinstance(plugin_value, dict):
|
||||
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: list = 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,
|
||||
@@ -189,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):
|
||||
"""
|
||||
导入下载记录
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,8 @@ class SiteStatistic(_PluginBase):
|
||||
self._statistic_sites = config.get("statistic_sites") or []
|
||||
|
||||
# 过滤掉已删除的站点
|
||||
self._statistic_sites = [site.get("id") for site in self.sites.get_indexers() if
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites()
|
||||
self._statistic_sites = [site.get("id") for site in all_sites if
|
||||
not site.get("public") and site.get("id") in self._statistic_sites]
|
||||
self.__update_config()
|
||||
|
||||
@@ -182,9 +183,14 @@ class SiteStatistic(_PluginBase):
|
||||
"""
|
||||
拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构
|
||||
"""
|
||||
# 站点的可选项
|
||||
site_options = [{"title": site.name, "value": site.id}
|
||||
# 站点的可选项(内置站点 + 自定义站点)
|
||||
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',
|
||||
@@ -467,7 +473,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/upload.png'
|
||||
'src': '/plugin_icon/upload.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -537,7 +543,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/download.png'
|
||||
'src': '/plugin_icon/download.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -607,7 +613,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/seed.png'
|
||||
'src': '/plugin_icon/seed.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -677,7 +683,7 @@ class SiteStatistic(_PluginBase):
|
||||
{
|
||||
'component': 'VImg',
|
||||
'props': {
|
||||
'src': '/plugin/database.png'
|
||||
'src': '/plugin_icon/database.png'
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -841,87 +847,88 @@ class SiteStatistic(_PluginBase):
|
||||
url = site_info.get("url")
|
||||
proxy = site_info.get("proxy")
|
||||
ua = site_info.get("ua")
|
||||
session = requests.Session()
|
||||
proxies = settings.PROXY if proxy else None
|
||||
proxy_server = settings.PROXY_SERVER if proxy else None
|
||||
render = site_info.get("render")
|
||||
# 会话管理
|
||||
with requests.Session() as session:
|
||||
proxies = settings.PROXY if proxy else None
|
||||
proxy_server = settings.PROXY_SERVER if proxy else None
|
||||
render = site_info.get("render")
|
||||
|
||||
logger.debug(f"站点 {site_name} url={url} site_cookie={site_cookie} ua={ua}")
|
||||
if render:
|
||||
# 演染模式
|
||||
html_text = PlaywrightHelper().get_page_source(url=url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
else:
|
||||
# 普通模式
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
# 第一次登录反爬
|
||||
if html_text.find("title") == -1:
|
||||
i = html_text.find("window.location")
|
||||
if i == -1:
|
||||
return None
|
||||
tmp_url = url + html_text[i:html_text.find(";")] \
|
||||
.replace("\"", "") \
|
||||
.replace("+", "") \
|
||||
.replace(" ", "") \
|
||||
.replace("window.location=", "")
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=tmp_url)
|
||||
if res and res.status_code == 200:
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
else:
|
||||
logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code))
|
||||
return None
|
||||
|
||||
# 兼容假首页情况,假首页通常没有 <link rel="search" 属性
|
||||
if '"search"' not in html_text and '"csrf-token"' not in html_text:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url + "/index.php")
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
elif res is not None:
|
||||
logger.error(f"站点 {site_name} 连接失败,状态码:{res.status_code}")
|
||||
return None
|
||||
logger.debug(f"站点 {site_name} url={url} site_cookie={site_cookie} ua={ua}")
|
||||
if render:
|
||||
# 演染模式
|
||||
html_text = PlaywrightHelper().get_page_source(url=url,
|
||||
cookies=site_cookie,
|
||||
ua=ua,
|
||||
proxies=proxy_server)
|
||||
else:
|
||||
logger.error(f"站点 {site_name} 无法访问:{url}")
|
||||
return None
|
||||
# 解析站点类型
|
||||
if html_text:
|
||||
site_schema = self.__build_class(html_text)
|
||||
if not site_schema:
|
||||
logger.error("站点 %s 无法识别站点类型" % site_name)
|
||||
return None
|
||||
return site_schema(site_name, url, site_cookie, html_text, session=session, ua=ua, proxy=proxy)
|
||||
return None
|
||||
# 普通模式
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url)
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
# 第一次登录反爬
|
||||
if html_text.find("title") == -1:
|
||||
i = html_text.find("window.location")
|
||||
if i == -1:
|
||||
return None
|
||||
tmp_url = url + html_text[i:html_text.find(";")] \
|
||||
.replace("\"", "") \
|
||||
.replace("+", "") \
|
||||
.replace(" ", "") \
|
||||
.replace("window.location=", "")
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=tmp_url)
|
||||
if res and res.status_code == 200:
|
||||
if "charset=utf-8" in res.text or "charset=UTF-8" in res.text:
|
||||
res.encoding = "UTF-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
else:
|
||||
logger.error("站点 %s 被反爬限制:%s, 状态码:%s" % (site_name, url, res.status_code))
|
||||
return None
|
||||
|
||||
# 兼容假首页情况,假首页通常没有 <link rel="search" 属性
|
||||
if '"search"' not in html_text and '"csrf-token"' not in html_text:
|
||||
res = RequestUtils(cookies=site_cookie,
|
||||
session=session,
|
||||
ua=ua,
|
||||
proxies=proxies
|
||||
).get_res(url=url + "/index.php")
|
||||
if res and res.status_code == 200:
|
||||
if re.search(r"charset=\"?utf-8\"?", res.text, re.IGNORECASE):
|
||||
res.encoding = "utf-8"
|
||||
else:
|
||||
res.encoding = res.apparent_encoding
|
||||
html_text = res.text
|
||||
if not html_text:
|
||||
return None
|
||||
elif res is not None:
|
||||
logger.error(f"站点 {site_name} 连接失败,状态码:{res.status_code}")
|
||||
return None
|
||||
else:
|
||||
logger.error(f"站点 {site_name} 无法访问:{url}")
|
||||
return None
|
||||
# 解析站点类型
|
||||
if html_text:
|
||||
site_schema = self.__build_class(html_text)
|
||||
if not site_schema:
|
||||
logger.error("站点 %s 无法识别站点类型" % site_name)
|
||||
return None
|
||||
return site_schema(site_name, url, site_cookie, html_text, session=session, ua=ua, proxy=proxy)
|
||||
return None
|
||||
|
||||
def refresh_by_domain(self, domain: str) -> schemas.Response:
|
||||
"""
|
||||
@@ -1047,11 +1054,12 @@ class SiteStatistic(_PluginBase):
|
||||
|
||||
with lock:
|
||||
|
||||
all_sites = [site for site in self.sites.get_indexers() if not site.get("public")] + self.__custom_sites()
|
||||
# 没有指定站点,默认使用全部站点
|
||||
if not self._statistic_sites:
|
||||
refresh_sites = [site for site in self.sites.get_indexers() if not site.get("public")]
|
||||
refresh_sites = all_sites
|
||||
else:
|
||||
refresh_sites = [site for site in self.sites.get_indexers() if
|
||||
refresh_sites = [site for site in all_sites if
|
||||
site.get("id") in self._statistic_sites]
|
||||
if not refresh_sites:
|
||||
return
|
||||
@@ -1114,6 +1122,13 @@ class SiteStatistic(_PluginBase):
|
||||
self.save_data("last_update_time", key)
|
||||
logger.info("站点数据刷新完成")
|
||||
|
||||
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 __update_config(self):
|
||||
self.update_config({
|
||||
"enabled": self._enabled,
|
||||
|
||||
@@ -6,7 +6,6 @@ from enum import Enum
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import requests
|
||||
from requests import Session
|
||||
|
||||
from app.core.config import settings
|
||||
@@ -107,7 +106,7 @@ class ISiteUserInfo(metaclass=ABCMeta):
|
||||
self._base_url = f"{split_url.scheme}://{split_url.netloc}"
|
||||
self._site_cookie = site_cookie
|
||||
self._index_html = index_html
|
||||
self._session = session if session else requests.Session()
|
||||
self._session = session if session else None
|
||||
self._ua = ua
|
||||
|
||||
self._emulate = emulate
|
||||
|
||||
@@ -383,7 +383,14 @@ class SpeedLimiter(_PluginBase):
|
||||
return
|
||||
if event:
|
||||
event_data: WebhookEventInfo = event.event_data
|
||||
if event_data.event not in ["playback.start", "PlaybackStart", "media.play"]:
|
||||
if event_data.event not in [
|
||||
"playback.start",
|
||||
"PlaybackStart",
|
||||
"media.play",
|
||||
"media.stop",
|
||||
"PlaybackStop",
|
||||
"playback.stop"
|
||||
]:
|
||||
return
|
||||
# 当前播放的总比特率
|
||||
total_bit_rate = 0
|
||||
@@ -396,7 +403,7 @@ class SpeedLimiter(_PluginBase):
|
||||
# 查询播放中会话
|
||||
playing_sessions = []
|
||||
if media_server == "emby":
|
||||
req_url = "{HOST}emby/Sessions?api_key={APIKEY}"
|
||||
req_url = "[HOST]emby/Sessions?api_key=[APIKEY]"
|
||||
try:
|
||||
res = Emby().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
@@ -419,7 +426,7 @@ class SpeedLimiter(_PluginBase):
|
||||
and session.get("NowPlayingItem", {}).get("MediaType") == "Video":
|
||||
total_bit_rate += int(session.get("NowPlayingItem", {}).get("Bitrate") or 0)
|
||||
elif media_server == "jellyfin":
|
||||
req_url = "{HOST}Sessions?api_key={APIKEY}"
|
||||
req_url = "[HOST]Sessions?api_key=[APIKEY]"
|
||||
try:
|
||||
res = Jellyfin().get_data(req_url)
|
||||
if res and res.status_code == 200:
|
||||
|
||||
@@ -100,7 +100,7 @@ class TorrentTransfer(_PluginBase):
|
||||
return
|
||||
if self._fromdownloader == self._todownloader:
|
||||
logger.error(f"源下载器和目的下载器不能相同")
|
||||
self.systemmessage(f"源下载器和目的下载器不能相同")
|
||||
self.systemmessage.put(f"源下载器和目的下载器不能相同")
|
||||
return
|
||||
self._scheduler = BackgroundScheduler(timezone=settings.TZ)
|
||||
if self._cron:
|
||||
@@ -110,7 +110,7 @@ class TorrentTransfer(_PluginBase):
|
||||
CronTrigger.from_crontab(self._cron))
|
||||
except Exception as e:
|
||||
logger.error(f"转移做种服务启动失败:{e}")
|
||||
self.systemmessage(f"转移做种服务启动失败:{e}")
|
||||
self.systemmessage.put(f"转移做种服务启动失败:{e}")
|
||||
return
|
||||
if self._onlyonce:
|
||||
logger.info(f"转移做种服务启动,立即运行一次")
|
||||
|
||||
@@ -137,13 +137,29 @@ class WebHook(_PluginBase):
|
||||
return
|
||||
|
||||
def __to_dict(_event):
|
||||
result = {}
|
||||
for key, value in _event.items():
|
||||
if hasattr(value, 'to_dict'):
|
||||
result[key] = value.to_dict()
|
||||
else:
|
||||
result[key] = str(value)
|
||||
return result
|
||||
"""
|
||||
递归将对象转换为字典
|
||||
"""
|
||||
if isinstance(_event, dict):
|
||||
for k, v in _event.items():
|
||||
_event[k] = __to_dict(v)
|
||||
return _event
|
||||
elif isinstance(_event, list):
|
||||
for i in range(len(_event)):
|
||||
_event[i] = __to_dict(_event[i])
|
||||
return _event
|
||||
elif isinstance(_event, tuple):
|
||||
return tuple(__to_dict(list(_event)))
|
||||
elif isinstance(_event, set):
|
||||
return set(__to_dict(list(_event)))
|
||||
elif hasattr(_event, 'to_dict'):
|
||||
return __to_dict(_event.to_dict())
|
||||
elif hasattr(_event, '__dict__'):
|
||||
return __to_dict(_event.__dict__)
|
||||
elif isinstance(_event, (int, float, str, bool, type(None))):
|
||||
return _event
|
||||
else:
|
||||
return str(_event)
|
||||
|
||||
event_info = {
|
||||
"type": event.event_type,
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.chain import ChainBase
|
||||
from app.chain.cookiecloud import CookieCloudChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.core.config import settings
|
||||
from app.db import SessionFactory
|
||||
@@ -183,6 +184,14 @@ class Scheduler(metaclass=Singleton):
|
||||
}
|
||||
)
|
||||
|
||||
# 后台刷新TMDB壁纸
|
||||
self._scheduler.add_job(
|
||||
TmdbChain(self._db).get_random_wallpager,
|
||||
"interval",
|
||||
minutes=30,
|
||||
next_run_time=datetime.now(pytz.timezone(settings.TZ)) + timedelta(seconds=3)
|
||||
)
|
||||
|
||||
# 公共定时服务
|
||||
self._scheduler.add_job(
|
||||
SchedulerChain(self._db).scheduler_job,
|
||||
|
||||
@@ -56,7 +56,7 @@ class TransferHistory(BaseModel):
|
||||
src: Optional[str] = None
|
||||
# 目的目录
|
||||
dest: Optional[str] = None
|
||||
# 转移模式link/copy/move/softlink
|
||||
# 转移模式
|
||||
mode: Optional[str] = None
|
||||
# 类型:电影、电视剧
|
||||
type: Optional[str] = None
|
||||
|
||||
@@ -14,6 +14,10 @@ class ExistMediaInfo(BaseModel):
|
||||
type: Optional[MediaType]
|
||||
# 季
|
||||
seasons: Optional[Dict[int, list]] = {}
|
||||
# 媒体服务器
|
||||
server: Optional[str] = None
|
||||
# 媒体ID
|
||||
itemid: Optional[Union[str, int]] = None
|
||||
|
||||
|
||||
class NotExistMediaInfo(BaseModel):
|
||||
|
||||
@@ -31,6 +31,12 @@ class Subscribe(BaseModel):
|
||||
include: Optional[str] = None
|
||||
# 排除
|
||||
exclude: Optional[str] = None
|
||||
# 质量
|
||||
quality: Optional[str] = None
|
||||
# 分辨率
|
||||
resolution: Optional[str] = None
|
||||
# 特效
|
||||
effect: Optional[str] = None
|
||||
# 总集数
|
||||
total_episode: Optional[int] = 0
|
||||
# 开始集数
|
||||
|
||||
@@ -12,6 +12,7 @@ class TransferTorrent(BaseModel):
|
||||
path: Optional[Path] = None
|
||||
hash: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
userid: Optional[str] = None
|
||||
|
||||
|
||||
class DownloadingTorrent(BaseModel):
|
||||
@@ -29,6 +30,7 @@ class DownloadingTorrent(BaseModel):
|
||||
upspeed: Optional[str] = None
|
||||
dlspeed: Optional[str] = None
|
||||
media: Optional[dict] = {}
|
||||
userid: Optional[str] = None
|
||||
|
||||
|
||||
class TransferInfo(BaseModel):
|
||||
|
||||
@@ -40,6 +40,12 @@ class EventType(Enum):
|
||||
UserMessage = "user.message"
|
||||
# 通知消息
|
||||
NoticeMessage = "notice.message"
|
||||
# 名称识别请求
|
||||
NameRecognize = "name.recognize"
|
||||
# 名称识别结果
|
||||
NameRecognizeResult = "name.recognize.result"
|
||||
# 目录监控同步
|
||||
DirectorySync = "directory.sync"
|
||||
|
||||
|
||||
# 系统配置Key字典
|
||||
@@ -58,6 +64,8 @@ class SystemConfigKey(Enum):
|
||||
NotificationChannels = "NotificationChannels"
|
||||
# 自定义制作组/字幕组
|
||||
CustomReleaseGroups = "CustomReleaseGroups"
|
||||
# 自定义占位符
|
||||
Customization = "Customization"
|
||||
# 自定义识别词
|
||||
CustomIdentifiers = "CustomIdentifiers"
|
||||
# 搜索优先级规则
|
||||
@@ -66,8 +74,10 @@ class SystemConfigKey(Enum):
|
||||
SubscribeFilterRules = "SubscribeFilterRules"
|
||||
# 洗版规则
|
||||
BestVersionFilterRules = "BestVersionFilterRules"
|
||||
# 默认过滤规则
|
||||
# 默认订阅过滤规则
|
||||
DefaultFilterRules = "DefaultFilterRules"
|
||||
# 默认搜索过滤规则
|
||||
DefaultSearchFilterRules = "DefaultSearchFilterRules"
|
||||
# 转移屏蔽词
|
||||
TransferExcludeWords = "TransferExcludeWords"
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ class RequestUtils:
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
timeout=self._timeout,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
else:
|
||||
return requests.post(url,
|
||||
data=data,
|
||||
@@ -67,7 +68,8 @@ class RequestUtils:
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
timeout=self._timeout,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
@@ -91,27 +93,38 @@ class RequestUtils:
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
def get_res(self, url: str, params: dict = None,
|
||||
allow_redirects: bool = True, raise_exception: bool = False) -> Optional[Response]:
|
||||
def get_res(self, url: str,
|
||||
params: dict = None,
|
||||
data: Any = None,
|
||||
json: dict = None,
|
||||
allow_redirects: bool = True,
|
||||
raise_exception: bool = False
|
||||
) -> Optional[Response]:
|
||||
try:
|
||||
if self._session:
|
||||
return self._session.get(url,
|
||||
params=params,
|
||||
data=data,
|
||||
json=json,
|
||||
verify=False,
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
cookies=self._cookies,
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects)
|
||||
allow_redirects=allow_redirects,
|
||||
stream=False)
|
||||
else:
|
||||
return requests.get(url,
|
||||
params=params,
|
||||
data=data,
|
||||
json=json,
|
||||
verify=False,
|
||||
headers=self._headers,
|
||||
proxies=self._proxies,
|
||||
cookies=self._cookies,
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects)
|
||||
allow_redirects=allow_redirects,
|
||||
stream=False)
|
||||
except requests.exceptions.RequestException:
|
||||
if raise_exception:
|
||||
raise requests.exceptions.RequestException
|
||||
@@ -120,7 +133,8 @@ class RequestUtils:
|
||||
def post_res(self, url: str, data: Any = None, params: dict = None,
|
||||
allow_redirects: bool = True,
|
||||
files: Any = None,
|
||||
json: dict = None) -> Optional[Response]:
|
||||
json: dict = None,
|
||||
raise_exception: bool = False) -> Optional[Response]:
|
||||
try:
|
||||
if self._session:
|
||||
return self._session.post(url,
|
||||
@@ -133,7 +147,8 @@ class RequestUtils:
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects,
|
||||
files=files,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
else:
|
||||
return requests.post(url,
|
||||
data=data,
|
||||
@@ -145,8 +160,11 @@ class RequestUtils:
|
||||
timeout=self._timeout,
|
||||
allow_redirects=allow_redirects,
|
||||
files=files,
|
||||
json=json)
|
||||
json=json,
|
||||
stream=False)
|
||||
except requests.exceptions.RequestException:
|
||||
if raise_exception:
|
||||
raise requests.exceptions.RequestException
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -68,6 +68,8 @@ class StringUtils:
|
||||
"""
|
||||
判断是否含有中文
|
||||
"""
|
||||
if not word:
|
||||
return False
|
||||
if isinstance(word, list):
|
||||
word = " ".join(word)
|
||||
chn = re.compile(r'[\u4e00-\u9fff]')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user