mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-06-13 23:16:46 +00:00
Compare commits
411 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98327d1750 | ||
|
|
b944306302 | ||
|
|
02ab1d4111 | ||
|
|
28552fb0ce | ||
|
|
bf52fcb2ec | ||
|
|
bab1f73480 | ||
|
|
c06001d921 | ||
|
|
0fa49bb9c6 | ||
|
|
bf23fe6ce2 | ||
|
|
7c6137b742 | ||
|
|
3823a7c9b6 | ||
|
|
a944975be2 | ||
|
|
6da65d3b03 | ||
|
|
0d938f2dca | ||
|
|
4fa9bb3c1f | ||
|
|
2f5b22a81f | ||
|
|
fcd5ca3fda | ||
|
|
c18247f3b1 | ||
|
|
f8fbfdbba7 | ||
|
|
21addfb947 | ||
|
|
8672bd12c4 | ||
|
|
be8054e81e | ||
|
|
82f46c6010 | ||
|
|
95a827e8a2 | ||
|
|
c534e3dcb8 | ||
|
|
9f5e1b8dd7 | ||
|
|
c86ed20c34 | ||
|
|
c32c37e66a | ||
|
|
7b100d3cdb | ||
|
|
95a2362885 | ||
|
|
d8b14b9a9f | ||
|
|
c45953f63a | ||
|
|
e3d3087a5d | ||
|
|
e162bd1168 | ||
|
|
db5d81d7f0 | ||
|
|
f737f1287b | ||
|
|
1ffa5178db | ||
|
|
49cb43488c | ||
|
|
fd7a6f8ddd | ||
|
|
7979ce0f0a | ||
|
|
2ba5d9484d | ||
|
|
23b981c5ac | ||
|
|
86ab2c8c05 | ||
|
|
9ea0bc609a | ||
|
|
5366c2844a | ||
|
|
eac4d703c7 | ||
|
|
8ed87294e2 | ||
|
|
b343c601be | ||
|
|
e56d7006b4 | ||
|
|
1b7bcd7784 | ||
|
|
4cb9025b6c | ||
|
|
f8864ab053 | ||
|
|
64eba46a67 | ||
|
|
35d9cc1d40 | ||
|
|
3036107dac | ||
|
|
214089b4ea | ||
|
|
95b7ba28e4 | ||
|
|
880272f96e | ||
|
|
7ed26fadb6 | ||
|
|
f0d25a02a6 | ||
|
|
162ba9307d | ||
|
|
49dae92b8e | ||
|
|
b484a52b6d | ||
|
|
d754091a7c | ||
|
|
e2febc24ae | ||
|
|
d0677edaaa | ||
|
|
f0aaecd0c7 | ||
|
|
3518940fec | ||
|
|
2e5c92ae0c | ||
|
|
4ad699dbe6 | ||
|
|
931be9e6aa | ||
|
|
9656d6fbd0 | ||
|
|
c7cbb13044 | ||
|
|
327d30dcc2 | ||
|
|
e4e2079917 | ||
|
|
0427506572 | ||
|
|
ea168edb43 | ||
|
|
aa039c6c05 | ||
|
|
3de998051a | ||
|
|
69ade1ae37 | ||
|
|
1d6133e3b1 | ||
|
|
203a111d1a | ||
|
|
0a20234268 | ||
|
|
7f8e50f83d | ||
|
|
443ef7d41b | ||
|
|
059ae6595d | ||
|
|
19c3dad338 | ||
|
|
81bc51c972 | ||
|
|
6c17868744 | ||
|
|
a18040ccfa | ||
|
|
0835a75503 | ||
|
|
3ee32757e5 | ||
|
|
344abfa8d8 | ||
|
|
906b2a3485 | ||
|
|
e0d2b87ed3 | ||
|
|
83a8c8b42b | ||
|
|
d840ed6c5a | ||
|
|
0112087be4 | ||
|
|
7320084e11 | ||
|
|
23929f5eaa | ||
|
|
c002d4619a | ||
|
|
f60a909bba | ||
|
|
c2c22e3968 | ||
|
|
f10299b2de | ||
|
|
1d3563ed97 | ||
|
|
f3eb2caa4e | ||
|
|
2364dacd52 | ||
|
|
883f7451c3 | ||
|
|
a534c9bca1 | ||
|
|
b14202a324 | ||
|
|
a6fae48f07 | ||
|
|
963caf2afe | ||
|
|
50b0268531 | ||
|
|
f484b64be3 | ||
|
|
349535557f | ||
|
|
de4973a270 | ||
|
|
e42d2baf8a | ||
|
|
eac435b233 | ||
|
|
447b8564e9 | ||
|
|
97cee657bd | ||
|
|
fe894754cf | ||
|
|
9ffb1d1931 | ||
|
|
a16bd30903 | ||
|
|
13f9ea8be4 | ||
|
|
304af5e980 | ||
|
|
dc180c09e9 | ||
|
|
8e20e26565 | ||
|
|
11075a4012 | ||
|
|
a9300faaf8 | ||
|
|
504827b7e5 | ||
|
|
e180130b38 | ||
|
|
faaee09827 | ||
|
|
99334795b6 | ||
|
|
8c9c59ef64 | ||
|
|
7a112000c9 | ||
|
|
1424087d5a | ||
|
|
984f4731cd | ||
|
|
3a3de64b0f | ||
|
|
0911854e9d | ||
|
|
2af8b6f445 | ||
|
|
bbfd8ca3f5 | ||
|
|
b4ed2880f7 | ||
|
|
5f18a21e86 | ||
|
|
5d188e3877 | ||
|
|
90f113a292 | ||
|
|
eecfe58297 | ||
|
|
079a747210 | ||
|
|
4be8c70f23 | ||
|
|
d9aee4df77 | ||
|
|
225de87d4d | ||
|
|
2ce7cedfbd | ||
|
|
cfb163d904 | ||
|
|
de7c9be11b | ||
|
|
841209adc9 | ||
|
|
e48d51fe6e | ||
|
|
9d436ec7ed | ||
|
|
fb2b29d088 | ||
|
|
1c46b0bc20 | ||
|
|
81d0e4696a | ||
|
|
f9a287b52b | ||
|
|
0f0072abea | ||
|
|
312933a259 | ||
|
|
288854b8f1 | ||
|
|
7f5991aa34 | ||
|
|
361df95d50 | ||
|
|
fc1ade32d7 | ||
|
|
b74c7531d9 | ||
|
|
7e3be3325a | ||
|
|
7dab7fbe66 | ||
|
|
62c06b6593 | ||
|
|
000b62969f | ||
|
|
b4473bb4a7 | ||
|
|
2c0e06d599 | ||
|
|
d2c55e8ed3 | ||
|
|
714abaa25a | ||
|
|
0017eb987b | ||
|
|
e5a0894692 | ||
|
|
a8e00e9f0f | ||
|
|
77a4c271ae | ||
|
|
014b77c3c7 | ||
|
|
076e241056 | ||
|
|
7ce57cc67a | ||
|
|
da0343283a | ||
|
|
d5f7f1ba91 | ||
|
|
8761c82afe | ||
|
|
13023141bc | ||
|
|
4dd2038625 | ||
|
|
06a32b0e9d | ||
|
|
c91ab7a76b | ||
|
|
0344aa6a49 | ||
|
|
a748c9d750 | ||
|
|
038dc372b7 | ||
|
|
bc8198fb8a | ||
|
|
f42275bd83 | ||
|
|
6bd86a724e | ||
|
|
fc96cfe8a0 | ||
|
|
a9f25fe7d6 | ||
|
|
f740fed5f2 | ||
|
|
a6d1bd12a2 | ||
|
|
e8ab20acf2 | ||
|
|
ccfe193800 | ||
|
|
bdccedca59 | ||
|
|
9abb1488df | ||
|
|
195fc1bdc3 | ||
|
|
2a9129f470 | ||
|
|
acbfc0cc6e | ||
|
|
bfb0c75e95 | ||
|
|
161a2ddae8 | ||
|
|
99621cfd66 | ||
|
|
e6e7234215 | ||
|
|
5b7b329279 | ||
|
|
3abb2c8674 | ||
|
|
39de89254f | ||
|
|
ac941968cb | ||
|
|
96f603bfd1 | ||
|
|
677e38c62d | ||
|
|
72fce20905 | ||
|
|
1eb41c20d5 | ||
|
|
dd0c1d331f | ||
|
|
12760a70a1 | ||
|
|
525d17270f | ||
|
|
bc9959f5ab | ||
|
|
94a8cd5128 | ||
|
|
5a1b2c4938 | ||
|
|
851a2ac03a | ||
|
|
34d7707f53 | ||
|
|
0aac7f62a3 | ||
|
|
34379b92d0 | ||
|
|
250999f9f5 | ||
|
|
2b3832222b | ||
|
|
c5f6d0e721 | ||
|
|
dbb0cf15b8 | ||
|
|
ab202ba951 | ||
|
|
e2c13aa7ed | ||
|
|
c1ab19f3cf | ||
|
|
beebfb2e19 | ||
|
|
cfca90aa7d | ||
|
|
19fe0a32c8 | ||
|
|
76659f8837 | ||
|
|
2254715190 | ||
|
|
ae1a5460d4 | ||
|
|
27d9f910ff | ||
|
|
28db4881d7 | ||
|
|
7c76c3ccd6 | ||
|
|
007bd24374 | ||
|
|
c8dc30287c | ||
|
|
360184bbd1 | ||
|
|
e8ed2454a1 | ||
|
|
923ecf29b8 | ||
|
|
a8f8bf5872 | ||
|
|
bedcd94020 | ||
|
|
959d4da1f8 | ||
|
|
861453c1a8 | ||
|
|
2f4072da0d | ||
|
|
411b5e0ca6 | ||
|
|
3f03963811 | ||
|
|
d43f81e118 | ||
|
|
b97dbd2515 | ||
|
|
c6a20a9ed3 | ||
|
|
27f0f29eef | ||
|
|
223508ae72 | ||
|
|
bce0a4b8cd | ||
|
|
65412a4263 | ||
|
|
0233b78c8e | ||
|
|
b0b25e4cfa | ||
|
|
806288d587 | ||
|
|
97265fc43b | ||
|
|
41ca50d0d4 | ||
|
|
9d02206fd9 | ||
|
|
ba2293eb30 | ||
|
|
8b9e28975d | ||
|
|
22ae8b8f87 | ||
|
|
187e352cbd | ||
|
|
23ef8ad28d | ||
|
|
1dadf56c42 | ||
|
|
52640b80c0 | ||
|
|
fe25f8f48f | ||
|
|
7f59572d8b | ||
|
|
90fc4c6bad | ||
|
|
16b6c0da33 | ||
|
|
488a691f29 | ||
|
|
bcbfe2ccd5 | ||
|
|
bd9a1d7ec7 | ||
|
|
9331ba64d6 | ||
|
|
21e5cb0a03 | ||
|
|
1a8e0c9ecb | ||
|
|
16fc0d31cd | ||
|
|
a622ada58b | ||
|
|
ee9c4948d3 | ||
|
|
cf28e1d963 | ||
|
|
089ec36160 | ||
|
|
04ce774c22 | ||
|
|
99c1422f37 | ||
|
|
b583a60f23 | ||
|
|
7be2910809 | ||
|
|
30de524319 | ||
|
|
c431d5e759 | ||
|
|
184b62b024 | ||
|
|
2751770350 | ||
|
|
75d98aee8e | ||
|
|
48120b9406 | ||
|
|
0e302d7959 | ||
|
|
59cd176f44 | ||
|
|
619f728f09 | ||
|
|
6e8002acc4 | ||
|
|
8a4a6174f7 | ||
|
|
ee6c4823d3 | ||
|
|
14dcb73d06 | ||
|
|
e15107e5ec | ||
|
|
0167a9462e | ||
|
|
7fa1d342ab | ||
|
|
05b9988e1d | ||
|
|
1c09e61219 | ||
|
|
35f0ad7a83 | ||
|
|
7ae1d6763a | ||
|
|
460e859795 | ||
|
|
4b88ec6460 | ||
|
|
27ee13bb7e | ||
|
|
e6cdd337c3 | ||
|
|
7d8dd12131 | ||
|
|
0800e3a136 | ||
|
|
9b0f1a2a04 | ||
|
|
9de3cb0f92 | ||
|
|
c053a8291c | ||
|
|
a0ddfe173b | ||
|
|
17843a7c71 | ||
|
|
324ae5c883 | ||
|
|
ef03989c3f | ||
|
|
63412ddd42 | ||
|
|
30ce32608a | ||
|
|
74799ad096 | ||
|
|
31176f99c8 | ||
|
|
b9439c05ec | ||
|
|
435a04da0c | ||
|
|
0040b266a5 | ||
|
|
645de137f2 | ||
|
|
1883607118 | ||
|
|
4ccae1dac7 | ||
|
|
ff75db310f | ||
|
|
5788520401 | ||
|
|
570dddc120 | ||
|
|
ea31072ae5 | ||
|
|
5eca5a6011 | ||
|
|
67d5357227 | ||
|
|
a0d04ff488 | ||
|
|
f83787508f | ||
|
|
20aba7eb17 | ||
|
|
0cdea3318c | ||
|
|
4dc2c18075 | ||
|
|
74e97abac4 | ||
|
|
b1db95a925 | ||
|
|
9dac9850b6 | ||
|
|
abe091254a | ||
|
|
d2e5367dc6 | ||
|
|
8ccd1f5fe4 | ||
|
|
50bc865dd2 | ||
|
|
74a6ee7066 | ||
|
|
89e76bcb48 | ||
|
|
c55f6baf67 | ||
|
|
ae154489e1 | ||
|
|
fdc79033ce | ||
|
|
9a8aa5e632 | ||
|
|
6b81f3ce5f | ||
|
|
aeaddfe36b | ||
|
|
20c1f30877 | ||
|
|
52ce6ff38e | ||
|
|
c692a3c80e | ||
|
|
491009636a | ||
|
|
ed16ee14ea | ||
|
|
7f2ed09267 | ||
|
|
c0976897ef | ||
|
|
85b55aa924 | ||
|
|
91d0f76783 | ||
|
|
741badf9e6 | ||
|
|
ca1f3ac377 | ||
|
|
e13e1c9ca3 | ||
|
|
06ad042443 | ||
|
|
9d333b855c | ||
|
|
f46e2acd56 | ||
|
|
5ac4d3f4ae | ||
|
|
1614eebc47 | ||
|
|
b50599b71f | ||
|
|
0459025bf8 | ||
|
|
0bd37da8c7 | ||
|
|
da969dde53 | ||
|
|
33fdd6cafa | ||
|
|
2fe68766eb | ||
|
|
205348697c | ||
|
|
9b3533c1da | ||
|
|
c3584e838e | ||
|
|
16d8b3fb58 | ||
|
|
686bbdc16b | ||
|
|
65b17e4f2b | ||
|
|
23c6898789 | ||
|
|
df2a1be2a2 | ||
|
|
2db628a2ba | ||
|
|
b6c40436c9 | ||
|
|
a8a70cac08 | ||
|
|
3eefbf97b1 | ||
|
|
3c423e0838 | ||
|
|
99cde43954 | ||
|
|
fa3a787bf7 | ||
|
|
c776dc8036 | ||
|
|
1ef068351d | ||
|
|
6abe0a1862 | ||
|
|
ff13045f52 | ||
|
|
59c09681cb | ||
|
|
f664cf6fa5 | ||
|
|
01a847a9c2 | ||
|
|
6da655f67f | ||
|
|
21df7dced1 |
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@@ -25,7 +25,9 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
images: |
|
||||
${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ env.app_version }}
|
||||
type=raw,value=latest
|
||||
@@ -42,11 +44,18 @@ jobs:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
file: docker/Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm64/v8
|
||||
@@ -56,10 +65,22 @@ jobs:
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
|
||||
- name: Get existing release body
|
||||
id: get_release_body
|
||||
continue-on-error: true
|
||||
run: |
|
||||
release_body=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.app_version }}" | \
|
||||
jq -r '.body // ""')
|
||||
echo "RELEASE_BODY<<EOF" >> $GITHUB_ENV
|
||||
echo "$release_body" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Delete Release
|
||||
uses: dev-drprasad/delete-tag-and-release@v1.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
tag_name: ${{ env.app_version }}
|
||||
tag_name: v${{ env.app_version }}
|
||||
delete_release: true
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -68,6 +89,7 @@ jobs:
|
||||
with:
|
||||
tag_name: v${{ env.app_version }}
|
||||
name: v${{ env.app_version }}
|
||||
body: ${{ env.RELEASE_BODY }}
|
||||
draft: false
|
||||
prerelease: false
|
||||
make_latest: false
|
||||
|
||||
55
.github/workflows/bulit-lite.yml
vendored
55
.github/workflows/bulit-lite.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: MoviePilot Builder v2 Lite
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- v2
|
||||
paths:
|
||||
- 'version.py'
|
||||
|
||||
jobs:
|
||||
Docker-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: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
|
||||
tags: |
|
||||
type=raw,value=lite-latest
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set Up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.lite
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha, scope=${{ github.workflow }}-docker
|
||||
cache-to: type=gha, scope=${{ github.workflow }}-docker
|
||||
30
.github/workflows/issues.yml
vendored
Normal file
30
.github/workflows/issues.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Close inactive issues
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
schedule:
|
||||
# Github Action 只支持 UTC 时间。
|
||||
# '0 18 * * *' 对应 UTC 时间的 18:00,也就是中国时区 (UTC+8) 的第二天凌晨 02:00。
|
||||
- cron: "0 18 * * *"
|
||||
|
||||
jobs:
|
||||
close-issues:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
# 标记 stale 标签时间
|
||||
days-before-issue-stale: 30
|
||||
# 关闭 issues 标签时间
|
||||
days-before-issue-close: 14
|
||||
# 自定义标签名
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "此问题已过时,因为它已打开 30 天且没有任何活动。"
|
||||
close-issue-message: "此问题已关闭,因为它在标记为 stale 后,已处于无更新状态 14 天。"
|
||||
# 忽略所有的 Pull Request,只处理 Issue
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
91
.github/workflows/pylint.yml
vendored
Normal file
91
.github/workflows/pylint.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Pylint Code Quality Check
|
||||
|
||||
on:
|
||||
# 允许手动触发
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
pylint:
|
||||
runs-on: ubuntu-latest
|
||||
name: Pylint Code Quality Check
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache pip dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/requirements.in') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install pylint
|
||||
# 安装项目依赖
|
||||
if [ -f requirements.txt ]; then
|
||||
echo "📦 安装 requirements.txt 中的依赖..."
|
||||
pip install -r requirements.txt
|
||||
elif [ -f requirements.in ]; then
|
||||
echo "📦 安装 requirements.in 中的依赖..."
|
||||
pip install -r requirements.in
|
||||
else
|
||||
echo "⚠️ 未找到依赖文件,仅安装 pylint"
|
||||
fi
|
||||
|
||||
- name: Verify pylint config
|
||||
run: |
|
||||
# 检查项目中的pylint配置文件是否存在
|
||||
if [ -f .pylintrc ]; then
|
||||
echo "✅ 找到项目配置文件: .pylintrc"
|
||||
echo "配置文件内容预览:"
|
||||
head -10 .pylintrc
|
||||
else
|
||||
echo "❌ 未找到 .pylintrc 配置文件"
|
||||
exit 1
|
||||
fi
|
||||
- name: Run pylint
|
||||
run: |
|
||||
# 运行pylint,检查主要的Python文件
|
||||
echo "🚀 运行 Pylint 错误检查..."
|
||||
|
||||
# 检查主要目录 - 只关注错误,如果有错误则退出
|
||||
echo "📂 检查 app/ 目录..."
|
||||
pylint app/ --output-format=colorized --reports=yes --score=yes
|
||||
|
||||
# 检查根目录的Python文件
|
||||
echo "📂 检查根目录 Python 文件..."
|
||||
for file in $(find . -name "*.py" -not -path "./.*" -not -path "./.venv/*" -not -path "./build/*" -not -path "./dist/*" -not -path "./tests/*" -not -path "./docs/*" -not -path "./__pycache__/*" -maxdepth 1); do
|
||||
echo "检查文件: $file"
|
||||
pylint "$file" --output-format=colorized || exit 1
|
||||
done
|
||||
|
||||
# 生成详细报告
|
||||
echo "📊 生成 Pylint 详细报告..."
|
||||
pylint app/ --output-format=json > pylint-report.json || true
|
||||
|
||||
# 显示评分(仅供参考)
|
||||
echo "📈 Pylint 评分(仅供参考):"
|
||||
pylint app/ --score=yes --reports=no | tail -2 || true
|
||||
|
||||
- name: Upload pylint report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: pylint-report
|
||||
path: pylint-report.json
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
echo "🎉 Pylint 检查完成!"
|
||||
echo "✅ 没有发现语法错误或严重问题"
|
||||
echo "📊 详细报告已保存为构建工件"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -23,4 +23,8 @@ config/cache/
|
||||
*.pyc
|
||||
*.log
|
||||
.vscode
|
||||
venv
|
||||
venv
|
||||
|
||||
# Pylint
|
||||
pylint-report.json
|
||||
.pylint.d/
|
||||
83
.pylintrc
Normal file
83
.pylintrc
Normal file
@@ -0,0 +1,83 @@
|
||||
[MASTER]
|
||||
# 指定Python路径
|
||||
init-hook='import sys; sys.path.append(".")'
|
||||
|
||||
# 忽略的文件和目录
|
||||
ignore=.git,__pycache__,.venv,build,dist,tests,docs
|
||||
|
||||
# 并行作业数量
|
||||
jobs=0
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
# 只关注错误级别的问题,禁用警告、约定和重构建议
|
||||
# E = Error (错误) - 会导致构建失败
|
||||
# W = Warning (警告) - 仅显示,不会失败
|
||||
# R = Refactor (重构建议) - 仅显示,不会失败
|
||||
# C = Convention (约定) - 仅显示,不会失败
|
||||
# I = Information (信息) - 仅显示,不会失败
|
||||
|
||||
# 禁用大部分警告、约定和重构建议,只保留错误和重要警告
|
||||
disable=all
|
||||
enable=error,
|
||||
syntax-error,
|
||||
undefined-variable,
|
||||
used-before-assignment,
|
||||
unreachable,
|
||||
return-outside-function,
|
||||
yield-outside-function,
|
||||
continue-in-finally,
|
||||
nonlocal-without-binding,
|
||||
undefined-loop-variable,
|
||||
redefined-builtin,
|
||||
not-callable,
|
||||
assignment-from-no-return,
|
||||
no-value-for-parameter,
|
||||
too-many-function-args,
|
||||
unexpected-keyword-arg,
|
||||
redundant-keyword-arg,
|
||||
import-error,
|
||||
relative-beyond-top-level
|
||||
|
||||
[REPORTS]
|
||||
# 设置报告格式
|
||||
output-format=colorized
|
||||
reports=yes
|
||||
score=yes
|
||||
|
||||
[FORMAT]
|
||||
# 最大行长度
|
||||
max-line-length=120
|
||||
# 缩进大小
|
||||
indent-string=' '
|
||||
|
||||
[DESIGN]
|
||||
# 最大参数数量
|
||||
max-args=10
|
||||
# 最大本地变量数量
|
||||
max-locals=20
|
||||
# 最大分支数量
|
||||
max-branches=15
|
||||
# 最大语句数量
|
||||
max-statements=50
|
||||
# 最大父类数量
|
||||
max-parents=7
|
||||
# 最大属性数量
|
||||
max-attributes=10
|
||||
# 最小公共方法数量
|
||||
min-public-methods=1
|
||||
# 最大公共方法数量
|
||||
max-public-methods=25
|
||||
|
||||
[SIMILARITIES]
|
||||
# 最小相似行数
|
||||
min-similarity-lines=6
|
||||
# 忽略注释
|
||||
ignore-comments=yes
|
||||
# 忽略文档字符串
|
||||
ignore-docstrings=yes
|
||||
# 忽略导入
|
||||
ignore-imports=yes
|
||||
|
||||
[TYPECHECK]
|
||||
# 生成缺失成员提示的类列表
|
||||
generated-members=requests.packages.urllib3
|
||||
@@ -1,92 +0,0 @@
|
||||
FROM python:3.12.8-slim-bookworm
|
||||
ENV LANG="C.UTF-8" \
|
||||
TZ="Asia/Shanghai" \
|
||||
HOME="/moviepilot" \
|
||||
CONFIG_DIR="/config" \
|
||||
TERM="xterm" \
|
||||
DISPLAY=:987 \
|
||||
PUID=0 \
|
||||
PGID=0 \
|
||||
UMASK=000 \
|
||||
PORT=3001 \
|
||||
NGINX_PORT=3000 \
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
WORKDIR "/app"
|
||||
RUN apt-get update -y \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get -y install \
|
||||
musl-dev \
|
||||
nginx \
|
||||
gettext-base \
|
||||
locales \
|
||||
procps \
|
||||
gosu \
|
||||
bash \
|
||||
wget \
|
||||
curl \
|
||||
busybox \
|
||||
dumb-init \
|
||||
jq \
|
||||
fuse3 \
|
||||
rsync \
|
||||
ffmpeg \
|
||||
nano \
|
||||
&& \
|
||||
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 \
|
||||
&& 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.in requirements.in
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y build-essential \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install Cython pip-tools \
|
||||
&& pip-compile requirements.in \
|
||||
&& pip install -r requirements.txt \
|
||||
&& playwright install-deps chromium \
|
||||
&& apt-get remove -y build-essential \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean -y \
|
||||
&& rm -rf \
|
||||
/tmp/* \
|
||||
/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 \
|
||||
&& cp -f /app/docker_http_proxy.conf /etc/nginx/docker_http_proxy.conf \
|
||||
&& chmod +x /entrypoint /usr/local/bin/mp_update \
|
||||
&& mkdir -p ${HOME} \
|
||||
&& groupadd -r moviepilot -g 918 \
|
||||
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 918 \
|
||||
&& 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 \
|
||||
&& python3 /app/setup.py \
|
||||
&& find /app/app -type f -name "*.py" ! -path "/app/app/main.py" -exec rm -f {} \; \
|
||||
&& FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/version.py) \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
|
||||
&& mv /dist /public \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \
|
||||
&& cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \
|
||||
while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \
|
||||
&& curl -sL "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
|
||||
&& mv -f /tmp/MoviePilot-Resources-main/resources/* /app/app/helper/ \
|
||||
&& rm -rf /tmp/* /app/build
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/config" ]
|
||||
ENTRYPOINT [ "/entrypoint" ]
|
||||
@@ -26,37 +26,31 @@ class AddDownloadAction(BaseAction):
|
||||
添加下载资源
|
||||
"""
|
||||
|
||||
# 已添加的下载
|
||||
_added_downloads = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.downloadchain = DownloadChain()
|
||||
self.mediachain = MediaChain()
|
||||
self._added_downloads = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
def name(cls) -> str: # noqa
|
||||
return "添加下载"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
def description(cls) -> str: # noqa
|
||||
return "根据资源列表添加下载任务"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
def data(cls) -> dict: # noqa
|
||||
return AddDownloadParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self._has_error
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
将上下文中的torrents添加到下载任务中
|
||||
"""
|
||||
@@ -73,13 +67,13 @@ class AddDownloadAction(BaseAction):
|
||||
if not t.meta_info:
|
||||
t.meta_info = MetaInfo(title=t.torrent_info.title, subtitle=t.torrent_info.description)
|
||||
if not t.media_info:
|
||||
t.media_info = self.mediachain.recognize_media(meta=t.meta_info)
|
||||
t.media_info = MediaChain().recognize_media(meta=t.meta_info)
|
||||
if not t.media_info:
|
||||
self._has_error = True
|
||||
logger.warning(f"{t.torrent_info.title} 未识别到媒体信息,无法下载")
|
||||
continue
|
||||
if params.only_lack:
|
||||
exists_info = self.downloadchain.media_exists(t.media_info)
|
||||
exists_info = DownloadChain().media_exists(t.media_info)
|
||||
if exists_info:
|
||||
if t.media_info.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
@@ -96,14 +90,15 @@ class AddDownloadAction(BaseAction):
|
||||
exists_episodes = exists_seasons.get(t.meta_info.begin_season)
|
||||
if exists_episodes:
|
||||
if set(t.meta_info.episode_list).issubset(exists_episodes):
|
||||
logger.warning(f"{t.meta_info.title} 第 {t.meta_info.begin_season} 季第 {t.meta_info.episode_list} 集已存在,跳过")
|
||||
logger.warning(
|
||||
f"{t.meta_info.title} 第 {t.meta_info.begin_season} 季第 {t.meta_info.episode_list} 集已存在,跳过")
|
||||
continue
|
||||
|
||||
_started = True
|
||||
did = self.downloadchain.download_single(context=t,
|
||||
downloader=params.downloader,
|
||||
save_path=params.save_path,
|
||||
label=params.labels)
|
||||
did = DownloadChain().download_single(context=t,
|
||||
downloader=params.downloader,
|
||||
save_path=params.save_path,
|
||||
label=params.labels)
|
||||
if did:
|
||||
self._added_downloads.append(did)
|
||||
# 保存缓存
|
||||
|
||||
@@ -19,29 +19,24 @@ class AddSubscribeAction(BaseAction):
|
||||
添加订阅
|
||||
"""
|
||||
|
||||
_added_subscribes = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.subscribechain = SubscribeChain()
|
||||
self.subscribeoper = SubscribeOper()
|
||||
self._added_subscribes = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
def name(cls) -> str: # noqa
|
||||
return "添加订阅"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
def description(cls) -> str: # noqa
|
||||
return "根据媒体列表添加订阅"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
def data(cls) -> dict: # noqa
|
||||
return AddSubscribeParams().dict()
|
||||
|
||||
@property
|
||||
@@ -63,19 +58,20 @@ class AddSubscribeAction(BaseAction):
|
||||
continue
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media.dict())
|
||||
if self.subscribechain.exists(mediainfo):
|
||||
subscribechain = SubscribeChain()
|
||||
if subscribechain.exists(mediainfo):
|
||||
logger.info(f"{media.title} 已存在订阅")
|
||||
continue
|
||||
# 添加订阅
|
||||
_started = True
|
||||
sid, message = self.subscribechain.add(mtype=mediainfo.type,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season,
|
||||
doubanid=mediainfo.douban_id,
|
||||
bangumiid=mediainfo.bangumi_id,
|
||||
username=settings.SUPERUSER)
|
||||
sid, message = subscribechain.add(mtype=mediainfo.type,
|
||||
title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season,
|
||||
doubanid=mediainfo.douban_id,
|
||||
bangumiid=mediainfo.bangumi_id,
|
||||
username=settings.SUPERUSER)
|
||||
if sid:
|
||||
self._added_subscribes.append(sid)
|
||||
# 保存缓存
|
||||
@@ -84,7 +80,7 @@ class AddSubscribeAction(BaseAction):
|
||||
if self._added_subscribes:
|
||||
logger.info(f"已添加 {len(self._added_subscribes)} 个订阅")
|
||||
for sid in self._added_subscribes:
|
||||
context.subscribes.append(self.subscribeoper.get(sid))
|
||||
context.subscribes.append(SubscribeOper().get(sid))
|
||||
elif _started:
|
||||
self._has_error = True
|
||||
|
||||
|
||||
@@ -16,11 +16,8 @@ class FetchDownloadsAction(BaseAction):
|
||||
获取下载任务
|
||||
"""
|
||||
|
||||
_downloads = []
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.chain = ActionChain()
|
||||
self._downloads = []
|
||||
|
||||
@classmethod
|
||||
@@ -51,7 +48,7 @@ class FetchDownloadsAction(BaseAction):
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
logger.info(f"获取下载任务 {download.download_id} 状态 ...")
|
||||
torrents = self.chain.list_torrents(hashs=[download.download_id])
|
||||
torrents = ActionChain().list_torrents(hashs=[download.download_id])
|
||||
if not torrents:
|
||||
download.completed = True
|
||||
continue
|
||||
|
||||
@@ -27,10 +27,6 @@ class FetchMediasAction(BaseAction):
|
||||
获取媒体数据
|
||||
"""
|
||||
|
||||
_inner_sources = []
|
||||
_medias = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
|
||||
@@ -40,54 +36,67 @@ class FetchMediasAction(BaseAction):
|
||||
{
|
||||
"func": RecommendChain().tmdb_trending,
|
||||
"name": '流行趋势',
|
||||
"api_path": "recommend/tmdb_trending"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_showing,
|
||||
"name": '正在热映',
|
||||
"api_path": "recommend/douban_showing"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().bangumi_calendar,
|
||||
"name": 'Bangumi每日放送',
|
||||
"api_path": "recommend/bangumi_calendar"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().tmdb_movies,
|
||||
"name": 'TMDB热门电影',
|
||||
"api_path": "recommend/tmdb_movies"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().tmdb_tvs,
|
||||
"name": 'TMDB热门电视剧',
|
||||
"api_path": "recommend/tmdb_tvs?with_original_language=zh|en|ja|ko"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_hot,
|
||||
"name": '豆瓣热门电影',
|
||||
"api_path": "recommend/douban_movie_hot"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_hot,
|
||||
"name": '豆瓣热门电视剧',
|
||||
"api_path": "recommend/douban_tv_hot"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_animation,
|
||||
"name": '豆瓣热门动漫',
|
||||
"api_path": "recommend/douban_tv_animation"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movies,
|
||||
"name": '豆瓣最新电影',
|
||||
"api_path": "recommend/douban_movies"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tvs,
|
||||
"name": '豆瓣最新电视剧',
|
||||
"api_path": "recommend/douban_tvs"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_movie_top250,
|
||||
"name": '豆瓣电影TOP250',
|
||||
"api_path": "recommend/douban_movie_top250"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_weekly_chinese,
|
||||
"name": '豆瓣国产剧集榜',
|
||||
"api_path": "recommend/douban_tv_weekly_chinese"
|
||||
},
|
||||
{
|
||||
"func": RecommendChain().douban_tv_weekly_global,
|
||||
"name": '豆瓣全球剧集榜',
|
||||
"api_path": "recommend/douban_tv_weekly_global"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -124,7 +133,7 @@ class FetchMediasAction(BaseAction):
|
||||
获取数据源
|
||||
"""
|
||||
for s in self.__inner_sources:
|
||||
if s['name'] == source:
|
||||
if s['api_path'] == source:
|
||||
return s
|
||||
return None
|
||||
|
||||
@@ -135,13 +144,14 @@ class FetchMediasAction(BaseAction):
|
||||
params = FetchMediasParams(**params)
|
||||
try:
|
||||
if params.source_type == "ranking":
|
||||
for name in params.sources:
|
||||
for api_path in params.sources:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
source = self.__get_source(name)
|
||||
source = self.__get_source(api_path)
|
||||
if not source:
|
||||
continue
|
||||
logger.info(f"获取媒体数据 {source} ...")
|
||||
name = source.get("name")
|
||||
results = []
|
||||
if source.get("func"):
|
||||
results = source['func']()
|
||||
|
||||
@@ -29,29 +29,24 @@ class FetchRssAction(BaseAction):
|
||||
获取RSS资源列表
|
||||
"""
|
||||
|
||||
_rss_torrents = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.rsshelper = RssHelper()
|
||||
self.chain = ActionChain()
|
||||
self._rss_torrents = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
def name(cls) -> str: # noqa
|
||||
return "获取RSS资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
def description(cls) -> str: # noqa
|
||||
return "订阅RSS地址获取资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
def data(cls) -> dict: # noqa
|
||||
return FetchRssParams().dict()
|
||||
|
||||
@property
|
||||
@@ -74,10 +69,10 @@ class FetchRssAction(BaseAction):
|
||||
if params.ua:
|
||||
headers["User-Agent"] = params.ua
|
||||
|
||||
rss_items = self.rsshelper.parse(url=params.url,
|
||||
proxy=settings.PROXY if params.proxy else None,
|
||||
timeout=params.timeout,
|
||||
headers=headers)
|
||||
rss_items = RssHelper().parse(url=params.url,
|
||||
proxy=settings.PROXY if params.proxy else None,
|
||||
timeout=params.timeout,
|
||||
headers=headers)
|
||||
if rss_items is None or rss_items is False:
|
||||
logger.error(f'RSS地址 {params.url} 请求失败!')
|
||||
self._has_error = True
|
||||
@@ -103,7 +98,7 @@ class FetchRssAction(BaseAction):
|
||||
meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description)
|
||||
mediainfo = None
|
||||
if params.match_media:
|
||||
mediainfo = self.chain.recognize_media(meta)
|
||||
mediainfo = ActionChain().recognize_media(meta)
|
||||
if not mediainfo:
|
||||
logger.warning(f"{torrentinfo.title} 未识别到媒体信息")
|
||||
continue
|
||||
|
||||
@@ -29,26 +29,23 @@ class FetchTorrentsAction(BaseAction):
|
||||
搜索站点资源
|
||||
"""
|
||||
|
||||
_torrents = []
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.searchchain = SearchChain()
|
||||
self._torrents = []
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
def name(cls) -> str: # noqa
|
||||
return "搜索站点资源"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
def description(cls) -> str: # noqa
|
||||
return "搜索站点种子资源列表"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
def data(cls) -> dict: # noqa
|
||||
return FetchTorrentsParams().dict()
|
||||
|
||||
@property
|
||||
@@ -60,9 +57,10 @@ class FetchTorrentsAction(BaseAction):
|
||||
搜索站点,获取资源列表
|
||||
"""
|
||||
params = FetchTorrentsParams(**params)
|
||||
searchchain = SearchChain()
|
||||
if params.search_type == "keyword":
|
||||
# 按关键字搜索
|
||||
torrents = self.searchchain.search_by_title(title=params.name, sites=params.sites)
|
||||
torrents = searchchain.search_by_title(title=params.name, sites=params.sites)
|
||||
for torrent in torrents:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
@@ -74,7 +72,7 @@ class FetchTorrentsAction(BaseAction):
|
||||
continue
|
||||
# 识别媒体信息
|
||||
if params.match_media:
|
||||
torrent.media_info = self.searchchain.recognize_media(torrent.meta_info)
|
||||
torrent.media_info = searchchain.recognize_media(torrent.meta_info)
|
||||
if not torrent.media_info:
|
||||
logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息")
|
||||
continue
|
||||
@@ -84,10 +82,10 @@ class FetchTorrentsAction(BaseAction):
|
||||
for media in context.medias:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
torrents = self.searchchain.search_by_id(tmdbid=media.tmdb_id,
|
||||
doubanid=media.douban_id,
|
||||
mtype=MediaType(media.type),
|
||||
sites=params.sites)
|
||||
torrents = searchchain.search_by_id(tmdbid=media.tmdb_id,
|
||||
doubanid=media.douban_id,
|
||||
mtype=MediaType(media.type),
|
||||
sites=params.sites)
|
||||
for torrent in torrents:
|
||||
self._torrents.append(torrent)
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ class FilterMediasAction(BaseAction):
|
||||
过滤媒体数据
|
||||
"""
|
||||
|
||||
_medias = []
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._medias = []
|
||||
|
||||
@@ -27,12 +27,8 @@ class FilterTorrentsAction(BaseAction):
|
||||
过滤资源数据
|
||||
"""
|
||||
|
||||
_torrents = []
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.torrenthelper = TorrentHelper()
|
||||
self.chain = ActionChain()
|
||||
self._torrents = []
|
||||
|
||||
@classmethod
|
||||
@@ -62,7 +58,7 @@ class FilterTorrentsAction(BaseAction):
|
||||
for torrent in context.torrents:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
if self.torrenthelper.filter_torrent(
|
||||
if TorrentHelper().filter_torrent(
|
||||
torrent_info=torrent.torrent_info,
|
||||
filter_params={
|
||||
"quality": params.quality,
|
||||
@@ -73,7 +69,7 @@ class FilterTorrentsAction(BaseAction):
|
||||
"size": params.size
|
||||
}
|
||||
):
|
||||
if self.chain.filter_torrents(
|
||||
if ActionChain().filter_torrents(
|
||||
rule_groups=params.rule_groups,
|
||||
torrent_list=[torrent.torrent_info],
|
||||
mediainfo=torrent.media_info
|
||||
|
||||
70
app/actions/invoke_plugin.py
Normal file
70
app/actions/invoke_plugin.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction
|
||||
from app.core.plugin import PluginManager
|
||||
from app.log import logger
|
||||
from app.schemas import ActionParams, ActionContext
|
||||
|
||||
|
||||
class InvokePluginParams(ActionParams):
|
||||
"""
|
||||
调用插件动作参数
|
||||
"""
|
||||
plugin_id: str = Field(default=None, description="插件ID")
|
||||
action_id: str = Field(default=None, description="动作ID")
|
||||
action_params: dict = Field(default={}, description="动作参数")
|
||||
|
||||
|
||||
class InvokePluginAction(BaseAction):
|
||||
"""
|
||||
调用插件
|
||||
"""
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self._success = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
return "调用插件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
return "调用插件提供的动作"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
return InvokePluginParams().dict()
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return self._success
|
||||
|
||||
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
|
||||
"""
|
||||
执行插件定义的动作
|
||||
"""
|
||||
params = InvokePluginParams(**params)
|
||||
if not params.plugin_id or not params.action_id:
|
||||
return context
|
||||
try:
|
||||
plugin_actions = PluginManager().get_plugin_actions(params.plugin_id)
|
||||
if not plugin_actions:
|
||||
logger.error(f"插件不存在: {params.plugin_id}")
|
||||
return context
|
||||
actions = plugin_actions[0].get("actions", [])
|
||||
action = next((action for action in actions if action.action_id == params.action_id), None)
|
||||
if not action or not action.get("func"):
|
||||
logger.error(f"插件动作不存在: {params.plugin_id} - {params.action_id}")
|
||||
return context
|
||||
# 执行插件动作
|
||||
self._success, context = action["func"](context, **params.action_params)
|
||||
except Exception as e:
|
||||
self._success = False
|
||||
logger.error(f"调用插件动作失败: {e}")
|
||||
return context
|
||||
self.job_done()
|
||||
return context
|
||||
@@ -24,12 +24,8 @@ class ScanFileAction(BaseAction):
|
||||
整理文件
|
||||
"""
|
||||
|
||||
_fileitems = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.storagechain = StorageChain()
|
||||
self._fileitems = []
|
||||
self._has_error = False
|
||||
|
||||
@@ -59,12 +55,13 @@ class ScanFileAction(BaseAction):
|
||||
params = ScanFileParams(**params)
|
||||
if not params.storage or not params.directory:
|
||||
return context
|
||||
fileitem = self.storagechain.get_file_item(params.storage, Path(params.directory))
|
||||
storagechain = StorageChain()
|
||||
fileitem = storagechain.get_file_item(params.storage, Path(params.directory))
|
||||
if not fileitem:
|
||||
logger.error(f"目录不存在: 【{params.storage}】{params.directory}")
|
||||
self._has_error = True
|
||||
return context
|
||||
files = self.storagechain.list_files(fileitem, recursion=True)
|
||||
files = storagechain.list_files(fileitem, recursion=True)
|
||||
for file in files:
|
||||
if global_vars.is_workflow_stopped(workflow_id):
|
||||
break
|
||||
|
||||
@@ -21,13 +21,8 @@ class ScrapeFileAction(BaseAction):
|
||||
刮削文件
|
||||
"""
|
||||
|
||||
_scraped_files = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.storagechain = StorageChain()
|
||||
self.mediachain = MediaChain()
|
||||
self._scraped_files = []
|
||||
self._has_error = False
|
||||
|
||||
@@ -61,7 +56,7 @@ class ScrapeFileAction(BaseAction):
|
||||
break
|
||||
if fileitem in self._scraped_files:
|
||||
continue
|
||||
if not self.storagechain.exists(fileitem):
|
||||
if not StorageChain().exists(fileitem):
|
||||
continue
|
||||
# 检查缓存
|
||||
cache_key = f"{fileitem.path}"
|
||||
@@ -69,12 +64,13 @@ class ScrapeFileAction(BaseAction):
|
||||
logger.info(f"{fileitem.path} 已刮削过,跳过")
|
||||
continue
|
||||
meta = MetaInfoPath(Path(fileitem.path))
|
||||
mediainfo = self.mediachain.recognize_media(meta)
|
||||
mediachain = MediaChain()
|
||||
mediainfo = mediachain.recognize_media(meta)
|
||||
if not mediainfo:
|
||||
_failed_count += 1
|
||||
logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削")
|
||||
continue
|
||||
self.mediachain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
mediachain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
|
||||
self._scraped_files.append(fileitem)
|
||||
# 保存缓存
|
||||
self.save_cache(workflow_id, cache_key)
|
||||
|
||||
@@ -4,7 +4,7 @@ from pydantic import Field
|
||||
|
||||
from app.actions import BaseAction, ActionChain
|
||||
from app.schemas import ActionParams, ActionContext, Notification
|
||||
from core.config import settings
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class SendMessageParams(ActionParams):
|
||||
@@ -22,7 +22,6 @@ class SendMessageAction(BaseAction):
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.chain = ActionChain()
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
@@ -60,7 +59,7 @@ class SendMessageAction(BaseAction):
|
||||
if not params.client:
|
||||
params.client = [""]
|
||||
for client in params.client:
|
||||
self.chain.post_message(
|
||||
ActionChain().post_message(
|
||||
Notification(
|
||||
source=client,
|
||||
userid=params.userid,
|
||||
|
||||
@@ -26,30 +26,24 @@ class TransferFileAction(BaseAction):
|
||||
整理文件
|
||||
"""
|
||||
|
||||
_fileitems = []
|
||||
_has_error = False
|
||||
|
||||
def __init__(self, action_id: str):
|
||||
super().__init__(action_id)
|
||||
self.transferchain = TransferChain()
|
||||
self.storagechain = StorageChain()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
self._fileitems = []
|
||||
self._has_error = False
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def name(cls) -> str: # noqa
|
||||
def name(cls) -> str: # noqa
|
||||
return "整理文件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def description(cls) -> str: # noqa
|
||||
def description(cls) -> str: # noqa
|
||||
return "整理队列中的文件"
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def data(cls) -> dict: # noqa
|
||||
def data(cls) -> dict: # noqa
|
||||
return TransferFileParams().dict()
|
||||
|
||||
@property
|
||||
@@ -72,6 +66,9 @@ class TransferFileAction(BaseAction):
|
||||
params = TransferFileParams(**params)
|
||||
# 失败次数
|
||||
_failed_count = 0
|
||||
storagechain = StorageChain()
|
||||
transferchain = TransferChain()
|
||||
transferhis = TransferHistoryOper()
|
||||
if params.source == "downloads":
|
||||
# 从下载任务中整理文件
|
||||
for download in context.downloads:
|
||||
@@ -85,16 +82,16 @@ class TransferFileAction(BaseAction):
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{download.path} 已整理过,跳过")
|
||||
continue
|
||||
fileitem = self.storagechain.get_file_item(storage="local", path=Path(download.path))
|
||||
fileitem = storagechain.get_file_item(storage="local", path=Path(download.path))
|
||||
if not fileitem:
|
||||
logger.info(f"文件 {download.path} 不存在")
|
||||
continue
|
||||
transferd = self.transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
|
||||
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
|
||||
if transferd:
|
||||
# 已经整理过的文件不再整理
|
||||
continue
|
||||
logger.info(f"开始整理文件 {download.path} ...")
|
||||
state, errmsg = self.transferchain.do_transfer(fileitem, background=False)
|
||||
state, errmsg = transferchain.do_transfer(fileitem, background=False)
|
||||
if not state:
|
||||
_failed_count += 1
|
||||
logger.error(f"整理文件 {download.path} 失败: {errmsg}")
|
||||
@@ -112,13 +109,13 @@ class TransferFileAction(BaseAction):
|
||||
if self.check_cache(workflow_id, cache_key):
|
||||
logger.info(f"{fileitem.path} 已整理过,跳过")
|
||||
continue
|
||||
transferd = self.transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
|
||||
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
|
||||
if transferd:
|
||||
# 已经整理过的文件不再整理
|
||||
continue
|
||||
logger.info(f"开始整理文件 {fileitem.path} ...")
|
||||
state, errmsg = self.transferchain.do_transfer(fileitem, background=False,
|
||||
continue_callback=check_continue)
|
||||
state, errmsg = transferchain.do_transfer(fileitem, background=False,
|
||||
continue_callback=check_continue)
|
||||
if not state:
|
||||
_failed_count += 1
|
||||
logger.error(f"整理文件 {fileitem.path} 失败: {errmsg}")
|
||||
|
||||
@@ -2,7 +2,7 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import login, user, site, message, webhook, subscribe, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, prefix="/login", tags=["login"])
|
||||
@@ -27,3 +27,4 @@ api_router.include_router(bangumi.router, prefix="/bangumi", tags=["bangumi"])
|
||||
api_router.include_router(discover.router, prefix="/discover", tags=["discover"])
|
||||
api_router.include_router(recommend.router, prefix="/recommend", tags=["recommend"])
|
||||
api_router.include_router(workflow.router, prefix="/workflow", tags=["workflow"])
|
||||
api_router.include_router(torrent.router, prefix="/torrent", tags=["torrent"])
|
||||
|
||||
@@ -7,9 +7,9 @@ from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.schemas import DiscoverSourceEventData
|
||||
from app.schemas.types import ChainEventType, MediaType
|
||||
from chain.bangumi import BangumiChain
|
||||
from chain.douban import DoubanChain
|
||||
from chain.tmdb import TmdbChain
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -94,22 +94,22 @@ def add(
|
||||
|
||||
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
|
||||
def start(
|
||||
hashString: str,
|
||||
hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
开如下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "start")
|
||||
ret = DownloadChain().set_downloading(hashString, "start", name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
|
||||
def stop(hashString: str,
|
||||
def stop(hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
暂停下载任务
|
||||
"""
|
||||
ret = DownloadChain().set_downloading(hashString, "stop")
|
||||
ret = DownloadChain().set_downloading(hashString, "stop", name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
|
||||
@@ -125,10 +125,10 @@ def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
|
||||
def delete(hashString: str,
|
||||
def delete(hashString: str, name: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
ret = DownloadChain().remove_downloading(hashString)
|
||||
ret = DownloadChain().remove_downloading(hashString, name=name)
|
||||
return schemas.Response(success=True if ret else False)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from typing import List, Any, Optional
|
||||
|
||||
import jieba
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.storage import StorageChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.security import verify_token
|
||||
from app.db import get_db
|
||||
@@ -59,9 +57,6 @@ def transfer_history(title: Optional[str] = None,
|
||||
status = True
|
||||
|
||||
if title:
|
||||
if settings.TOKENIZED_SEARCH:
|
||||
words = jieba.cut(title, HMM=False)
|
||||
title = "%".join(words)
|
||||
total = TransferHistory.count_by_title(db, title=title, status=status)
|
||||
result = TransferHistory.list_by_title(db, title=title, page=page,
|
||||
count=count, status=status)
|
||||
|
||||
@@ -5,13 +5,11 @@ from fastapi import APIRouter, Depends, Form, HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app import schemas
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.user import UserChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.core import security
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.utils.web import WebUtils
|
||||
from app.helper.wallpaper import WallpaperHelper
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -45,7 +43,8 @@ def login_access_token(
|
||||
user_id=user_or_message.id,
|
||||
user_name=user_or_message.name,
|
||||
avatar=user_or_message.avatar,
|
||||
level=level
|
||||
level=level,
|
||||
permissions= user_or_message.permissions or {},
|
||||
)
|
||||
|
||||
|
||||
@@ -54,12 +53,7 @@ def wallpaper() -> Any:
|
||||
"""
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
url = WebUtils.get_bing_wallpaper()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
url = MediaServerChain().get_latest_wallpaper()
|
||||
else:
|
||||
url = TmdbChain().get_random_wallpager()
|
||||
url = WallpaperHelper().get_wallpaper()
|
||||
if url:
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
@@ -73,9 +67,4 @@ def wallpapers() -> Any:
|
||||
"""
|
||||
获取登录页面电影海报
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
return WebUtils.get_bing_wallpapers()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
return MediaServerChain().get_latest_wallpapers()
|
||||
else:
|
||||
return TmdbChain().get_trending_wallpapers()
|
||||
return WallpaperHelper().get_wallpapers()
|
||||
|
||||
@@ -149,11 +149,12 @@ def seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any
|
||||
"""
|
||||
查询媒体剧集组列表(themoviedb)
|
||||
"""
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return []
|
||||
return mediainfo.episode_groups
|
||||
|
||||
|
||||
@router.get("/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason])
|
||||
def seasons(mediaid: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
@@ -198,7 +199,7 @@ def seasons(mediaid: Optional[str] = None,
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: int = None,
|
||||
def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
根据媒体ID查询themoviedb或豆瓣媒体信息,type_name: 电影/电视剧
|
||||
@@ -219,14 +220,13 @@ def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: int
|
||||
)
|
||||
event = eventmanager.send_event(ChainEventType.MediaRecognizeConvert, event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data:
|
||||
if event and event.event_data and event.event_data.media_dict:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
if event_data.media_dict:
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=new_id, mtype=mtype)
|
||||
elif event_data.convert_type == "douban":
|
||||
mediainfo = MediaChain().recognize_media(doubanid=new_id, mtype=mtype)
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
mediainfo = MediaChain().recognize_media(tmdbid=new_id, mtype=mtype)
|
||||
elif event_data.convert_type == "douban":
|
||||
mediainfo = MediaChain().recognize_media(doubanid=new_id, mtype=mtype)
|
||||
elif title:
|
||||
# 使用名称识别兜底
|
||||
meta = MetaInfo(title)
|
||||
|
||||
@@ -121,7 +121,7 @@ def not_exists(media_in: schemas.MediaInfo,
|
||||
|
||||
|
||||
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
|
||||
def latest(server: str, count: Optional[int] = 18,
|
||||
def latest(server: str, count: Optional[int] = 20,
|
||||
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import mimetypes
|
||||
import shutil
|
||||
from typing import Annotated, Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from starlette import status
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
from app import schemas
|
||||
from app.command import Command
|
||||
@@ -16,7 +20,6 @@ from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
|
||||
PROTECTED_ROUTES = {"/api/v1/openapi.json", "/docs", "/docs/oauth2-redirect", "/redoc"}
|
||||
|
||||
PLUGIN_PREFIX = f"{settings.API_V1_STR}/plugin"
|
||||
|
||||
router = APIRouter()
|
||||
@@ -66,9 +69,13 @@ def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
|
||||
try:
|
||||
api["path"] = api_path
|
||||
allow_anonymous = api.pop("allow_anonymous", False)
|
||||
auth_mode = api.pop("auth", "apikey")
|
||||
dependencies = api.setdefault("dependencies", [])
|
||||
if not allow_anonymous and Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
if not allow_anonymous:
|
||||
if auth_mode == "bear" and Depends(verify_token) not in dependencies:
|
||||
dependencies.append(Depends(verify_token))
|
||||
elif Depends(verify_apikey) not in dependencies:
|
||||
dependencies.append(Depends(verify_apikey))
|
||||
app.add_api_route(**api, tags=["plugin"])
|
||||
is_modified = True
|
||||
logger.debug(f"Added plugin route: {api_path}")
|
||||
@@ -116,9 +123,21 @@ def _clean_protected_routes(existing_paths: dict):
|
||||
logger.error(f"Error removing protected route {protected_route}: {str(e)}")
|
||||
|
||||
|
||||
def register_plugin(plugin_id: str):
|
||||
"""
|
||||
注册一个插件相关的服务
|
||||
"""
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
|
||||
|
||||
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
|
||||
def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
state: Optional[str] = "all") -> List[schemas.Plugin]:
|
||||
state: Optional[str] = "all", force: bool = False) -> List[schemas.Plugin]:
|
||||
"""
|
||||
查询所有插件清单,包括本地插件和在线插件,插件状态:installed, market, all
|
||||
"""
|
||||
@@ -126,13 +145,13 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
local_plugins = PluginManager().get_local_plugins()
|
||||
# 已安装插件
|
||||
installed_plugins = [plugin for plugin in local_plugins if plugin.installed]
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
if state == "installed":
|
||||
return installed_plugins
|
||||
|
||||
|
||||
# 未安装的本地插件
|
||||
not_installed_plugins = [plugin for plugin in local_plugins if not plugin.installed]
|
||||
# 在线插件
|
||||
online_plugins = PluginManager().get_online_plugins()
|
||||
online_plugins = PluginManager().get_online_plugins(force)
|
||||
if not online_plugins:
|
||||
# 没有获取在线插件
|
||||
if state == "market":
|
||||
@@ -159,6 +178,7 @@ def all_plugins(_: schemas.TokenPayload = Depends(get_current_active_superuser),
|
||||
if state == "market":
|
||||
# 返回未安装的插件
|
||||
return market_plugins
|
||||
|
||||
# 返回所有插件
|
||||
return installed_plugins + market_plugins
|
||||
|
||||
@@ -179,6 +199,18 @@ def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
return PluginHelper().get_statistic()
|
||||
|
||||
|
||||
@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response)
|
||||
def reload_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重新加载插件
|
||||
"""
|
||||
# 重新加载插件
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
register_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
|
||||
def install(plugin_id: str,
|
||||
repo_url: Optional[str] = "",
|
||||
@@ -207,36 +239,65 @@ def install(plugin_id: str,
|
||||
install_plugins.append(plugin_id)
|
||||
# 保存设置
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 加载插件到内存
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
|
||||
def remotes(token: str) -> Any:
|
||||
"""
|
||||
获取插件联邦组件列表
|
||||
"""
|
||||
if token != "moviepilot":
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
return PluginManager().get_plugin_remotes()
|
||||
|
||||
|
||||
@router.get("/form/{plugin_id}", summary="获取插件表单页面")
|
||||
def plugin_form(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件配置表单
|
||||
根据插件ID获取插件配置表单或Vue组件URL
|
||||
"""
|
||||
conf, model = PluginManager().get_plugin_form(plugin_id)
|
||||
return {
|
||||
"conf": conf,
|
||||
"model": model
|
||||
}
|
||||
plugin_instance = PluginManager().running_plugins.get(plugin_id)
|
||||
if not plugin_instance:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
|
||||
|
||||
# 渲染模式
|
||||
render_mode, _ = plugin_instance.get_render_mode()
|
||||
try:
|
||||
conf, model = plugin_instance.get_form()
|
||||
return {
|
||||
"render_mode": render_mode,
|
||||
"conf": conf,
|
||||
"model": PluginManager().get_plugin_config(plugin_id) or model
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
|
||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> List[dict]:
|
||||
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
根据插件ID获取插件数据页面
|
||||
"""
|
||||
return PluginManager().get_plugin_page(plugin_id)
|
||||
plugin_instance = PluginManager().running_plugins.get(plugin_id)
|
||||
if not plugin_instance:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {plugin_id} 不存在或未加载")
|
||||
|
||||
# 渲染模式
|
||||
render_mode, _ = plugin_instance.get_render_mode()
|
||||
try:
|
||||
page = plugin_instance.get_page()
|
||||
return {
|
||||
"render_mode": render_mode,
|
||||
"page": page or []
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"插件 {plugin_id} 调用方法 get_page 出错: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
|
||||
@@ -247,22 +308,22 @@ def plugin_dashboard_meta(_: schemas.TokenPayload = Depends(verify_token)) -> Li
|
||||
return PluginManager().get_plugin_dashboard_meta()
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard_by_key(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, key, user_agent)
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, user_agent=user_agent)
|
||||
|
||||
|
||||
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
|
||||
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
|
||||
"""
|
||||
根据插件ID获取插件仪表板
|
||||
"""
|
||||
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
|
||||
return plugin_dashboard_by_key(plugin_id, "", user_agent)
|
||||
|
||||
|
||||
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
|
||||
@@ -271,21 +332,116 @@ def reset_plugin(plugin_id: str,
|
||||
"""
|
||||
根据插件ID重置插件配置及数据
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
# 删除配置
|
||||
PluginManager().delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
# 删除插件所有数据
|
||||
PluginManager().delete_plugin_data(plugin_id)
|
||||
# 重新生效插件
|
||||
PluginManager().reload_plugin(plugin_id)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
# 重新加载插件
|
||||
reload_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
|
||||
def plugin_static_file(plugin_id: str, filepath: str):
|
||||
"""
|
||||
获取插件静态文件
|
||||
"""
|
||||
# 基础安全检查
|
||||
if ".." in filepath or ".." in plugin_id:
|
||||
logger.warning(f"Static File API: Path traversal attempt detected: {plugin_id}/{filepath}")
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Forbidden")
|
||||
|
||||
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||
plugin_file_path = plugin_base_dir / filepath
|
||||
if not plugin_file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在")
|
||||
if not plugin_file_path.is_file():
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"{plugin_file_path} 不是文件")
|
||||
|
||||
# 判断 MIME 类型
|
||||
response_type, _ = mimetypes.guess_type(str(plugin_file_path))
|
||||
suffix = plugin_file_path.suffix.lower()
|
||||
# 强制修正 .mjs 和 .js 的 MIME 类型
|
||||
if suffix in ['.js', '.mjs']:
|
||||
response_type = 'application/javascript'
|
||||
elif suffix == '.css' and not response_type: # 如果 guess_type 没猜对 css,也修正
|
||||
response_type = 'text/css'
|
||||
elif not response_type: # 对于其他猜不出的类型
|
||||
response_type = 'application/octet-stream'
|
||||
|
||||
try:
|
||||
return FileResponse(plugin_file_path, media_type=response_type)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating/sending FileResponse for {plugin_file_path}: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Internal Server Error")
|
||||
|
||||
|
||||
@router.get("/folders", summary="获取插件文件夹配置", response_model=dict)
|
||||
def get_plugin_folders(_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
"""
|
||||
获取插件文件夹分组配置
|
||||
"""
|
||||
try:
|
||||
result = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[文件夹API] 获取文件夹配置失败: {str(e)}")
|
||||
return {}
|
||||
|
||||
|
||||
@router.post("/folders", summary="保存插件文件夹配置", response_model=schemas.Response)
|
||||
def save_plugin_folders(folders: dict, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
保存插件文件夹分组配置
|
||||
"""
|
||||
try:
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[文件夹API] 保存文件夹配置失败: {str(e)}")
|
||||
return schemas.Response(success=False, message=str(e))
|
||||
|
||||
|
||||
@router.post("/folders/{folder_name}", summary="创建插件文件夹", response_model=schemas.Response)
|
||||
def create_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
创建新的插件文件夹
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
if folder_name not in folders:
|
||||
folders[folder_name] = []
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 创建成功")
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 已存在")
|
||||
|
||||
|
||||
@router.delete("/folders/{folder_name}", summary="删除插件文件夹", response_model=schemas.Response)
|
||||
def delete_plugin_folder(folder_name: str, _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
删除插件文件夹
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
if folder_name in folders:
|
||||
del folders[folder_name]
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 删除成功")
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"文件夹 '{folder_name}' 不存在")
|
||||
|
||||
|
||||
@router.put("/folders/{folder_name}/plugins", summary="更新文件夹中的插件", response_model=schemas.Response)
|
||||
def update_folder_plugins(folder_name: str, plugin_ids: List[str], _: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
更新指定文件夹中的插件列表
|
||||
"""
|
||||
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
|
||||
folders[folder_name] = plugin_ids
|
||||
SystemConfigOper().set(SystemConfigKey.PluginFolders, folders)
|
||||
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新")
|
||||
|
||||
|
||||
@router.get("/{plugin_id}", summary="获取插件配置")
|
||||
def plugin_config(plugin_id: str,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> dict:
|
||||
@@ -301,16 +457,13 @@ def set_plugin_config(plugin_id: str, conf: dict,
|
||||
"""
|
||||
更新插件配置
|
||||
"""
|
||||
plugin_manager = PluginManager()
|
||||
# 保存配置
|
||||
PluginManager().save_plugin_config(plugin_id, conf)
|
||||
plugin_manager.save_plugin_config(plugin_id, conf)
|
||||
# 重新生效插件
|
||||
PluginManager().init_plugin(plugin_id, conf)
|
||||
plugin_manager.init_plugin(plugin_id, conf)
|
||||
# 注册插件服务
|
||||
Scheduler().update_plugin_job(plugin_id)
|
||||
# 注册菜单命令
|
||||
Command().init_commands(plugin_id)
|
||||
# 注册插件API
|
||||
register_plugin_api(plugin_id)
|
||||
register_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@@ -320,22 +473,153 @@ def uninstall_plugin(plugin_id: str,
|
||||
"""
|
||||
卸载插件
|
||||
"""
|
||||
config_oper = SystemConfigOper()
|
||||
# 删除已安装信息
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
for plugin in install_plugins:
|
||||
if plugin == plugin_id:
|
||||
install_plugins.remove(plugin)
|
||||
break
|
||||
# 保存
|
||||
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins)
|
||||
# 移除插件API
|
||||
remove_plugin_api(plugin_id)
|
||||
# 移除插件服务
|
||||
Scheduler().remove_plugin_job(plugin_id)
|
||||
# 判断是否为分身
|
||||
plugin_manager = PluginManager()
|
||||
plugin_class = plugin_manager.plugins.get(plugin_id)
|
||||
if getattr(plugin_class, "is_clone", False):
|
||||
# 如果是分身插件,则删除分身数据和配置
|
||||
plugin_manager.delete_plugin_config(plugin_id)
|
||||
plugin_manager.delete_plugin_data(plugin_id)
|
||||
# 删除分身文件
|
||||
plugin_base_dir = settings.ROOT_PATH / "app" / "plugins" / plugin_id.lower()
|
||||
if plugin_base_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(plugin_base_dir)
|
||||
plugin_manager.plugins.pop(plugin_id, None)
|
||||
except Exception as e:
|
||||
logger.error(f"删除插件分身目录 {plugin_base_dir} 失败: {str(e)}")
|
||||
# 从插件文件夹中移除该插件
|
||||
_remove_plugin_from_folders(plugin_id)
|
||||
# 移除插件
|
||||
PluginManager().remove_plugin(plugin_id)
|
||||
plugin_manager.remove_plugin(plugin_id)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
# 注册全部插件API
|
||||
register_plugin_api()
|
||||
@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response)
|
||||
def clone_plugin(plugin_id: str,
|
||||
clone_data: dict,
|
||||
_: schemas.TokenPayload = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
创建插件分身
|
||||
"""
|
||||
try:
|
||||
success, message = PluginManager().clone_plugin(
|
||||
plugin_id=plugin_id,
|
||||
suffix=clone_data.get("suffix", ""),
|
||||
name=clone_data.get("name", ""),
|
||||
description=clone_data.get("description", ""),
|
||||
version=clone_data.get("version", ""),
|
||||
icon=clone_data.get("icon", "")
|
||||
)
|
||||
|
||||
if success:
|
||||
# 注册插件服务
|
||||
reload_plugin(message)
|
||||
# 将分身插件添加到原插件所在的文件夹中
|
||||
_add_clone_to_plugin_folder(plugin_id, message)
|
||||
return schemas.Response(success=True, message="插件分身创建成功")
|
||||
else:
|
||||
return schemas.Response(success=False, message=message)
|
||||
except Exception as e:
|
||||
logger.error(f"创建插件分身失败:{str(e)}")
|
||||
return schemas.Response(success=False, message=f"创建插件分身失败:{str(e)}")
|
||||
|
||||
|
||||
def _add_clone_to_plugin_folder(original_plugin_id: str, clone_plugin_id: str):
|
||||
"""
|
||||
将分身插件添加到原插件所在的文件夹中
|
||||
:param original_plugin_id: 原插件ID
|
||||
:param clone_plugin_id: 分身插件ID
|
||||
"""
|
||||
try:
|
||||
config_oper = SystemConfigOper()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
# 查找原插件所在的文件夹
|
||||
target_folder = None
|
||||
for folder_name, folder_data in folders.items():
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
|
||||
if original_plugin_id in folder_data['plugins']:
|
||||
target_folder = folder_name
|
||||
break
|
||||
elif isinstance(folder_data, list):
|
||||
# 旧格式:直接是插件列表
|
||||
if original_plugin_id in folder_data:
|
||||
target_folder = folder_name
|
||||
break
|
||||
|
||||
# 如果找到了原插件所在的文件夹,则将分身插件也添加到该文件夹中
|
||||
if target_folder:
|
||||
folder_data = folders[target_folder]
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
# 新格式
|
||||
if clone_plugin_id not in folder_data['plugins']:
|
||||
folder_data['plugins'].append(clone_plugin_id)
|
||||
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中")
|
||||
elif isinstance(folder_data, list):
|
||||
# 旧格式
|
||||
if clone_plugin_id not in folder_data:
|
||||
folder_data.append(clone_plugin_id)
|
||||
logger.info(f"已将分身插件 {clone_plugin_id} 添加到文件夹 '{target_folder}' 中")
|
||||
|
||||
# 保存更新后的文件夹配置
|
||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||
else:
|
||||
logger.info(f"原插件 {original_plugin_id} 不在任何文件夹中,分身插件 {clone_plugin_id} 将保持独立")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理插件文件夹时出错:{str(e)}")
|
||||
# 文件夹处理失败不影响插件分身创建的整体流程
|
||||
|
||||
|
||||
def _remove_plugin_from_folders(plugin_id: str):
|
||||
"""
|
||||
从所有文件夹中移除指定的插件
|
||||
:param plugin_id: 要移除的插件ID
|
||||
"""
|
||||
try:
|
||||
config_oper = SystemConfigOper()
|
||||
# 获取插件文件夹配置
|
||||
folders = config_oper.get(SystemConfigKey.PluginFolders) or {}
|
||||
|
||||
# 标记是否有修改
|
||||
modified = False
|
||||
|
||||
# 遍历所有文件夹,移除指定插件
|
||||
for folder_name, folder_data in folders.items():
|
||||
if isinstance(folder_data, dict) and 'plugins' in folder_data:
|
||||
# 新格式:{"plugins": [...], "order": ..., "icon": ...}
|
||||
if plugin_id in folder_data['plugins']:
|
||||
folder_data['plugins'].remove(plugin_id)
|
||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||
modified = True
|
||||
elif isinstance(folder_data, list):
|
||||
# 旧格式:直接是插件列表
|
||||
if plugin_id in folder_data:
|
||||
folder_data.remove(plugin_id)
|
||||
logger.info(f"已从文件夹 '{folder_name}' 中移除插件 {plugin_id}")
|
||||
modified = True
|
||||
|
||||
# 如果有修改,保存更新后的文件夹配置
|
||||
if modified:
|
||||
config_oper.set(SystemConfigKey.PluginFolders, folders)
|
||||
else:
|
||||
logger.debug(f"插件 {plugin_id} 不在任何文件夹中,无需移除")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"从文件夹中移除插件时出错:{str(e)}")
|
||||
# 文件夹处理失败不影响插件卸载的整体流程
|
||||
|
||||
@@ -5,8 +5,10 @@ from sqlalchemy.orm import Session
|
||||
from starlette.background import BackgroundTasks
|
||||
|
||||
from app import schemas
|
||||
from app.api.endpoints.plugin import register_plugin_api
|
||||
from app.chain.site import SiteChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.command import Command
|
||||
from app.core.event import EventManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.security import verify_token
|
||||
@@ -16,6 +18,7 @@ from app.db.models.site import Site
|
||||
from app.db.models.siteicon import SiteIcon
|
||||
from app.db.models.sitestatistic import SiteStatistic
|
||||
from app.db.models.siteuserdata import SiteUserData
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.sites import SitesHelper
|
||||
@@ -385,11 +388,29 @@ def auth_site(
|
||||
return schemas.Response(success=False, message="请输入认证站点和认证参数")
|
||||
status, msg = SitesHelper().check_user(auth_info.site, auth_info.params)
|
||||
SystemConfigOper().set(SystemConfigKey.UserSiteAuthParams, auth_info.dict())
|
||||
# 认证成功后,重新初始化插件
|
||||
PluginManager().init_config()
|
||||
Scheduler().init_plugin_jobs()
|
||||
Command().init_commands()
|
||||
register_plugin_api()
|
||||
return schemas.Response(success=status, message=msg)
|
||||
|
||||
|
||||
@router.get("/mapping", summary="获取站点域名到名称的映射", response_model=schemas.Response)
|
||||
def site_mapping(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
获取站点域名到名称的映射关系
|
||||
"""
|
||||
try:
|
||||
sites = SiteOper().list()
|
||||
mapping = {}
|
||||
for site in sites:
|
||||
mapping[site.domain] = site.name
|
||||
return schemas.Response(success=True, data=mapping)
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"获取映射失败:{str(e)}")
|
||||
|
||||
|
||||
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
|
||||
def read_site(
|
||||
site_id: int,
|
||||
|
||||
@@ -31,7 +31,7 @@ def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
|
||||
|
||||
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
|
||||
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
||||
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||||
"""
|
||||
二维码登录确认
|
||||
@@ -56,6 +56,16 @@ def save(name: str,
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/reset/{name}", summary="重置存储配置", response_model=schemas.Response)
|
||||
def reset(name: str,
|
||||
_: User = Depends(get_current_active_superuser)) -> Any:
|
||||
"""
|
||||
重置存储配置
|
||||
"""
|
||||
StorageChain().reset_config(name)
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.post("/list", summary="所有目录和文件", response_model=List[schemas.FileItem])
|
||||
def list_files(fileitem: schemas.FileItem,
|
||||
sort: Optional[str] = 'updated_at',
|
||||
@@ -152,47 +162,50 @@ def rename(fileitem: schemas.FileItem,
|
||||
"""
|
||||
if not new_name:
|
||||
return schemas.Response(success=False, message="新名称为空")
|
||||
|
||||
# 重命名目录内文件
|
||||
if recursive:
|
||||
transferchain = TransferChain()
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
|
||||
# 递归修改目录内文件(智能识别命名)
|
||||
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
|
||||
if sub_files:
|
||||
# 开始进度
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.BatchRename)
|
||||
total = len(sub_files)
|
||||
handled = 0
|
||||
for sub_file in sub_files:
|
||||
handled += 1
|
||||
progress.update(value=handled / total * 100,
|
||||
text=f"正在处理 {sub_file.name} ...",
|
||||
key=ProgressKey.BatchRename)
|
||||
if sub_file.type == "dir":
|
||||
continue
|
||||
if not sub_file.extension:
|
||||
continue
|
||||
if f".{sub_file.extension.lower()}" not in media_exts:
|
||||
continue
|
||||
sub_path = Path(f"{fileitem.path}{sub_file.name}")
|
||||
meta = MetaInfoPath(sub_path)
|
||||
mediainfo = transferchain.recognize_media(meta)
|
||||
if not mediainfo:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
|
||||
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
|
||||
if not new_path:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
|
||||
ret: schemas.Response = rename(fileitem=sub_file,
|
||||
new_name=Path(new_path).name,
|
||||
recursive=False)
|
||||
if not ret.success:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
# 重命名自己
|
||||
result = StorageChain().rename_file(fileitem, new_name)
|
||||
if result:
|
||||
if recursive:
|
||||
transferchain = TransferChain()
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
|
||||
# 递归修改目录内文件(智能识别命名)
|
||||
sub_files: List[schemas.FileItem] = StorageChain().list_files(fileitem)
|
||||
if sub_files:
|
||||
# 开始进度
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.BatchRename)
|
||||
total = len(sub_files)
|
||||
handled = 0
|
||||
for sub_file in sub_files:
|
||||
handled += 1
|
||||
progress.update(value=handled / total * 100,
|
||||
text=f"正在处理 {sub_file.name} ...",
|
||||
key=ProgressKey.BatchRename)
|
||||
if sub_file.type == "dir":
|
||||
continue
|
||||
if not sub_file.extension:
|
||||
continue
|
||||
if f".{sub_file.extension.lower()}" not in media_exts:
|
||||
continue
|
||||
sub_path = Path(f"{fileitem.path}{sub_file.name}")
|
||||
meta = MetaInfoPath(sub_path)
|
||||
mediainfo = transferchain.recognize_media(meta)
|
||||
if not mediainfo:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到媒体信息")
|
||||
new_path = transferchain.recommend_name(meta=meta, mediainfo=mediainfo)
|
||||
if not new_path:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 未识别到新名称")
|
||||
ret: schemas.Response = rename(fileitem=sub_file,
|
||||
new_name=Path(new_path).name,
|
||||
recursive=False)
|
||||
if not ret.success:
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
|
||||
progress.end(ProgressKey.BatchRename)
|
||||
return schemas.Response(success=True)
|
||||
return schemas.Response(success=False)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
@@ -20,23 +21,24 @@ from app.core.config import global_vars, settings
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.security import verify_apitoken, verify_resource_token, verify_token
|
||||
from app.core.event import eventmanager
|
||||
from app.db.models import User
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.helper.message import MessageHelper, MessageQueueManager
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.monitor import Monitor
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.utils.crypto import HashUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.security import SecurityUtils
|
||||
from app.utils.system import SystemUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from version import APP_VERSION
|
||||
|
||||
@@ -171,10 +173,13 @@ def cache_img(
|
||||
|
||||
|
||||
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
|
||||
def get_global_setting():
|
||||
def get_global_setting(token: str):
|
||||
"""
|
||||
查询非敏感系统设置(无需鉴权)
|
||||
查询非敏感系统设置(默认鉴权)
|
||||
"""
|
||||
if token != "moviepilot":
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
# FIXME: 新增敏感配置项时要在此处添加排除项
|
||||
info = settings.dict(
|
||||
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY", "API_TOKEN", "TMDB_API_KEY", "TVDB_API_KEY", "FANART_API_KEY",
|
||||
@@ -216,18 +221,27 @@ def set_env_setting(env: dict,
|
||||
result = settings.update_settings(env=env)
|
||||
# 统计成功和失败的结果
|
||||
success_updates = {k: v for k, v in result.items() if v[0]}
|
||||
failed_updates = {k: v for k, v in result.items() if not v[0]}
|
||||
failed_updates = {k: v for k, v in result.items() if v[0] is False}
|
||||
|
||||
if failed_updates:
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message="部分配置项更新失败",
|
||||
message=f"{', '.join([v[1] for v in failed_updates.values()])}",
|
||||
data={
|
||||
"success_updates": success_updates,
|
||||
"failed_updates": failed_updates
|
||||
}
|
||||
)
|
||||
|
||||
if success_updates:
|
||||
for key in success_updates.keys():
|
||||
# 发送配置变更事件
|
||||
eventmanager.send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=getattr(settings, key, None),
|
||||
change_type="update"
|
||||
))
|
||||
|
||||
return schemas.Response(
|
||||
success=True,
|
||||
message="所有配置项更新成功",
|
||||
@@ -281,12 +295,28 @@ def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
|
||||
"""
|
||||
if hasattr(settings, key):
|
||||
success, message = settings.update_setting(key=key, value=value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
eventmanager.send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
))
|
||||
elif success is None:
|
||||
success = True
|
||||
return schemas.Response(success=success, message=message)
|
||||
elif key in {item.value for item in SystemConfigKey}:
|
||||
if isinstance(value, list):
|
||||
value = list(filter(None, value))
|
||||
value = value if value else None
|
||||
SystemConfigOper().set(key, value)
|
||||
success = SystemConfigOper().set(key, value)
|
||||
if success:
|
||||
# 发送配置变更事件
|
||||
eventmanager.send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
|
||||
key=key,
|
||||
value=value,
|
||||
change_type="update"
|
||||
))
|
||||
return schemas.Response(success=True)
|
||||
else:
|
||||
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
|
||||
@@ -417,30 +447,55 @@ def ruletest(title: str,
|
||||
|
||||
|
||||
@router.get("/nettest", summary="测试网络连通性")
|
||||
def nettest(url: str,
|
||||
proxy: bool,
|
||||
_: schemas.TokenPayload = Depends(verify_token)):
|
||||
def nettest(
|
||||
url: str,
|
||||
proxy: bool,
|
||||
include: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
):
|
||||
"""
|
||||
测试网络连通性
|
||||
"""
|
||||
# 记录开始的毫秒数
|
||||
start_time = datetime.now()
|
||||
headers = None
|
||||
if "github" in url or "{GITHUB_PROXY}" in url:
|
||||
# 这是github的连通性测试
|
||||
url = url.replace(
|
||||
"{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "")
|
||||
)
|
||||
headers = settings.GITHUB_HEADERS
|
||||
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
|
||||
result = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
ua=settings.USER_AGENT).get_res(url)
|
||||
url = url.replace(
|
||||
"{PIP_PROXY}",
|
||||
UrlUtils.standardize_base_url(settings.PIP_PROXY or "https://pypi.org/simple/"),
|
||||
)
|
||||
result = RequestUtils(
|
||||
proxies=settings.PROXY if proxy else None,
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
ua=settings.USER_AGENT,
|
||||
).get_res(url)
|
||||
# 计时结束的毫秒数
|
||||
end_time = datetime.now()
|
||||
time = round((end_time - start_time).total_seconds() * 1000)
|
||||
# 计算相关秒数
|
||||
if result and result.status_code == 200:
|
||||
return schemas.Response(success=True, data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
elif result:
|
||||
return schemas.Response(success=False, message=f"错误码:{result.status_code}", data={
|
||||
"time": round((end_time - start_time).microseconds / 1000)
|
||||
})
|
||||
if result is None:
|
||||
return schemas.Response(success=False, message="无法连接", data={"time": time})
|
||||
elif result.status_code == 200:
|
||||
if include and not re.search(r"%s" % include, result.text, re.IGNORECASE):
|
||||
# 通常是被加速代理跳转到其它页面了
|
||||
logger.error(f"{url} 的响应内容不匹配包含规则 {include}")
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message=f"无效响应,不匹配 {include}",
|
||||
data={"time": time},
|
||||
)
|
||||
return schemas.Response(success=True, data={"time": time})
|
||||
else:
|
||||
return schemas.Response(success=False, message="网络连接失败!")
|
||||
return schemas.Response(
|
||||
success=False, message=f"错误码:{result.status_code}", data={"time": time}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
|
||||
@@ -471,27 +526,15 @@ def restart_system(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重启系统(仅管理员)
|
||||
"""
|
||||
if not SystemUtils.can_restart():
|
||||
if not SystemHelper.can_restart():
|
||||
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
|
||||
# 标识停止事件
|
||||
global_vars.stop_system()
|
||||
# 执行重启
|
||||
ret, msg = SystemUtils.restart()
|
||||
ret, msg = SystemHelper.restart()
|
||||
return schemas.Response(success=ret, message=msg)
|
||||
|
||||
|
||||
@router.get("/reload", summary="重新加载模块", response_model=schemas.Response)
|
||||
def reload_module(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重新加载模块(仅管理员)
|
||||
"""
|
||||
MessageQueueManager().init_config()
|
||||
ModuleManager().reload()
|
||||
Scheduler().init()
|
||||
Monitor().init()
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/runscheduler", summary="运行服务", response_model=schemas.Response)
|
||||
def run_scheduler(jobid: str,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
|
||||
199
app/api/endpoints/torrent.py
Normal file
199
app/api/endpoints/torrent.py
Normal file
@@ -0,0 +1,199 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.models import User
|
||||
from app.db.user_oper import get_current_active_superuser
|
||||
from app.utils.crypto import HashUtils
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
|
||||
def torrents_cache(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
获取当前种子缓存数据
|
||||
"""
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
# 获取spider和rss两种缓存
|
||||
if settings.SUBSCRIBE_MODE == "rss":
|
||||
cache_info = torrents_chain.get_torrents("rss")
|
||||
else:
|
||||
cache_info = torrents_chain.get_torrents("spider")
|
||||
|
||||
# 统计信息
|
||||
torrent_count = sum(len(torrents) for torrents in cache_info.values())
|
||||
|
||||
# 转换为前端需要的格式
|
||||
torrent_data = []
|
||||
for domain, contexts in cache_info.items():
|
||||
for context in contexts:
|
||||
torrent_hash = HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}")
|
||||
torrent_data.append({
|
||||
"hash": torrent_hash,
|
||||
"domain": domain,
|
||||
"title": context.torrent_info.title,
|
||||
"description": context.torrent_info.description,
|
||||
"size": context.torrent_info.size,
|
||||
"pubdate": context.torrent_info.pubdate,
|
||||
"site_name": context.torrent_info.site_name,
|
||||
"media_name": context.media_info.title if context.media_info else "",
|
||||
"media_year": context.media_info.year if context.media_info else "",
|
||||
"media_type": context.media_info.type if context.media_info else "",
|
||||
"season_episode": context.meta_info.season_episode if context.meta_info else "",
|
||||
"resource_term": context.meta_info.resource_term if context.meta_info else "",
|
||||
"enclosure": context.torrent_info.enclosure,
|
||||
"page_url": context.torrent_info.page_url,
|
||||
"poster_path": context.media_info.get_poster_image() if context.media_info else "",
|
||||
"backdrop_path": context.media_info.get_backdrop_image() if context.media_info else ""
|
||||
})
|
||||
|
||||
return schemas.Response(success=True, data={
|
||||
"count": torrent_count,
|
||||
"sites": len(cache_info),
|
||||
"data": torrent_data
|
||||
})
|
||||
|
||||
|
||||
@router.delete("/cache/{domain}/{torrent_hash}", summary="删除指定种子缓存",
|
||||
response_model=schemas.Response)
|
||||
def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
删除指定的种子缓存
|
||||
:param domain: 站点域名
|
||||
:param torrent_hash: 种子hash(使用title+description的md5)
|
||||
:param _: 当前用户,必须是超级用户
|
||||
"""
|
||||
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
# 获取当前缓存
|
||||
cache_data = torrents_chain.get_torrents()
|
||||
|
||||
if domain not in cache_data:
|
||||
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
|
||||
|
||||
# 查找并删除指定种子
|
||||
original_count = len(cache_data[domain])
|
||||
cache_data[domain] = [
|
||||
context for context in cache_data[domain]
|
||||
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") != torrent_hash
|
||||
]
|
||||
|
||||
if len(cache_data[domain]) == original_count:
|
||||
return schemas.Response(success=False, message="未找到指定的种子")
|
||||
|
||||
# 保存更新后的缓存
|
||||
torrents_chain.save_cache(cache_data, torrents_chain.cache_file)
|
||||
|
||||
return schemas.Response(success=True, message="种子删除成功")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"删除失败:{str(e)}")
|
||||
|
||||
|
||||
@router.delete("/cache", summary="清理种子缓存", response_model=schemas.Response)
|
||||
def clear_cache(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
清理所有种子缓存
|
||||
"""
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
torrents_chain.clear_torrents()
|
||||
return schemas.Response(success=True, message="种子缓存清理完成")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"清理失败:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/cache/refresh", summary="刷新种子缓存", response_model=schemas.Response)
|
||||
def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
刷新种子缓存
|
||||
"""
|
||||
from app.chain.torrents import TorrentsChain
|
||||
|
||||
torrents_chain = TorrentsChain()
|
||||
|
||||
try:
|
||||
result = torrents_chain.refresh()
|
||||
|
||||
# 统计刷新结果
|
||||
total_count = sum(len(torrents) for torrents in result.values())
|
||||
sites_count = len(result)
|
||||
|
||||
return schemas.Response(success=True, message=f"缓存刷新完成,共刷新 {sites_count} 个站点,{total_count} 个种子")
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"刷新失败:{str(e)}")
|
||||
|
||||
|
||||
@router.post("/cache/reidentify/{domain}/{torrent_hash}", summary="重新识别种子", response_model=schemas.Response)
|
||||
def reidentify_cache(domain: str, torrent_hash: str,
|
||||
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
_: User = Depends(get_current_active_superuser)):
|
||||
"""
|
||||
重新识别指定的种子
|
||||
:param domain: 站点域名
|
||||
:param torrent_hash: 种子hash(使用title+description的md5)
|
||||
:param tmdbid: 手动指定的TMDB ID
|
||||
:param doubanid: 手动指定的豆瓣ID
|
||||
:param _: 当前用户,必须是超级用户
|
||||
"""
|
||||
|
||||
torrents_chain = TorrentsChain()
|
||||
media_chain = MediaChain()
|
||||
|
||||
try:
|
||||
# 获取当前缓存
|
||||
cache_data = torrents_chain.get_torrents()
|
||||
|
||||
if domain not in cache_data:
|
||||
return schemas.Response(success=False, message=f"站点 {domain} 缓存不存在")
|
||||
|
||||
# 查找指定种子
|
||||
target_context = None
|
||||
for context in cache_data[domain]:
|
||||
if HashUtils.md5(f"{context.torrent_info.title}{context.torrent_info.description}") == torrent_hash:
|
||||
target_context = context
|
||||
break
|
||||
|
||||
if not target_context:
|
||||
return schemas.Response(success=False, message="未找到指定的种子")
|
||||
|
||||
# 重新识别
|
||||
meta = MetaInfo(title=target_context.torrent_info.title,
|
||||
subtitle=target_context.torrent_info.description)
|
||||
if tmdbid or doubanid:
|
||||
# 手动指定媒体信息
|
||||
mediainfo = MediaChain().recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
|
||||
else:
|
||||
# 自动重新识别
|
||||
mediainfo = media_chain.recognize_by_meta(meta)
|
||||
|
||||
if not mediainfo:
|
||||
# 创建空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
else:
|
||||
# 清理多余数据
|
||||
mediainfo.clear()
|
||||
|
||||
# 更新上下文中的媒体信息
|
||||
target_context.media_info = mediainfo
|
||||
|
||||
# 保存更新后的缓存
|
||||
torrents_chain.save_cache(cache_data, TorrentsChain().cache_file)
|
||||
|
||||
return schemas.Response(success=True, message="重新识别完成", data={
|
||||
"media_name": mediainfo.title if mediainfo else "",
|
||||
"media_year": mediainfo.year if mediainfo else "",
|
||||
"media_type": mediainfo.type.value if mediainfo and mediainfo.type else ""
|
||||
})
|
||||
except Exception as e:
|
||||
return schemas.Response(success=False, message=f"重新识别失败:{str(e)}")
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import global_vars
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.workflow import WorkFlowManager
|
||||
from app.db import get_db
|
||||
from app.db.models.workflow import Workflow
|
||||
@@ -43,6 +44,14 @@ def create_workflow(workflow: schemas.Workflow,
|
||||
return schemas.Response(success=True, message="创建工作流成功")
|
||||
|
||||
|
||||
@router.get("/plugin/actions", summary="查询插件动作", response_model=List[dict])
|
||||
def list_plugin_actions(plugin_id: str = None, _: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
获取所有动作
|
||||
"""
|
||||
return PluginManager().get_plugin_actions(plugin_id)
|
||||
|
||||
|
||||
@router.get("/actions", summary="所有动作", response_model=List[dict])
|
||||
def list_actions(_: schemas.TokenPayload = Depends(get_current_active_user)) -> Any:
|
||||
"""
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.tvdb import TvdbChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_apikey
|
||||
@@ -518,88 +519,89 @@ def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db:
|
||||
"""
|
||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||
"""
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
return [SonarrSeries()]
|
||||
tvdbid = mediainfo.tvdb_id
|
||||
if not tvdbid:
|
||||
return [SonarrSeries()]
|
||||
else:
|
||||
mediainfo = None
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
return [SonarrSeries()]
|
||||
|
||||
# 季信息
|
||||
seas: List[int] = []
|
||||
sea_num = tvdbinfo.get('season')
|
||||
if sea_num:
|
||||
seas = list(range(1, int(sea_num) + 1))
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
if not mediainfo:
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('seriesName')),
|
||||
mtype=MediaType.TV)
|
||||
|
||||
# 查询是否存在
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
# tvdbid 列表
|
||||
tvdbids: List[int] = []
|
||||
# 获取TVDBID
|
||||
if not term.startswith("tvdb:"):
|
||||
title = term.replace("+", " ")
|
||||
tvdbids = TvdbChain().get_tvdbid_by_name(title=title)
|
||||
else:
|
||||
hasfile = False
|
||||
tvdbid = int(term.replace("tvdb:", ""))
|
||||
tvdbids.append(tvdbid)
|
||||
|
||||
# 查询订阅信息
|
||||
seasons: List[dict] = []
|
||||
subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)
|
||||
if subscribes:
|
||||
# 已监控
|
||||
monitored = True
|
||||
# 已监控季
|
||||
sub_seas = [sub.season for sub in subscribes]
|
||||
for sea in seas:
|
||||
if sea in sub_seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": True,
|
||||
})
|
||||
else:
|
||||
sonarr_series_list = []
|
||||
for tvdbid in tvdbids:
|
||||
# 查询TVDB信息
|
||||
tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid)
|
||||
if not tvdbinfo:
|
||||
continue
|
||||
|
||||
# 季信息(只取默认季类型,排除特别季)
|
||||
sea_num = len([season for season in tvdbinfo.get('seasons') if
|
||||
season['type']['id'] == tvdbinfo.get('defaultSeasonType') and season['number'] > 0])
|
||||
if sea_num:
|
||||
seas = list(range(1, int(sea_num) + 1))
|
||||
|
||||
# 根据TVDB查询媒体信息
|
||||
mediainfo = MediaChain().recognize_media(meta=MetaInfo(tvdbinfo.get('name')),
|
||||
mtype=MediaType.TV)
|
||||
if not mediainfo:
|
||||
continue
|
||||
# 查询是否存在
|
||||
exists = MediaChain().media_exists(mediainfo)
|
||||
if exists:
|
||||
hasfile = True
|
||||
else:
|
||||
hasfile = False
|
||||
|
||||
# 查询订阅信息
|
||||
seasons: List[dict] = []
|
||||
subscribes = Subscribe.get_by_tmdbid(db, mediainfo.tmdb_id)
|
||||
if subscribes:
|
||||
# 已监控
|
||||
monitored = True
|
||||
# 已监控季
|
||||
sub_seas = [sub.season for sub in subscribes]
|
||||
for sea in seas:
|
||||
if sea in sub_seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": True,
|
||||
})
|
||||
else:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": False,
|
||||
})
|
||||
subid = subscribes[-1].id
|
||||
else:
|
||||
subid = None
|
||||
monitored = False
|
||||
for sea in seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": False,
|
||||
})
|
||||
subid = subscribes[-1].id
|
||||
else:
|
||||
subid = None
|
||||
monitored = False
|
||||
for sea in seas:
|
||||
seasons.append({
|
||||
"seasonNumber": sea,
|
||||
"monitored": False,
|
||||
})
|
||||
sonarr_series = SonarrSeries(
|
||||
id=subid,
|
||||
title=mediainfo.title,
|
||||
seasonCount=len(seasons),
|
||||
seasons=seasons,
|
||||
remotePoster=mediainfo.get_poster_image(),
|
||||
year=mediainfo.year,
|
||||
tmdbId=mediainfo.tmdb_id,
|
||||
tvdbId=tvdbid,
|
||||
imdbId=mediainfo.imdb_id,
|
||||
profileId=1,
|
||||
languageProfileId=1,
|
||||
monitored=monitored,
|
||||
hasFile=hasfile,
|
||||
)
|
||||
sonarr_series_list.append(sonarr_series)
|
||||
|
||||
return [SonarrSeries(
|
||||
id=subid,
|
||||
title=mediainfo.title,
|
||||
seasonCount=len(seasons),
|
||||
seasons=seasons,
|
||||
remotePoster=mediainfo.get_poster_image(),
|
||||
year=mediainfo.year,
|
||||
tmdbId=mediainfo.tmdb_id,
|
||||
tvdbId=mediainfo.tvdb_id,
|
||||
imdbId=mediainfo.imdb_id,
|
||||
profileId=1,
|
||||
languageProfileId=1,
|
||||
qualityProfileId=1,
|
||||
isAvailable=True,
|
||||
monitored=monitored,
|
||||
hasFile=hasfile
|
||||
)]
|
||||
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
|
||||
|
||||
|
||||
@arr_router.get("/series/{tid}", summary="剧集详情")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import copy
|
||||
import gc
|
||||
import pickle
|
||||
import traceback
|
||||
from abc import ABCMeta
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Optional, Any, Tuple, List, Set, Union, Dict
|
||||
|
||||
@@ -14,9 +14,10 @@ from app.core.context import Context, MediaInfo, TorrentInfo
|
||||
from app.core.event import EventManager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.module import ModuleManager
|
||||
from app.core.plugin import PluginManager
|
||||
from app.db.message_oper import MessageOper
|
||||
from app.db.user_oper import UserOper
|
||||
from app.helper.message import MessageHelper, MessageQueueManager
|
||||
from app.helper.message import MessageHelper, MessageQueueManager, MessageTemplateHelper
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, ExistMediaInfo, DownloadingTorrent, CommingMessage, Notification, \
|
||||
@@ -41,7 +42,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
self.messagequeue = MessageQueueManager(
|
||||
send_callback=self.run_module
|
||||
)
|
||||
self.useroper = UserOper()
|
||||
self.pluginmanager = PluginManager()
|
||||
|
||||
@staticmethod
|
||||
def load_cache(filename: str) -> Any:
|
||||
@@ -64,13 +65,9 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
try:
|
||||
with open(settings.TEMP_PATH / filename, 'wb') as f:
|
||||
pickle.dump(cache, f) # noqa
|
||||
pickle.dump(cache, f) # noqa
|
||||
except Exception as err:
|
||||
logger.error(f"保存缓存 {filename} 出错:{str(err)}")
|
||||
finally:
|
||||
# 主动资源回收
|
||||
del cache
|
||||
gc.collect()
|
||||
|
||||
@staticmethod
|
||||
def remove_cache(filename: str) -> None:
|
||||
@@ -97,7 +94,50 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return ret is None
|
||||
|
||||
result = None
|
||||
logger.debug(f"请求模块执行:{method} ...")
|
||||
plugin_modules = self.pluginmanager.get_plugin_modules()
|
||||
# 插件模块
|
||||
for plugin, module_dict in plugin_modules.items():
|
||||
plugin_id, plugin_name = plugin
|
||||
if method in module_dict:
|
||||
func = module_dict[method]
|
||||
if func:
|
||||
try:
|
||||
logger.info(f"请求插件 {plugin_name} 执行:{method} ...")
|
||||
if is_result_empty(result):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
result = func(*args, **kwargs)
|
||||
elif isinstance(result, list):
|
||||
# 返回为列表,有多个模块运行结果时进行合并
|
||||
temp = func(*args, **kwargs)
|
||||
if isinstance(temp, list):
|
||||
result.extend(temp)
|
||||
else:
|
||||
break
|
||||
except Exception as err:
|
||||
if kwargs.get("raise_exception"):
|
||||
raise
|
||||
logger.error(
|
||||
f"运行插件 {plugin_id} 模块 {method} 出错:{str(err)}\n{traceback.format_exc()}")
|
||||
self.messagehelper.put(title=f"{plugin_name} 发生了错误",
|
||||
message=str(err),
|
||||
role="plugin")
|
||||
self.eventmanager.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
"type": "plugin",
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_name": plugin_name,
|
||||
"plugin_method": method,
|
||||
"error": str(err),
|
||||
"traceback": traceback.format_exc()
|
||||
}
|
||||
)
|
||||
if not is_result_empty(result) and not isinstance(result, list):
|
||||
# 插件模块返回结果不为空且不是列表,直接返回
|
||||
return result
|
||||
|
||||
# 系统模块
|
||||
logger.debug(f"请求系统模块执行:{method} ...")
|
||||
modules = self.modulemanager.get_running_modules(method)
|
||||
# 按优先级排序
|
||||
modules = sorted(modules, key=lambda x: x.get_priority())
|
||||
@@ -114,10 +154,10 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 返回None,第一次执行或者需继续执行下一模块
|
||||
result = func(*args, **kwargs)
|
||||
elif ObjectUtils.check_signature(func, result):
|
||||
# 返回结果与方法签名一致,将结果传入(不能多个模块同时运行的需要通过开关控制)
|
||||
# 返回结果与方法签名一致,将结果传入
|
||||
result = func(result)
|
||||
elif isinstance(result, list):
|
||||
# 返回为列表,有多个模块运行结果时进行合并(不能多个模块同时运行的需要通过开关控制)
|
||||
# 返回为列表,有多个模块运行结果时进行合并
|
||||
temp = func(*args, **kwargs)
|
||||
if isinstance(temp, list):
|
||||
result.extend(temp)
|
||||
@@ -328,7 +368,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
return self.run_module("search_torrents", site=site, keywords=keywords,
|
||||
mtype=mtype, page=page)
|
||||
|
||||
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
|
||||
def refresh_torrents(self, site: dict, keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None, page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点最新一页的种子,多个站点需要多线程处理
|
||||
@@ -401,7 +441,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
target_storage: Optional[str] = None, target_path: Path = None,
|
||||
transfer_type: Optional[str] = None, scrape: bool = None,
|
||||
library_type_folder: bool = None, library_category_folder: bool = None,
|
||||
episodes_info: List[TmdbEpisode] = None) -> Optional[TransferInfo]:
|
||||
episodes_info: List[TmdbEpisode] = None,
|
||||
source_oper: Callable = None, target_oper: Callable = None) -> Optional[TransferInfo]:
|
||||
"""
|
||||
文件转移
|
||||
:param fileitem: 文件信息
|
||||
@@ -415,6 +456,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param library_type_folder: 是否按类型创建目录
|
||||
:param library_category_folder: 是否按类别创建目录
|
||||
:param episodes_info: 当前季的全部集信息
|
||||
:param source_oper: 源存储操作类
|
||||
:param target_oper: 目标存储操作类
|
||||
:return: {path, target_path, message}
|
||||
"""
|
||||
return self.run_module("transfer",
|
||||
@@ -424,7 +467,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
transfer_type=transfer_type, scrape=scrape,
|
||||
library_type_folder=library_type_folder,
|
||||
library_category_folder=library_category_folder,
|
||||
episodes_info=episodes_info)
|
||||
episodes_info=episodes_info,
|
||||
source_oper=source_oper, target_oper=target_oper)
|
||||
|
||||
def transfer_completed(self, hashs: str, downloader: Optional[str] = None) -> None:
|
||||
"""
|
||||
@@ -492,13 +536,27 @@ class ChainBase(metaclass=ABCMeta):
|
||||
"""
|
||||
return self.run_module("media_files", mediainfo=mediainfo)
|
||||
|
||||
def post_message(self, message: Notification) -> None:
|
||||
def post_message(self,
|
||||
message: Optional[Notification] = None,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
torrentinfo: Optional[TorrentInfo] = None,
|
||||
transferinfo: Optional[TransferInfo] = None,
|
||||
**kwargs) -> None:
|
||||
"""
|
||||
发送消息
|
||||
:param message: 消息体
|
||||
:param message: Notification实例
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrentinfo: 种子信息
|
||||
:param transferinfo: 文件整理信息
|
||||
:param kwargs: 其他参数(覆盖业务对象属性值)
|
||||
:return: 成功或失败
|
||||
"""
|
||||
# 保存原消息
|
||||
# 渲染消息
|
||||
message = MessageTemplateHelper.render(message=message, meta=meta, mediainfo=mediainfo,
|
||||
torrentinfo=torrentinfo, transferinfo=transferinfo, **kwargs)
|
||||
# 保存消息
|
||||
self.messagehelper.put(message, role="user", title=message.title)
|
||||
self.messageoper.add(**message.dict())
|
||||
# 发送消息按设置隔离
|
||||
@@ -511,26 +569,27 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 是否已发送管理员标志
|
||||
admin_sended = False
|
||||
send_orignal = False
|
||||
useroper = UserOper()
|
||||
for action in actions:
|
||||
send_message = copy.deepcopy(message)
|
||||
if action == "admin" and not admin_sended:
|
||||
# 仅发送管理员
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
send_message.targets = useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
elif action == "user" and send_message.username:
|
||||
# 发送对应用户
|
||||
logger.info(f"{send_message.mtype} 的消息已设置发送给用户 {send_message.username}")
|
||||
# 读取用户消息IDS
|
||||
send_message.targets = self.useroper.get_settings(send_message.username)
|
||||
send_message.targets = useroper.get_settings(send_message.username)
|
||||
if send_message.targets is None:
|
||||
# 没有找到用户
|
||||
if not admin_sended:
|
||||
# 回滚发送管理员
|
||||
logger.info(f"用户 {send_message.username} 不存在,消息将发送给管理员")
|
||||
# 读取管理员消息IDS
|
||||
send_message.targets = self.useroper.get_settings(settings.SUPERUSER)
|
||||
send_message.targets = useroper.get_settings(settings.SUPERUSER)
|
||||
admin_sended = True
|
||||
else:
|
||||
# 管理员发过了,此消息不发了
|
||||
@@ -553,7 +612,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
# 发送消息事件
|
||||
self.eventmanager.send_event(etype=EventType.NoticeMessage, data={**message.dict(), "type": message.mtype})
|
||||
# 按原消息发送
|
||||
self.messagequeue.send_message("post_message", message=message)
|
||||
self.messagequeue.send_message("post_message", message=message,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> None:
|
||||
"""
|
||||
@@ -565,7 +625,8 @@ class ChainBase(metaclass=ABCMeta):
|
||||
note_list = [media.to_dict() for media in medias]
|
||||
self.messagehelper.put(message, role="user", note=note_list, title=message.title)
|
||||
self.messageoper.add(**message.dict(), note=note_list)
|
||||
return self.messagequeue.send_message("post_medias_message", message=message, medias=medias)
|
||||
return self.messagequeue.send_message("post_medias_message", message=message, medias=medias,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> None:
|
||||
"""
|
||||
@@ -577,9 +638,10 @@ class ChainBase(metaclass=ABCMeta):
|
||||
note_list = [torrent.torrent_info.to_dict() for torrent in torrents]
|
||||
self.messagehelper.put(message, role="user", note=note_list, title=message.title)
|
||||
self.messageoper.add(**message.dict(), note=note_list)
|
||||
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents)
|
||||
return self.messagequeue.send_message("post_torrents_message", message=message, torrents=torrents,
|
||||
immediately=True if message.userid else False)
|
||||
|
||||
def metadata_img(self, mediainfo: MediaInfo,
|
||||
def metadata_img(self, mediainfo: MediaInfo,
|
||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[dict]:
|
||||
"""
|
||||
获取图片名称和url
|
||||
|
||||
@@ -3,12 +3,11 @@ from typing import Optional, List
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class BangumiChain(ChainBase, metaclass=Singleton):
|
||||
class BangumiChain(ChainBase):
|
||||
"""
|
||||
Bangumi处理链,单例运行
|
||||
Bangumi处理链
|
||||
"""
|
||||
|
||||
def calendar(self) -> Optional[List[MediaInfo]]:
|
||||
|
||||
@@ -2,10 +2,9 @@ from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DashboardChain(ChainBase, metaclass=Singleton):
|
||||
class DashboardChain(ChainBase):
|
||||
"""
|
||||
各类仪表板统计处理链
|
||||
"""
|
||||
|
||||
@@ -4,12 +4,11 @@ from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class DoubanChain(ChainBase, metaclass=Singleton):
|
||||
class DoubanChain(ChainBase):
|
||||
"""
|
||||
豆瓣处理链,单例运行
|
||||
豆瓣处理链
|
||||
"""
|
||||
|
||||
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
|
||||
|
||||
@@ -16,11 +16,12 @@ from app.core.metainfo import MetaInfo
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, ResourceDownloadEventData
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ChainEventType
|
||||
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification, ResourceSelectionEventData, \
|
||||
ResourceDownloadEventData
|
||||
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType, ContentType, \
|
||||
ChainEventType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -30,71 +31,6 @@ class DownloadChain(ChainBase):
|
||||
下载处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.torrent = TorrentHelper()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.mediaserver = MediaServerOper()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
|
||||
def post_download_message(self, meta: MetaBase, mediainfo: MediaInfo, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None, username: Optional[str] = None,
|
||||
download_episodes: Optional[str] = None):
|
||||
"""
|
||||
发送添加下载的消息,根据消息场景开关决定发给谁
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrent: 种子信息
|
||||
:param channel: 通知渠道
|
||||
:param username: 通知显示的下载用户信息
|
||||
:param download_episodes: 下载的集数
|
||||
"""
|
||||
# 拼装消息内容
|
||||
msg_text = ""
|
||||
if username:
|
||||
msg_text = f"用户:{username}"
|
||||
if torrent.site_name:
|
||||
msg_text = f"{msg_text}\n站点:{torrent.site_name}"
|
||||
if meta.resource_term:
|
||||
msg_text = f"{msg_text}\n质量:{meta.resource_term}"
|
||||
if torrent.size:
|
||||
if str(torrent.size).replace(".", "").isdigit():
|
||||
size = StringUtils.str_filesize(torrent.size)
|
||||
else:
|
||||
size = torrent.size
|
||||
msg_text = f"{msg_text}\n大小:{size}"
|
||||
if torrent.title:
|
||||
msg_text = f"{msg_text}\n种子:{torrent.title}"
|
||||
if torrent.pubdate:
|
||||
msg_text = f"{msg_text}\n发布时间:{torrent.pubdate}"
|
||||
if torrent.freedate:
|
||||
msg_text = f"{msg_text}\n免费时间:{StringUtils.diff_time_str(torrent.freedate)}"
|
||||
if torrent.seeders:
|
||||
msg_text = f"{msg_text}\n做种数:{torrent.seeders}"
|
||||
if torrent.uploadvolumefactor and torrent.downloadvolumefactor:
|
||||
msg_text = f"{msg_text}\n促销:{torrent.volume_factor}"
|
||||
if torrent.hit_and_run:
|
||||
msg_text = f"{msg_text}\nHit&Run:是"
|
||||
if torrent.labels:
|
||||
msg_text = f"{msg_text}\n标签:{' '.join(torrent.labels)}"
|
||||
if torrent.description:
|
||||
html_re = re.compile(r'<[^>]+>', re.S)
|
||||
description = html_re.sub('', torrent.description)
|
||||
torrent.description = re.sub(r'<[^>]+>', '', description)
|
||||
msg_text = f"{msg_text}\n描述:{torrent.description}"
|
||||
|
||||
# 下载成功按规则发送消息
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
mtype=NotificationType.Download,
|
||||
title=f"{mediainfo.title_year} "
|
||||
f"{'%s %s' % (meta.season, download_episodes) if download_episodes else meta.season_episode} 开始下载",
|
||||
text=msg_text,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=settings.MP_DOMAIN('/#/downloading'),
|
||||
username=username))
|
||||
|
||||
def download_torrent(self, torrent: TorrentInfo,
|
||||
channel: MessageChannel = None,
|
||||
source: Optional[str] = None,
|
||||
@@ -177,7 +113,7 @@ class DownloadChain(ChainBase):
|
||||
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}!")
|
||||
return None, "", []
|
||||
# 下载种子文件
|
||||
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
|
||||
torrent_file, content, download_folder, files, error_msg = TorrentHelper().download_torrent(
|
||||
url=torrent_url,
|
||||
cookie=site_cookie,
|
||||
ua=torrent.site_ua or settings.USER_AGENT,
|
||||
@@ -275,7 +211,7 @@ class DownloadChain(ChainBase):
|
||||
else:
|
||||
content = torrent_file
|
||||
# 获取种子文件的文件夹名和文件清单
|
||||
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
|
||||
_folder_name, _file_list = TorrentHelper().get_torrent_info(torrent_file)
|
||||
|
||||
# 下载目录
|
||||
if save_path:
|
||||
@@ -283,7 +219,7 @@ class DownloadChain(ChainBase):
|
||||
download_dir = Path(save_path)
|
||||
else:
|
||||
# 根据媒体信息查询下载目录配置
|
||||
dir_info = self.directoryhelper.get_dir(_media, storage="local", include_unsorted=True)
|
||||
dir_info = DirectoryHelper().get_dir(_media, storage="local", include_unsorted=True)
|
||||
# 拼装子目录
|
||||
if dir_info:
|
||||
# 一级目录
|
||||
@@ -333,7 +269,8 @@ class DownloadChain(ChainBase):
|
||||
_save_path = download_dir if _layout == "NoSubfolder" or not _folder_name else download_path
|
||||
|
||||
# 登记下载记录
|
||||
self.downloadhis.add(
|
||||
downloadhis = DownloadHistoryOper()
|
||||
downloadhis.add(
|
||||
path=str(download_path),
|
||||
type=_media.type.value,
|
||||
title=_media.title,
|
||||
@@ -381,11 +318,26 @@ class DownloadChain(ChainBase):
|
||||
"torrentname": _meta.org_string,
|
||||
})
|
||||
if files_to_add:
|
||||
self.downloadhis.add_files(files_to_add)
|
||||
downloadhis.add_files(files_to_add)
|
||||
|
||||
# 下载成功发送消息
|
||||
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
|
||||
username=username, download_episodes=download_episodes)
|
||||
self.post_message(
|
||||
Notification(
|
||||
channel=channel,
|
||||
source=source if channel else None,
|
||||
mtype=NotificationType.Download,
|
||||
ctype=ContentType.DownloadAdded,
|
||||
image=_media.get_message_image(),
|
||||
link=settings.MP_DOMAIN('/#/downloading'),
|
||||
userid=userid,
|
||||
username=username
|
||||
),
|
||||
meta=_meta,
|
||||
mediainfo=_media,
|
||||
torrentinfo=_torrent,
|
||||
download_episodes=download_episodes,
|
||||
username=username,
|
||||
)
|
||||
# 下载成功后处理
|
||||
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
|
||||
# 广播事件
|
||||
@@ -582,7 +534,7 @@ class DownloadChain(ChainBase):
|
||||
if isinstance(content, str):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
|
||||
continue
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
|
||||
if not torrent_episodes:
|
||||
continue
|
||||
@@ -756,7 +708,7 @@ class DownloadChain(ChainBase):
|
||||
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
|
||||
continue
|
||||
# 种子全部集
|
||||
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
|
||||
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
|
||||
logger.info(f"{torrent.site_name} - {meta.org_string} 解析种子文件集数:{torrent_episodes}")
|
||||
# 选中的集
|
||||
selected_episodes = set(torrent_episodes).intersection(set(need_episodes))
|
||||
@@ -845,11 +797,12 @@ class DownloadChain(ChainBase):
|
||||
if not totals:
|
||||
totals = {}
|
||||
|
||||
mediaserver = MediaServerOper()
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id)
|
||||
exists_movies: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||
if exists_movies:
|
||||
logger.info(f"媒体库中已存在电影:{mediainfo.title_year}")
|
||||
@@ -869,10 +822,10 @@ class DownloadChain(ChainBase):
|
||||
logger.error(f"媒体信息中没有季集信息:{mediainfo.title_year}")
|
||||
return False, {}
|
||||
# 电视剧
|
||||
itemid = self.mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season)
|
||||
itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,
|
||||
title=mediainfo.title,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=mediainfo.season)
|
||||
# 媒体库已存在的剧集
|
||||
exists_tvs: Optional[ExistMediaInfo] = self.media_exists(mediainfo=mediainfo, itemid=itemid)
|
||||
if not exists_tvs:
|
||||
@@ -971,7 +924,7 @@ class DownloadChain(ChainBase):
|
||||
return []
|
||||
ret_torrents = []
|
||||
for torrent in torrents:
|
||||
history = self.downloadhis.get_by_hash(torrent.hash)
|
||||
history = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if history:
|
||||
# 媒体信息
|
||||
torrent.media = {
|
||||
@@ -988,21 +941,21 @@ class DownloadChain(ChainBase):
|
||||
ret_torrents.append(torrent)
|
||||
return ret_torrents
|
||||
|
||||
def set_downloading(self, hash_str, oper: str) -> bool:
|
||||
def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
控制下载任务 start/stop
|
||||
"""
|
||||
if oper == "start":
|
||||
return self.start_torrents(hashs=[hash_str])
|
||||
return self.start_torrents(hashs=[hash_str], downloader=name)
|
||||
elif oper == "stop":
|
||||
return self.stop_torrents(hashs=[hash_str])
|
||||
return self.stop_torrents(hashs=[hash_str], downloader=name)
|
||||
return False
|
||||
|
||||
def remove_downloading(self, hash_str: str) -> bool:
|
||||
def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool:
|
||||
"""
|
||||
删除下载任务
|
||||
"""
|
||||
return self.remove_torrents(hashs=[hash_str])
|
||||
return self.remove_torrents(hashs=[hash_str], downloader=name)
|
||||
|
||||
@eventmanager.register(EventType.DownloadFileDeleted)
|
||||
def download_file_deleted(self, event: Event):
|
||||
|
||||
@@ -10,11 +10,11 @@ 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.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas import FileItem
|
||||
from app.schemas.types import EventType, MediaType, ChainEventType
|
||||
from app.schemas.types import EventType, MediaType, ChainEventType, SystemConfigKey
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
recognize_lock = Lock()
|
||||
@@ -22,14 +22,53 @@ scraping_lock = Lock()
|
||||
scraping_files = []
|
||||
|
||||
|
||||
class MediaChain(ChainBase, metaclass=Singleton):
|
||||
class MediaChain(ChainBase):
|
||||
"""
|
||||
媒体信息处理链,单例运行
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.storagechain = StorageChain()
|
||||
@staticmethod
|
||||
def _get_scraping_switchs() -> dict:
|
||||
"""
|
||||
获取刮削开关配置
|
||||
"""
|
||||
switchs = SystemConfigOper().get(SystemConfigKey.ScrapingSwitchs) or {}
|
||||
# 默认配置
|
||||
default_switchs = {
|
||||
'movie_nfo': True, # 电影NFO
|
||||
'movie_poster': True, # 电影海报
|
||||
'movie_backdrop': True, # 电影背景图
|
||||
'movie_logo': True, # 电影Logo
|
||||
'movie_disc': True, # 电影光盘图
|
||||
'movie_banner': True, # 电影横幅图
|
||||
'movie_thumb': True, # 电影缩略图
|
||||
'tv_nfo': True, # 电视剧NFO
|
||||
'tv_poster': True, # 电视剧海报
|
||||
'tv_backdrop': True, # 电视剧背景图
|
||||
'tv_banner': True, # 电视剧横幅图
|
||||
'tv_logo': True, # 电视剧Logo
|
||||
'tv_thumb': True, # 电视剧缩略图
|
||||
'season_nfo': True, # 季NFO
|
||||
'season_poster': True, # 季海报
|
||||
'season_banner': True, # 季横幅图
|
||||
'season_thumb': True, # 季缩略图
|
||||
'episode_nfo': True, # 集NFO
|
||||
'episode_thumb': True # 集缩略图
|
||||
}
|
||||
# 合并用户配置和默认配置
|
||||
for key, default_value in default_switchs.items():
|
||||
if key not in switchs:
|
||||
switchs[key] = default_value
|
||||
return switchs
|
||||
|
||||
@staticmethod
|
||||
def set_scraping_switchs(switchs: dict) -> bool:
|
||||
"""
|
||||
设置刮削开关配置
|
||||
:param switchs: 开关配置字典
|
||||
:return: 是否设置成功
|
||||
"""
|
||||
return SystemConfigOper().set(SystemConfigKey.ScrapingSwitchs, switchs)
|
||||
|
||||
def metadata_nfo(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
season: Optional[int] = None, episode: Optional[int] = None) -> Optional[str]:
|
||||
@@ -337,6 +376,8 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
:param overwrite: 是否覆盖已有文件
|
||||
"""
|
||||
|
||||
storagechain = StorageChain()
|
||||
|
||||
def is_bluray_folder(_fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
判断是否为原盘目录
|
||||
@@ -346,7 +387,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 蓝光原盘目录必备的文件或文件夹
|
||||
required_files = ['BDMV', 'CERTIFICATE']
|
||||
# 检查目录下是否存在所需文件或文件夹
|
||||
for item in self.storagechain.list_files(_fileitem):
|
||||
for item in storagechain.list_files(_fileitem):
|
||||
if item.name in required_files:
|
||||
return True
|
||||
return False
|
||||
@@ -355,7 +396,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
列出下级文件
|
||||
"""
|
||||
return self.storagechain.list_files(fileitem=_fileitem)
|
||||
return storagechain.list_files(fileitem=_fileitem)
|
||||
|
||||
def __save_file(_fileitem: schemas.FileItem, _path: Path, _content: Union[bytes, str]):
|
||||
"""
|
||||
@@ -371,7 +412,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
tmp_file.write_bytes(_content)
|
||||
# 获取文件的父目录
|
||||
try:
|
||||
item = self.storagechain.upload_file(fileitem=_fileitem, path=tmp_file, new_name=_path.name)
|
||||
item = storagechain.upload_file(fileitem=_fileitem, path=tmp_file, new_name=_path.name)
|
||||
if item:
|
||||
logger.info(f"已保存文件:{item.path}")
|
||||
else:
|
||||
@@ -407,37 +448,47 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if not mediainfo:
|
||||
logger.warn(f"{filepath} 无法识别文件媒体信息!")
|
||||
return
|
||||
|
||||
# 获取刮削开关配置
|
||||
scraping_switchs = self._get_scraping_switchs()
|
||||
logger.info(f"开始刮削:{filepath} ...")
|
||||
if mediainfo.type == MediaType.MOVIE:
|
||||
# 电影
|
||||
if fileitem.type == "file":
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
# 电影目录
|
||||
if is_bluray_folder(fileitem):
|
||||
# 原盘目录
|
||||
nfo_path = filepath / (filepath.name + ".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 生成原盘nfo
|
||||
# 检查电影NFO开关
|
||||
if scraping_switchs.get('movie_nfo', True):
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 电影文件
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
# 保存或上传nfo文件到上级目录
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
logger.info("电影NFO刮削已关闭,跳过")
|
||||
else:
|
||||
# 电影目录
|
||||
if is_bluray_folder(fileitem):
|
||||
# 原盘目录
|
||||
if scraping_switchs.get('movie_nfo', True):
|
||||
nfo_path = filepath / (filepath.name + ".nfo")
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 生成原盘nfo
|
||||
movie_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if movie_nfo:
|
||||
# 保存或上传nfo文件到当前目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=movie_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
logger.info("电影NFO刮削已关闭,跳过")
|
||||
else:
|
||||
# 处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -449,23 +500,38 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 生成目录内图片文件
|
||||
if init_folder:
|
||||
# 图片
|
||||
for attr_name, attr_value in vars(mediainfo).items():
|
||||
if attr_value \
|
||||
and attr_name.endswith("_path") \
|
||||
and attr_value \
|
||||
and isinstance(attr_value, str) \
|
||||
and attr_value.startswith("http"):
|
||||
image_name = attr_name.replace("_path", "") + Path(attr_value).suffix
|
||||
image_path = filepath / image_name
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(_url=attr_value)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
# 根据图片类型检查开关
|
||||
if 'poster' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_poster', True)
|
||||
elif 'backdrop' in image_name.lower() or 'fanart' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_backdrop', True)
|
||||
elif 'logo' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_logo', True)
|
||||
elif 'disc' in image_name.lower() or 'cdart' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_disc', True)
|
||||
elif 'banner' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_banner', True)
|
||||
elif 'thumb' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('movie_thumb', True)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
should_scrape = True # 未知类型默认刮削
|
||||
|
||||
if should_scrape:
|
||||
image_path = filepath.with_name(image_name)
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 写入图片到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
logger.info(f"电影图片刮削已关闭,跳过:{image_name}")
|
||||
else:
|
||||
# 电视剧
|
||||
if fileitem.type == "file":
|
||||
@@ -479,38 +545,45 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if not file_mediainfo:
|
||||
logger.warn(f"{filepath.name} 无法识别文件媒体信息!")
|
||||
return
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season,
|
||||
episode=file_meta.begin_episode)
|
||||
if episode_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
else:
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# 获取集的图片
|
||||
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if image_dict:
|
||||
for episode, image_url in image_dict.items():
|
||||
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
# 检查集NFO开关
|
||||
if scraping_switchs.get('episode_nfo', True):
|
||||
# 是否已存在
|
||||
nfo_path = filepath.with_suffix(".nfo")
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 获取集的nfo文件
|
||||
episode_nfo = self.metadata_nfo(meta=file_meta, mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season,
|
||||
episode=file_meta.begin_episode)
|
||||
if episode_nfo:
|
||||
# 保存或上传nfo文件到上级目录
|
||||
if not parent:
|
||||
parent = storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=nfo_path, _content=episode_nfo)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
logger.warn(f"{filepath.name} nfo文件生成失败!")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
logger.info("集NFO刮削已关闭,跳过")
|
||||
# 获取集的图片
|
||||
if scraping_switchs.get('episode_thumb', True):
|
||||
image_dict = self.metadata_img(mediainfo=file_mediainfo,
|
||||
season=file_meta.begin_season, episode=file_meta.begin_episode)
|
||||
if image_dict:
|
||||
for episode, image_url in image_dict.items():
|
||||
image_path = filepath.with_suffix(Path(image_url).suffix)
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
logger.info("集缩略图刮削已关闭,跳过")
|
||||
else:
|
||||
# 当前为目录,处理目录内的文件
|
||||
files = __list_files(_fileitem=fileitem)
|
||||
@@ -528,71 +601,95 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
if filepath.name in settings.RENAME_FORMAT_S0_NAMES:
|
||||
season_meta.begin_season = 0
|
||||
if season_meta.begin_season is not None:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "season.nfo"
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo,
|
||||
season=season_meta.begin_season)
|
||||
if season_nfo:
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
# TMDB季poster图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
image_path = filepath.with_name(image_name)
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到剧集目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
# 检查季NFO开关
|
||||
if scraping_switchs.get('season_nfo', True):
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "season.nfo"
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有季号,生成季nfo
|
||||
season_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo,
|
||||
season=season_meta.begin_season)
|
||||
if season_nfo:
|
||||
# 写入nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=season_nfo)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
logger.warn(f"无法生成电视剧季nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
logger.info("季NFO刮削已关闭,跳过")
|
||||
# TMDB季poster图片
|
||||
if scraping_switchs.get('season_poster', True):
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo, season=season_meta.begin_season)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
image_path = filepath.with_name(image_name)
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到剧集目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
logger.info("季海报刮削已关闭,跳过")
|
||||
# 额外fanart季图片:poster thumb banner
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
for image_name, image_url in image_dict.items():
|
||||
if image_name.startswith("season"):
|
||||
image_path = filepath.with_name(image_name)
|
||||
# 只下载当前刮削季的图片
|
||||
image_season = "00" if "specials" in image_name else image_name[6:8]
|
||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
continue
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = self.storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
# 根据季图片类型检查开关
|
||||
if 'poster' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('season_poster', True)
|
||||
elif 'banner' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('season_banner', True)
|
||||
elif 'thumb' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('season_thumb', True)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
should_scrape = True # 未知类型默认刮削
|
||||
|
||||
if should_scrape:
|
||||
image_path = filepath.with_name(image_name)
|
||||
# 只下载当前刮削季的图片
|
||||
image_season = "00" if "specials" in image_name else image_name[6:8]
|
||||
if image_season != str(season_meta.begin_season).rjust(2, '0'):
|
||||
logger.info(f"当前刮削季为:{season_meta.begin_season},跳过文件:{image_path}")
|
||||
continue
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
if not parent:
|
||||
parent = storagechain.get_parent_item(fileitem)
|
||||
__save_file(_fileitem=parent, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
logger.info(f"季图片刮削已关闭,跳过:{image_name}")
|
||||
# 判断当前目录是不是剧集根目录
|
||||
if not season_meta.season:
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "tvshow.nfo"
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if tv_nfo:
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
# 检查电视剧NFO开关
|
||||
if scraping_switchs.get('tv_nfo', True):
|
||||
# 是否已存在
|
||||
nfo_path = filepath / "tvshow.nfo"
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage, path=nfo_path):
|
||||
# 当前目录有名称,生成tvshow nfo 和 tv图片
|
||||
tv_nfo = self.metadata_nfo(meta=meta, mediainfo=mediainfo)
|
||||
if tv_nfo:
|
||||
# 写入tvshow nfo到根目录
|
||||
__save_file(_fileitem=fileitem, _path=nfo_path, _content=tv_nfo)
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
else:
|
||||
logger.warn(f"无法生成电视剧nfo文件:{meta.name}")
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
else:
|
||||
logger.info(f"已存在nfo文件:{nfo_path}")
|
||||
logger.info("电视剧NFO刮削已关闭,跳过")
|
||||
# 生成目录图片
|
||||
image_dict = self.metadata_img(mediainfo=mediainfo)
|
||||
if image_dict:
|
||||
@@ -600,14 +697,31 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
# 不下载季图片
|
||||
if image_name.startswith("season"):
|
||||
continue
|
||||
image_path = filepath / image_name
|
||||
if overwrite or not self.storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
# 根据电视剧图片类型检查开关
|
||||
if 'poster' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_poster', True)
|
||||
elif 'backdrop' in image_name.lower() or 'fanart' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_backdrop', True)
|
||||
elif 'banner' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_banner', True)
|
||||
elif 'logo' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_logo', True)
|
||||
elif 'thumb' in image_name.lower():
|
||||
should_scrape = scraping_switchs.get('tv_thumb', True)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
should_scrape = True # 未知类型默认刮削
|
||||
|
||||
if should_scrape:
|
||||
image_path = filepath / image_name
|
||||
if overwrite or not storagechain.get_file_item(storage=fileitem.storage,
|
||||
path=image_path):
|
||||
# 下载图片
|
||||
content = __download_image(image_url)
|
||||
# 保存图片文件到当前目录
|
||||
if content:
|
||||
__save_file(_fileitem=fileitem, _path=image_path, _content=content)
|
||||
else:
|
||||
logger.info(f"已存在图片文件:{image_path}")
|
||||
else:
|
||||
logger.info(f"电视剧图片刮削已关闭,跳过:{image_name}")
|
||||
logger.info(f"{filepath.name} 刮削完成")
|
||||
|
||||
@@ -2,7 +2,6 @@ import threading
|
||||
from typing import List, Union, Optional, Generator, Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.cache import cached
|
||||
from app.core.config import global_vars
|
||||
from app.db.mediaserver_oper import MediaServerOper
|
||||
from app.helper.service import ServiceConfigHelper
|
||||
@@ -17,10 +16,6 @@ class MediaServerChain(ChainBase):
|
||||
媒体服务器处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.dboper = MediaServerOper()
|
||||
|
||||
def librarys(self, server: str, username: Optional[str] = None,
|
||||
hidden: bool = False) -> List[MediaServerLibrary]:
|
||||
"""
|
||||
@@ -96,7 +91,6 @@ class MediaServerChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_latest_wallpapers(self, server: Optional[str] = None, count: Optional[int] = 10,
|
||||
remote: bool = True, username: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
@@ -131,7 +125,8 @@ class MediaServerChain(ChainBase):
|
||||
# 汇总统计
|
||||
total_count = 0
|
||||
# 清空登记薄
|
||||
self.dboper.empty()
|
||||
dboper = MediaServerOper()
|
||||
dboper.empty()
|
||||
# 遍历媒体服务器
|
||||
for mediaserver in mediaservers:
|
||||
if not mediaserver:
|
||||
@@ -175,7 +170,7 @@ class MediaServerChain(ChainBase):
|
||||
item_dict = item.dict()
|
||||
item_dict["seasoninfo"] = seasoninfo
|
||||
item_dict["item_type"] = item_type
|
||||
self.dboper.add(**item_dict)
|
||||
dboper.add(**item_dict)
|
||||
logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
|
||||
# 总数累加
|
||||
total_count += library_count
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import copy
|
||||
import gc
|
||||
import re
|
||||
from typing import Any, Optional, Dict, Union
|
||||
from typing import Any, Optional, Dict, Union, List
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
@@ -9,13 +9,12 @@ from app.chain.search import SearchChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, Context
|
||||
from app.core.event import EventManager
|
||||
from app.core.meta import MetaBase
|
||||
from app.db.message_oper import MessageOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.db.user_oper import UserOper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotExistMediaInfo, CommingMessage
|
||||
from app.schemas.message import ChannelCapabilityManager
|
||||
from app.schemas.types import EventType, MessageChannel, MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -36,19 +35,8 @@ class MessageChain(ChainBase):
|
||||
# 每页数据量
|
||||
_page_size: int = 8
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.subscribechain = SubscribeChain()
|
||||
self.searchchain = SearchChain()
|
||||
self.mediachain = MediaChain()
|
||||
self.eventmanager = EventManager()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
self.messagehelper = MessageHelper()
|
||||
self.messageoper = MessageOper()
|
||||
|
||||
@staticmethod
|
||||
def __get_noexits_info(
|
||||
self,
|
||||
_meta: MetaBase,
|
||||
_mediainfo: MediaInfo) -> Dict[Union[int, str], Dict[int, NotExistMediaInfo]]:
|
||||
"""
|
||||
@@ -57,10 +45,10 @@ class MessageChain(ChainBase):
|
||||
if _mediainfo.type == MediaType.TV:
|
||||
if not _mediainfo.seasons:
|
||||
# 补充媒体信息
|
||||
_mediainfo = self.mediachain.recognize_media(mtype=_mediainfo.type,
|
||||
tmdbid=_mediainfo.tmdb_id,
|
||||
doubanid=_mediainfo.douban_id,
|
||||
cache=False)
|
||||
_mediainfo = MediaChain().recognize_media(mtype=_mediainfo.type,
|
||||
tmdbid=_mediainfo.tmdb_id,
|
||||
doubanid=_mediainfo.douban_id,
|
||||
cache=False)
|
||||
if not _mediainfo:
|
||||
logger.warn(f"{_mediainfo.tmdb_id or _mediainfo.douban_id} 媒体信息识别失败!")
|
||||
return {}
|
||||
@@ -119,7 +107,7 @@ class MessageChain(ChainBase):
|
||||
userid = info.userid
|
||||
# 用户名
|
||||
username = info.username or userid
|
||||
if not userid:
|
||||
if userid is None or userid == '':
|
||||
logger.debug(f'未识别到用户ID:{body}{form}{args}')
|
||||
return
|
||||
# 消息内容
|
||||
@@ -127,11 +115,19 @@ class MessageChain(ChainBase):
|
||||
if not text:
|
||||
logger.debug(f'未识别到消息内容::{body}{form}{args}')
|
||||
return
|
||||
|
||||
# 获取原消息ID信息
|
||||
original_message_id = info.message_id
|
||||
original_chat_id = info.chat_id
|
||||
|
||||
# 处理消息
|
||||
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=text)
|
||||
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=text,
|
||||
original_message_id=original_message_id, original_chat_id=original_chat_id)
|
||||
|
||||
def handle_message(self, channel: MessageChannel, source: str,
|
||||
userid: Union[str, int], username: str, text: str) -> None:
|
||||
userid: Union[str, int], username: str, text: str,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
识别消息内容,执行操作
|
||||
"""
|
||||
@@ -142,23 +138,32 @@ class MessageChain(ChainBase):
|
||||
# 处理消息
|
||||
logger.info(f'收到用户消息内容,用户:{userid},内容:{text}')
|
||||
# 保存消息
|
||||
self.messagehelper.put(
|
||||
CommingMessage(
|
||||
userid=userid,
|
||||
username=username,
|
||||
if not text.startswith('CALLBACK:'):
|
||||
self.messagehelper.put(
|
||||
CommingMessage(
|
||||
userid=userid,
|
||||
username=username,
|
||||
channel=channel,
|
||||
source=source,
|
||||
text=text
|
||||
), role="user")
|
||||
self.messageoper.add(
|
||||
channel=channel,
|
||||
source=source,
|
||||
text=text
|
||||
), role="user")
|
||||
self.messageoper.add(
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=username or userid,
|
||||
text=text,
|
||||
action=0
|
||||
)
|
||||
userid=username or userid,
|
||||
text=text,
|
||||
action=0
|
||||
)
|
||||
# 处理消息
|
||||
if text.startswith('/'):
|
||||
if text.startswith('CALLBACK:'):
|
||||
# 处理按钮回调(适配支持回调的渠道)
|
||||
if ChannelCapabilityManager.supports_callbacks(channel):
|
||||
self._handle_callback(text=text, channel=channel, source=source,
|
||||
userid=userid, username=username,
|
||||
original_message_id=original_message_id, original_chat_id=original_chat_id)
|
||||
else:
|
||||
logger.warning(f"渠道 {channel.value} 不支持回调,但收到了回调消息:{text}")
|
||||
elif text.startswith('/'):
|
||||
# 执行命令
|
||||
self.eventmanager.send_event(
|
||||
EventType.CommandExcute,
|
||||
@@ -173,7 +178,7 @@ class MessageChain(ChainBase):
|
||||
elif text.isdigit():
|
||||
# 用户选择了具体的条目
|
||||
# 缓存
|
||||
cache_data: dict = user_cache.get(userid)
|
||||
cache_data: dict = user_cache.get(userid).copy()
|
||||
# 选择项目
|
||||
if not cache_data \
|
||||
or not cache_data.get('items') \
|
||||
@@ -186,15 +191,15 @@ class MessageChain(ChainBase):
|
||||
# 缓存类型
|
||||
cache_type: str = cache_data.get('type')
|
||||
# 缓存列表
|
||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||
cache_list: list = cache_data.get('items').copy()
|
||||
# 选择
|
||||
if cache_type in ["Search", "ReSearch"]:
|
||||
# 当前媒体信息
|
||||
mediainfo: MediaInfo = cache_list[_choice]
|
||||
_current_media = mediainfo
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=_current_media)
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=_current_media)
|
||||
if exist_flag and cache_type == "Search":
|
||||
# 媒体库中已存在
|
||||
self.post_message(
|
||||
@@ -234,8 +239,8 @@ class MessageChain(ChainBase):
|
||||
title=f"开始搜索 {mediainfo.type.value} {mediainfo.title_year} ...",
|
||||
userid=userid))
|
||||
# 开始搜索
|
||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||
no_exists=no_exists)
|
||||
contexts = SearchChain().process(mediainfo=mediainfo,
|
||||
no_exists=no_exists)
|
||||
if not contexts:
|
||||
# 没有数据
|
||||
self.post_message(Notification(
|
||||
@@ -246,7 +251,7 @@ class MessageChain(ChainBase):
|
||||
userid=userid))
|
||||
return
|
||||
# 搜索结果排序
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
contexts = TorrentHelper().sort_torrents(contexts)
|
||||
# 判断是否设置自动下载
|
||||
auto_download_user = settings.AUTO_DOWNLOAD_USER
|
||||
# 匹配到自动下载用户
|
||||
@@ -274,7 +279,9 @@ class MessageChain(ChainBase):
|
||||
title=mediainfo.title,
|
||||
items=contexts[:self._page_size],
|
||||
userid=userid,
|
||||
total=len(contexts))
|
||||
total=len(contexts),
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id)
|
||||
|
||||
elif cache_type in ["Subscribe", "ReSubscribe"]:
|
||||
# 订阅或洗版媒体
|
||||
@@ -283,8 +290,8 @@ class MessageChain(ChainBase):
|
||||
best_version = False
|
||||
# 查询缺失的媒体信息
|
||||
if cache_type == "Subscribe":
|
||||
exist_flag, _ = self.downloadchain.get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=mediainfo)
|
||||
exist_flag, _ = DownloadChain().get_no_exists_info(meta=_current_meta,
|
||||
mediainfo=mediainfo)
|
||||
if exist_flag:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -296,18 +303,18 @@ class MessageChain(ChainBase):
|
||||
else:
|
||||
best_version = True
|
||||
# 转换用户名
|
||||
mp_name = self.useroper.get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||
mp_name = UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||
# 添加订阅,状态为N
|
||||
self.subscribechain.add(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=mp_name or username,
|
||||
best_version=best_version)
|
||||
SubscribeChain().add(title=mediainfo.title,
|
||||
year=mediainfo.year,
|
||||
mtype=mediainfo.type,
|
||||
tmdbid=mediainfo.tmdb_id,
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=mp_name or username,
|
||||
best_version=best_version)
|
||||
elif cache_type == "Torrent":
|
||||
if int(text) == 0:
|
||||
# 自动选择下载,强制下载模式
|
||||
@@ -320,12 +327,12 @@ class MessageChain(ChainBase):
|
||||
# 下载种子
|
||||
context: Context = cache_list[_choice]
|
||||
# 下载
|
||||
self.downloadchain.download_single(context, channel=channel, source=source,
|
||||
userid=userid, username=username)
|
||||
DownloadChain().download_single(context, channel=channel, source=source,
|
||||
userid=userid, username=username)
|
||||
|
||||
elif text.lower() == "p":
|
||||
# 上一页
|
||||
cache_data: dict = user_cache.get(userid)
|
||||
cache_data: dict = user_cache.get(userid).copy()
|
||||
if not cache_data:
|
||||
# 没有缓存
|
||||
self.post_message(Notification(
|
||||
@@ -341,7 +348,7 @@ class MessageChain(ChainBase):
|
||||
_current_page -= 1
|
||||
cache_type: str = cache_data.get('type')
|
||||
# 产生副本,避免修改原值
|
||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||
cache_list: list = cache_data.get('items').copy()
|
||||
if _current_page == 0:
|
||||
start = 0
|
||||
end = self._page_size
|
||||
@@ -355,7 +362,9 @@ class MessageChain(ChainBase):
|
||||
title=_current_media.title,
|
||||
items=cache_list[start:end],
|
||||
userid=userid,
|
||||
total=len(cache_list))
|
||||
total=len(cache_list),
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id)
|
||||
else:
|
||||
# 发送媒体数据
|
||||
self.__post_medias_message(channel=channel,
|
||||
@@ -363,11 +372,13 @@ class MessageChain(ChainBase):
|
||||
title=_current_meta.name,
|
||||
items=cache_list[start:end],
|
||||
userid=userid,
|
||||
total=len(cache_list))
|
||||
total=len(cache_list),
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id)
|
||||
|
||||
elif text.lower() == "n":
|
||||
# 下一页
|
||||
cache_data: dict = user_cache.get(userid)
|
||||
cache_data: dict = user_cache.get(userid).copy()
|
||||
if not cache_data:
|
||||
# 没有缓存
|
||||
self.post_message(Notification(
|
||||
@@ -375,7 +386,7 @@ class MessageChain(ChainBase):
|
||||
return
|
||||
cache_type: str = cache_data.get('type')
|
||||
# 产生副本,避免修改原值
|
||||
cache_list: list = copy.deepcopy(cache_data.get('items'))
|
||||
cache_list: list = cache_data.get('items').copy()
|
||||
total = len(cache_list)
|
||||
# 加一页
|
||||
cache_list = cache_list[
|
||||
@@ -393,13 +404,21 @@ class MessageChain(ChainBase):
|
||||
self.__post_torrents_message(channel=channel,
|
||||
source=source,
|
||||
title=_current_media.title,
|
||||
items=cache_list, userid=userid, total=total)
|
||||
items=cache_list,
|
||||
userid=userid,
|
||||
total=total,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id)
|
||||
else:
|
||||
# 发送媒体数据
|
||||
self.__post_medias_message(channel=channel,
|
||||
source=source,
|
||||
title=_current_meta.name,
|
||||
items=cache_list, userid=userid, total=total)
|
||||
items=cache_list,
|
||||
userid=userid,
|
||||
total=total,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id)
|
||||
|
||||
else:
|
||||
# 搜索或订阅
|
||||
@@ -422,15 +441,19 @@ class MessageChain(ChainBase):
|
||||
or text.find("继续") != -1:
|
||||
# 聊天
|
||||
content = text
|
||||
action = "chat"
|
||||
action = "Chat"
|
||||
elif StringUtils.is_link(text):
|
||||
# 链接
|
||||
content = text
|
||||
action = "Link"
|
||||
else:
|
||||
# 搜索
|
||||
content = text
|
||||
action = "Search"
|
||||
|
||||
if action != "chat":
|
||||
if action in ["Search", "ReSearch", "Subscribe", "ReSubscribe"]:
|
||||
# 搜索
|
||||
meta, medias = self.mediachain.search(content)
|
||||
meta, medias = MediaChain().search(content)
|
||||
# 识别
|
||||
if not meta.name:
|
||||
self.post_message(Notification(
|
||||
@@ -455,7 +478,9 @@ class MessageChain(ChainBase):
|
||||
source=source,
|
||||
title=meta.name,
|
||||
items=medias[:self._page_size],
|
||||
userid=userid, total=len(medias))
|
||||
userid=userid, total=len(medias),
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id)
|
||||
else:
|
||||
# 广播事件
|
||||
self.eventmanager.send_event(
|
||||
@@ -471,15 +496,149 @@ class MessageChain(ChainBase):
|
||||
# 保存缓存
|
||||
self.save_cache(user_cache, self._cache_file)
|
||||
|
||||
# 清理内存
|
||||
user_cache.clear()
|
||||
del user_cache
|
||||
|
||||
gc.collect()
|
||||
|
||||
def _handle_callback(self, text: str, channel: MessageChannel, source: str,
|
||||
userid: Union[str, int], username: str,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
处理按钮回调
|
||||
"""
|
||||
# 提取回调数据
|
||||
callback_data = text[9:] # 去掉 "CALLBACK:" 前缀
|
||||
logger.info(f"处理按钮回调:{callback_data}")
|
||||
|
||||
# 插件消息的事件回调 [PLUGIN]插件ID|内容
|
||||
if callback_data.startswith('[PLUGIN]'):
|
||||
# 提取插件ID和内容
|
||||
plugin_id, content = callback_data.split("|", 1)
|
||||
# 广播给插件处理
|
||||
self.eventmanager.send_event(
|
||||
EventType.MessageAction,
|
||||
{
|
||||
"plugin_id": plugin_id.replace("[PLUGIN]", ""),
|
||||
"text": content,
|
||||
"userid": userid,
|
||||
"channel": channel,
|
||||
"source": source,
|
||||
"original_message_id": original_message_id,
|
||||
"original_chat_id": original_chat_id
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# 解析系统回调数据
|
||||
if callback_data.startswith("page_"):
|
||||
# 翻页操作
|
||||
self._handle_page_callback(callback_data=callback_data, channel=channel,
|
||||
source=source, userid=userid,
|
||||
original_message_id=original_message_id, original_chat_id=original_chat_id)
|
||||
elif callback_data.startswith("select_"):
|
||||
# 选择操作或翻页操作
|
||||
if callback_data in ["select_p", "select_n"]:
|
||||
# 翻页操作
|
||||
page_text = callback_data.split("_")[1] # 提取 "p" 或 "n"
|
||||
self.handle_message(channel=channel, source=source,
|
||||
userid=userid, username=username,
|
||||
text=page_text,
|
||||
original_message_id=original_message_id, original_chat_id=original_chat_id)
|
||||
else:
|
||||
# 选择操作
|
||||
self._handle_select_callback(callback_data=callback_data, channel=channel,
|
||||
source=source, userid=userid, username=username)
|
||||
elif callback_data.startswith("download_"):
|
||||
# 下载操作
|
||||
self._handle_download_callback(callback_data=callback_data, channel=channel,
|
||||
source=source, userid=userid, username=username)
|
||||
elif callback_data.startswith("subscribe_"):
|
||||
# 订阅操作
|
||||
self._handle_subscribe_callback(callback_data=callback_data, channel=channel,
|
||||
source=source, userid=userid, username=username)
|
||||
else:
|
||||
# 其他自定义回调
|
||||
logger.info(f"未知的回调数据:{callback_data}")
|
||||
|
||||
def _handle_page_callback(self, callback_data: str, channel: MessageChannel, source: str,
|
||||
userid: Union[str, int], original_message_id: Optional[Union[str, int]],
|
||||
original_chat_id: Optional[str]):
|
||||
"""
|
||||
处理翻页回调
|
||||
"""
|
||||
try:
|
||||
page = int(callback_data.split("_")[1])
|
||||
|
||||
# 获取当前页面
|
||||
global _current_page
|
||||
|
||||
# 判断是上一页还是下一页
|
||||
if page < _current_page:
|
||||
# 上一页,调用原来的 "p" 逻辑
|
||||
self.handle_message(channel=channel, source=source, userid=userid,
|
||||
username="", text="p",
|
||||
original_message_id=original_message_id, original_chat_id=original_chat_id)
|
||||
elif page > _current_page:
|
||||
# 下一页,调用原来的 "n" 逻辑
|
||||
self.handle_message(channel=channel, source=source, userid=userid,
|
||||
username="", text="n",
|
||||
original_message_id=original_message_id, original_chat_id=original_chat_id)
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"处理翻页回调失败:{e}")
|
||||
|
||||
def _handle_select_callback(self, callback_data: str, channel: MessageChannel, source: str,
|
||||
userid: Union[str, int], username: str) -> None:
|
||||
"""
|
||||
处理选择回调
|
||||
"""
|
||||
try:
|
||||
index = int(callback_data.split("_")[1])
|
||||
# 调用原有的数字选择逻辑
|
||||
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=str(index + 1))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"处理选择回调失败:{e}")
|
||||
|
||||
def _handle_download_callback(self, callback_data: str, channel: MessageChannel, source: str,
|
||||
userid: Union[str, int], username: str) -> None:
|
||||
"""
|
||||
处理下载回调
|
||||
"""
|
||||
try:
|
||||
if callback_data == "download_auto":
|
||||
# 自动选择下载
|
||||
self.handle_message(channel=channel, source=source, userid=userid, username=username, text="0")
|
||||
else:
|
||||
index = int(callback_data.split("_")[1])
|
||||
self.handle_message(channel=channel, source=source, userid=userid, username=username,
|
||||
text=str(index + 1))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"处理下载回调失败:{e}")
|
||||
|
||||
def _handle_subscribe_callback(self, callback_data: str, channel: MessageChannel, source: str,
|
||||
userid: Union[str, int], username: str) -> None:
|
||||
"""
|
||||
处理订阅回调
|
||||
"""
|
||||
try:
|
||||
index = int(callback_data.split("_")[1])
|
||||
self.handle_message(channel=channel, source=source, userid=userid, username=username, text=str(index + 1))
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"处理订阅回调失败:{e}")
|
||||
|
||||
def __auto_download(self, channel: MessageChannel, source: str, cache_list: list[Context],
|
||||
userid: Union[str, int], username: str,
|
||||
no_exists: Optional[Dict[Union[int, str], Dict[int, NotExistMediaInfo]]] = None):
|
||||
"""
|
||||
自动择优下载
|
||||
"""
|
||||
downloadchain = DownloadChain()
|
||||
if no_exists is None:
|
||||
# 查询缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
exist_flag, no_exists = downloadchain.get_no_exists_info(
|
||||
meta=_current_meta,
|
||||
mediainfo=_current_media
|
||||
)
|
||||
@@ -488,12 +647,12 @@ class MessageChain(ChainBase):
|
||||
no_exists = self.__get_noexits_info(_current_meta, _current_media)
|
||||
|
||||
# 批量下载
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=cache_list,
|
||||
no_exists=no_exists,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username)
|
||||
downloads, lefts = downloadchain.batch_download(contexts=cache_list,
|
||||
no_exists=no_exists,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=username)
|
||||
if downloads and not lefts:
|
||||
# 全部下载完成
|
||||
logger.info(f'{_current_media.title_year} 下载完成')
|
||||
@@ -508,50 +667,200 @@ class MessageChain(ChainBase):
|
||||
else:
|
||||
note = None
|
||||
# 转换用户名
|
||||
mp_name = self.useroper.get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||
mp_name = UserOper().get_name(**{f"{channel.name.lower()}_userid": userid}) if channel else None
|
||||
# 添加订阅,状态为R
|
||||
self.subscribechain.add(title=_current_media.title,
|
||||
year=_current_media.year,
|
||||
mtype=_current_media.type,
|
||||
tmdbid=_current_media.tmdb_id,
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=mp_name or username,
|
||||
state="R",
|
||||
note=note)
|
||||
SubscribeChain().add(title=_current_media.title,
|
||||
year=_current_media.year,
|
||||
mtype=_current_media.type,
|
||||
tmdbid=_current_media.tmdb_id,
|
||||
season=_current_meta.begin_season,
|
||||
channel=channel,
|
||||
source=source,
|
||||
userid=userid,
|
||||
username=mp_name or username,
|
||||
state="R",
|
||||
note=note)
|
||||
|
||||
def __post_medias_message(self, channel: MessageChannel, source: str,
|
||||
title: str, items: list, userid: str, total: int):
|
||||
title: str, items: list, userid: str, total: int,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None):
|
||||
"""
|
||||
发送媒体列表消息
|
||||
"""
|
||||
if total > self._page_size:
|
||||
title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择(p: 上一页 n: 下一页)"
|
||||
else:
|
||||
title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择"
|
||||
self.post_medias_message(Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title=title,
|
||||
userid=userid
|
||||
), medias=items)
|
||||
# 检查渠道是否支持按钮
|
||||
supports_buttons = ChannelCapabilityManager.supports_buttons(channel)
|
||||
|
||||
def __post_torrents_message(self, channel: MessageChannel, source: str,
|
||||
title: str, items: list,
|
||||
userid: str, total: int):
|
||||
"""
|
||||
发送种子列表消息
|
||||
"""
|
||||
if total > self._page_size:
|
||||
title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择 p: 上一页 n: 下一页)"
|
||||
if supports_buttons:
|
||||
# 支持按钮的渠道
|
||||
if total > self._page_size:
|
||||
title = f"【{title}】共找到{total}条相关信息,请选择操作"
|
||||
else:
|
||||
title = f"【{title}】共找到{total}条相关信息,请选择操作"
|
||||
|
||||
buttons = self._create_media_buttons(channel=channel, items=items, total=total)
|
||||
else:
|
||||
title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)"
|
||||
self.post_torrents_message(Notification(
|
||||
# 不支持按钮的渠道,使用文本提示
|
||||
if total > self._page_size:
|
||||
title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择(p: 上一页 n: 下一页)"
|
||||
else:
|
||||
title = f"【{title}】共找到{total}条相关信息,请回复对应数字选择"
|
||||
buttons = None
|
||||
|
||||
notification = Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title=title,
|
||||
userid=userid,
|
||||
link=settings.MP_DOMAIN('#/resource')
|
||||
), torrents=items)
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
|
||||
self.post_medias_message(notification, medias=items)
|
||||
|
||||
def _create_media_buttons(self, channel: MessageChannel, items: list, total: int) -> List[List[Dict]]:
|
||||
"""
|
||||
创建媒体选择按钮
|
||||
"""
|
||||
global _current_page
|
||||
|
||||
buttons = []
|
||||
max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel)
|
||||
max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel)
|
||||
|
||||
# 为每个媒体项创建选择按钮
|
||||
current_row = []
|
||||
for i in range(len(items)):
|
||||
media = items[i]
|
||||
|
||||
if max_per_row == 1:
|
||||
# 每行一个按钮,使用完整文本
|
||||
button_text = f"{i + 1}. {media.title_year}"
|
||||
if len(button_text) > max_text_length:
|
||||
button_text = button_text[:max_text_length - 3] + "..."
|
||||
|
||||
buttons.append([{
|
||||
"text": button_text,
|
||||
"callback_data": f"select_{_current_page * self._page_size + i}"
|
||||
}])
|
||||
else:
|
||||
# 多按钮一行的情况,使用简化文本
|
||||
button_text = f"{i + 1}"
|
||||
|
||||
current_row.append({
|
||||
"text": button_text,
|
||||
"callback_data": f"select_{_current_page * self._page_size + i}"
|
||||
})
|
||||
|
||||
# 如果当前行已满或者是最后一个按钮,添加到按钮列表
|
||||
if len(current_row) == max_per_row or i == len(items) - 1:
|
||||
buttons.append(current_row)
|
||||
current_row = []
|
||||
|
||||
# 添加翻页按钮
|
||||
if total > self._page_size:
|
||||
page_buttons = []
|
||||
if _current_page > 0:
|
||||
page_buttons.append({"text": "⬅️ 上一页", "callback_data": "select_p"})
|
||||
if (_current_page + 1) * self._page_size < total:
|
||||
page_buttons.append({"text": "下一页 ➡️", "callback_data": "select_n"})
|
||||
if page_buttons:
|
||||
buttons.append(page_buttons)
|
||||
|
||||
return buttons
|
||||
|
||||
def __post_torrents_message(self, channel: MessageChannel, source: str,
|
||||
title: str, items: list, userid: str, total: int,
|
||||
original_message_id: Optional[Union[str, int]] = None,
|
||||
original_chat_id: Optional[str] = None):
|
||||
"""
|
||||
发送种子列表消息
|
||||
"""
|
||||
# 检查渠道是否支持按钮
|
||||
supports_buttons = ChannelCapabilityManager.supports_buttons(channel)
|
||||
|
||||
if supports_buttons:
|
||||
# 支持按钮的渠道
|
||||
if total > self._page_size:
|
||||
title = f"【{title}】共找到{total}条相关资源,请选择下载"
|
||||
else:
|
||||
title = f"【{title}】共找到{total}条相关资源,请选择下载"
|
||||
|
||||
buttons = self._create_torrent_buttons(channel=channel, items=items, total=total)
|
||||
else:
|
||||
# 不支持按钮的渠道,使用文本提示
|
||||
if total > self._page_size:
|
||||
title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择 p: 上一页 n: 下一页)"
|
||||
else:
|
||||
title = f"【{title}】共找到{total}条相关资源,请回复对应数字下载(0: 自动选择)"
|
||||
buttons = None
|
||||
|
||||
notification = Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
title=title,
|
||||
userid=userid,
|
||||
link=settings.MP_DOMAIN('#/resource'),
|
||||
buttons=buttons,
|
||||
original_message_id=original_message_id,
|
||||
original_chat_id=original_chat_id
|
||||
)
|
||||
|
||||
self.post_torrents_message(notification, torrents=items)
|
||||
|
||||
def _create_torrent_buttons(self, channel: MessageChannel, items: list, total: int) -> List[List[Dict]]:
|
||||
"""
|
||||
创建种子下载按钮
|
||||
"""
|
||||
|
||||
global _current_page
|
||||
|
||||
buttons = []
|
||||
max_text_length = ChannelCapabilityManager.get_max_button_text_length(channel)
|
||||
max_per_row = ChannelCapabilityManager.get_max_buttons_per_row(channel)
|
||||
|
||||
# 自动选择按钮
|
||||
buttons.append([{"text": "🤖 自动选择下载", "callback_data": "download_auto"}])
|
||||
|
||||
# 为每个种子项创建下载按钮
|
||||
current_row = []
|
||||
for i in range(len(items)):
|
||||
context = items[i]
|
||||
torrent = context.torrent_info
|
||||
|
||||
if max_per_row == 1:
|
||||
# 每行一个按钮,使用完整文本
|
||||
button_text = f"{i + 1}. {torrent.site_name} - {torrent.seeders}↑"
|
||||
if len(button_text) > max_text_length:
|
||||
button_text = button_text[:max_text_length - 3] + "..."
|
||||
|
||||
buttons.append([{
|
||||
"text": button_text,
|
||||
"callback_data": f"download_{_current_page * self._page_size + i}"
|
||||
}])
|
||||
else:
|
||||
# 多按钮一行的情况,使用简化文本
|
||||
button_text = f"{i + 1}"
|
||||
|
||||
current_row.append({
|
||||
"text": button_text,
|
||||
"callback_data": f"download_{_current_page * self._page_size + i}"
|
||||
})
|
||||
|
||||
# 如果当前行已满或者是最后一个按钮,添加到按钮列表
|
||||
if len(current_row) == max_per_row or i == len(items) - 1:
|
||||
buttons.append(current_row)
|
||||
current_row = []
|
||||
|
||||
# 添加翻页按钮
|
||||
if total > self._page_size:
|
||||
page_buttons = []
|
||||
if _current_page > 0:
|
||||
page_buttons.append({"text": "⬅️ 上一页", "callback_data": "select_p"})
|
||||
if (_current_page + 1) * self._page_size < total:
|
||||
page_buttons.append({"text": "下一页 ➡️", "callback_data": "select_n"})
|
||||
if page_buttons:
|
||||
buttons.append(page_buttons)
|
||||
|
||||
return buttons
|
||||
|
||||
@@ -29,12 +29,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
推荐处理链,单例运行
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.doubanchain = DoubanChain()
|
||||
self.bangumichain = BangumiChain()
|
||||
self.cache_max_pages = 5
|
||||
# 推荐数据的缓存页数
|
||||
cache_max_pages = 5
|
||||
|
||||
def refresh_recommend(self):
|
||||
"""
|
||||
@@ -174,16 +170,16 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TMDB热门电影
|
||||
"""
|
||||
movies = self.tmdbchain.tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -200,16 +196,16 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TMDB热门电视剧
|
||||
"""
|
||||
tvs = self.tmdbchain.tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
|
||||
sort_by=sort_by,
|
||||
with_genres=with_genres,
|
||||
with_original_language=with_original_language,
|
||||
with_keywords=with_keywords,
|
||||
with_watch_providers=with_watch_providers,
|
||||
vote_average=vote_average,
|
||||
vote_count=vote_count,
|
||||
release_date=release_date,
|
||||
page=page)
|
||||
return [tv.to_dict() for tv in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -218,7 +214,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
TMDB流行趋势
|
||||
"""
|
||||
infos = self.tmdbchain.tmdb_trending(page=page)
|
||||
infos = TmdbChain().tmdb_trending(page=page)
|
||||
return [info.to_dict() for info in infos] if infos else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -227,7 +223,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
Bangumi每日放送
|
||||
"""
|
||||
medias = self.bangumichain.calendar()
|
||||
medias = BangumiChain().calendar()
|
||||
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -236,7 +232,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣正在热映
|
||||
"""
|
||||
movies = self.doubanchain.movie_showing(page=page, count=count)
|
||||
movies = DoubanChain().movie_showing(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -246,8 +242,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣最新电影
|
||||
"""
|
||||
movies = self.doubanchain.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)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -257,8 +253,8 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣最新电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.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)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -267,7 +263,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣电影TOP250
|
||||
"""
|
||||
movies = self.doubanchain.movie_top250(page=page, count=count)
|
||||
movies = DoubanChain().movie_top250(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -276,7 +272,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣国产剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_chinese(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -285,7 +281,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣全球剧集榜
|
||||
"""
|
||||
tvs = self.doubanchain.tv_weekly_global(page=page, count=count)
|
||||
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -294,7 +290,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣热门动漫
|
||||
"""
|
||||
tvs = self.doubanchain.tv_animation(page=page, count=count)
|
||||
tvs = DoubanChain().tv_animation(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -303,7 +299,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣热门电影
|
||||
"""
|
||||
movies = self.doubanchain.movie_hot(page=page, count=count)
|
||||
movies = DoubanChain().movie_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@@ -312,5 +308,5 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
豆瓣热门电视剧
|
||||
"""
|
||||
tvs = self.doubanchain.tv_hot(page=page, count=count)
|
||||
tvs = DoubanChain().tv_hot(page=page, count=count)
|
||||
return [media.to_dict() for media in tvs] if tvs else []
|
||||
|
||||
@@ -27,13 +27,6 @@ class SearchChain(ChainBase):
|
||||
|
||||
__result_temp_file = "__search_result__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.progress = ProgressHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
|
||||
def search_by_id(self, tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
|
||||
mtype: MediaType = None, area: Optional[str] = "title", season: Optional[int] = None,
|
||||
sites: List[int] = None, cache_local: bool = False) -> List[Context]:
|
||||
@@ -184,19 +177,20 @@ class SearchChain(ChainBase):
|
||||
return []
|
||||
|
||||
# 开始新进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.Search)
|
||||
|
||||
# 开始过滤
|
||||
self.progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
# 匹配订阅附加参数
|
||||
if filter_params:
|
||||
logger.info(f'开始附加参数过滤,附加参数:{filter_params} ...')
|
||||
torrents = [torrent for torrent in torrents if self.torrenthelper.filter_torrent(torrent, filter_params)]
|
||||
torrents = [torrent for torrent in torrents if TorrentHelper().filter_torrent(torrent, filter_params)]
|
||||
# 开始过滤规则过滤
|
||||
if rule_groups is None:
|
||||
# 取搜索过滤规则
|
||||
rule_groups: List[str] = self.systemconfig.get(SystemConfigKey.SearchFilterRuleGroups)
|
||||
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups)
|
||||
if rule_groups:
|
||||
logger.info(f'开始过滤规则/剧集过滤,使用规则组:{rule_groups} ...')
|
||||
torrents = __do_filter(torrents)
|
||||
@@ -206,7 +200,7 @@ class SearchChain(ChainBase):
|
||||
logger.info(f"过滤规则/剧集过滤完成,剩余 {len(torrents)} 个资源")
|
||||
|
||||
# 过滤完成
|
||||
self.progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源', key=ProgressKey.Search)
|
||||
progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源', key=ProgressKey.Search)
|
||||
|
||||
# 开始匹配
|
||||
_match_torrents = []
|
||||
@@ -215,17 +209,19 @@ class SearchChain(ChainBase):
|
||||
# 已处理数
|
||||
_count = 0
|
||||
|
||||
torrenthelper = TorrentHelper()
|
||||
|
||||
if mediainfo:
|
||||
# 英文标题应该在别名/原标题中,不需要再匹配
|
||||
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
|
||||
self.progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
|
||||
for torrent in torrents:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
_count += 1
|
||||
self.progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||
key=ProgressKey.Search)
|
||||
progress.update(value=(_count / _total) * 96,
|
||||
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
|
||||
key=ProgressKey.Search)
|
||||
if not torrent.title:
|
||||
continue
|
||||
|
||||
@@ -236,10 +232,9 @@ class SearchChain(ChainBase):
|
||||
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
|
||||
# 季集数过滤
|
||||
if season_episodes \
|
||||
and not self.torrenthelper.match_season_episodes(
|
||||
torrent=torrent,
|
||||
meta=torrent_meta,
|
||||
season_episodes=season_episodes):
|
||||
and not torrenthelper.match_season_episodes(torrent=torrent,
|
||||
meta=torrent_meta,
|
||||
season_episodes=season_episodes):
|
||||
continue
|
||||
# 比对IMDBID
|
||||
if torrent.imdbid \
|
||||
@@ -250,17 +245,17 @@ class SearchChain(ChainBase):
|
||||
continue
|
||||
|
||||
# 比对种子
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent):
|
||||
if torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent):
|
||||
# 匹配成功
|
||||
_match_torrents.append((torrent, torrent_meta))
|
||||
continue
|
||||
# 匹配完成
|
||||
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
|
||||
self.progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
progress.update(value=97,
|
||||
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
else:
|
||||
_match_torrents = [(t, MetaInfo(title=t.title, subtitle=t.description)) for t in torrents]
|
||||
|
||||
@@ -273,17 +268,17 @@ class SearchChain(ChainBase):
|
||||
meta_info=t[1]) for t in _match_torrents]
|
||||
|
||||
# 排序
|
||||
self.progress.update(value=99,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = self.torrenthelper.sort_torrents(contexts)
|
||||
progress.update(value=99,
|
||||
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
|
||||
key=ProgressKey.Search)
|
||||
contexts = torrenthelper.sort_torrents(contexts)
|
||||
|
||||
# 结束进度
|
||||
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
|
||||
self.progress.update(value=100,
|
||||
text=f'搜索完成,共 {len(contexts)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
self.progress.end(ProgressKey.Search)
|
||||
progress.update(value=100,
|
||||
text=f'搜索完成,共 {len(contexts)} 个资源',
|
||||
key=ProgressKey.Search)
|
||||
progress.end(ProgressKey.Search)
|
||||
|
||||
# 返回
|
||||
return contexts
|
||||
@@ -307,9 +302,9 @@ class SearchChain(ChainBase):
|
||||
|
||||
# 配置的索引站点
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.IndexerSites) or []
|
||||
sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
|
||||
|
||||
for indexer in self.siteshelper.get_indexers():
|
||||
for indexer in SitesHelper().get_indexers():
|
||||
# 检查站点索引开关
|
||||
if not sites or indexer.get("id") in sites:
|
||||
indexer_sites.append(indexer)
|
||||
@@ -318,7 +313,8 @@ class SearchChain(ChainBase):
|
||||
return []
|
||||
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.Search)
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.Search)
|
||||
# 开始计时
|
||||
start_time = datetime.now()
|
||||
# 总数
|
||||
@@ -326,48 +322,48 @@ class SearchChain(ChainBase):
|
||||
# 完成数
|
||||
finish_count = 0
|
||||
# 更新进度
|
||||
self.progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 多线程
|
||||
executor = ThreadPoolExecutor(max_workers=len(indexer_sites))
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
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)
|
||||
progress.update(value=0,
|
||||
text=f"开始搜索,共 {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 结果集
|
||||
results = []
|
||||
for future in as_completed(all_task):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
finish_count += 1
|
||||
result = future.result()
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
self.progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 多线程
|
||||
with ThreadPoolExecutor(max_workers=len(indexer_sites)) as executor:
|
||||
all_task = []
|
||||
for site in indexer_sites:
|
||||
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)
|
||||
for future in as_completed(all_task):
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
finish_count += 1
|
||||
result = future.result()
|
||||
if result:
|
||||
results.extend(result)
|
||||
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
|
||||
progress.update(value=finish_count / total_num * 100,
|
||||
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
|
||||
key=ProgressKey.Search)
|
||||
# 计算耗时
|
||||
end_time = datetime.now()
|
||||
# 更新进度
|
||||
self.progress.update(value=100,
|
||||
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒",
|
||||
key=ProgressKey.Search)
|
||||
progress.update(value=100,
|
||||
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒",
|
||||
key=ProgressKey.Search)
|
||||
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds} 秒")
|
||||
# 结束进度
|
||||
self.progress.end(ProgressKey.Search)
|
||||
progress.end(ProgressKey.Search)
|
||||
# 返回
|
||||
return results
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from app.helper.browser import PlaywrightHelper
|
||||
from app.helper.cloudflare import under_challenge
|
||||
from app.helper.cookie import CookieHelper
|
||||
from app.helper.cookiecloud import CookieCloudHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.rss import RssHelper
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.log import logger
|
||||
@@ -34,13 +33,6 @@ class SiteChain(ChainBase):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteoper = SiteOper()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.cookiehelper = CookieHelper()
|
||||
self.message = MessageHelper()
|
||||
self.cookiecloud = CookieCloudHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
# 特殊站点登录验证
|
||||
self.special_site_test = {
|
||||
@@ -62,9 +54,9 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
userdata: SiteUserData = self.run_module("refresh_userdata", site=site)
|
||||
if userdata:
|
||||
self.siteoper.update_userdata(domain=StringUtils.get_url_domain(site.get("domain")),
|
||||
name=site.get("name"),
|
||||
payload=userdata.dict())
|
||||
SiteOper().update_userdata(domain=StringUtils.get_url_domain(site.get("domain")),
|
||||
name=site.get("name"),
|
||||
payload=userdata.dict())
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SiteRefreshed, {
|
||||
"site_id": site.get("id")
|
||||
@@ -100,7 +92,7 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
刷新所有站点的用户数据
|
||||
"""
|
||||
sites = self.siteshelper.get_indexers()
|
||||
sites = SitesHelper().get_indexers()
|
||||
any_site_updated = False
|
||||
result = {}
|
||||
for site in sites:
|
||||
@@ -297,27 +289,30 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
if StringUtils.get_url_domain(inx.get("domain")) == sub_domain:
|
||||
return inx.get("domain")
|
||||
for ext_d in inx.get("ext_domains"):
|
||||
for ext_d in inx.get("ext_domains", []):
|
||||
if StringUtils.get_url_domain(ext_d) == sub_domain:
|
||||
return ext_d
|
||||
return sub_domain
|
||||
|
||||
logger.info("开始同步CookieCloud站点 ...")
|
||||
cookies, msg = self.cookiecloud.download()
|
||||
cookies, msg = CookieCloudHelper().download()
|
||||
if not cookies:
|
||||
logger.error(f"CookieCloud同步失败:{msg}")
|
||||
if manual:
|
||||
self.message.put(msg, title="CookieCloud同步失败", role="system")
|
||||
self.messagehelper.put(msg, title="CookieCloud同步失败", role="system")
|
||||
return False, msg
|
||||
# 保存Cookie或新增站点
|
||||
_update_count = 0
|
||||
_add_count = 0
|
||||
_fail_count = 0
|
||||
siteshelper = SitesHelper()
|
||||
siteoper = SiteOper()
|
||||
rsshelper = RssHelper()
|
||||
for domain, cookie in cookies.items():
|
||||
# 索引器信息
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
indexer = siteshelper.get_indexer(domain)
|
||||
# 数据库的站点信息
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
site_info = siteoper.get_by_domain(domain)
|
||||
if site_info and site_info.is_active == 1:
|
||||
# 站点已存在,检查站点连通性
|
||||
status, msg = self.test(domain)
|
||||
@@ -327,7 +322,7 @@ class SiteChain(ChainBase):
|
||||
# 更新站点rss地址
|
||||
if not site_info.public and not site_info.rss:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
rss_url, errmsg = rsshelper.get_rss_link(
|
||||
url=site_info.url,
|
||||
cookie=cookie,
|
||||
ua=site_info.ua or settings.USER_AGENT,
|
||||
@@ -335,13 +330,13 @@ class SiteChain(ChainBase):
|
||||
)
|
||||
if rss_url:
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=rss_url)
|
||||
siteoper.update_rss(domain=domain, rss=rss_url)
|
||||
else:
|
||||
logger.warn(errmsg)
|
||||
continue
|
||||
# 更新站点Cookie
|
||||
logger.info(f"更新站点 {domain} Cookie ...")
|
||||
self.siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||
siteoper.update_cookie(domain=domain, cookies=cookie)
|
||||
_update_count += 1
|
||||
elif indexer:
|
||||
if settings.COOKIECLOUD_BLACKLIST and any(
|
||||
@@ -396,21 +391,21 @@ class SiteChain(ChainBase):
|
||||
rss_url = None
|
||||
if not indexer.get("public") and domain_url:
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=proxy)
|
||||
rss_url, errmsg = rsshelper.get_rss_link(url=domain_url,
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT,
|
||||
proxy=proxy)
|
||||
if errmsg:
|
||||
logger.warn(errmsg)
|
||||
# 插入数据库
|
||||
logger.info(f"新增站点 {indexer.get('name')} ...")
|
||||
self.siteoper.add(name=indexer.get("name"),
|
||||
url=domain_url,
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
proxy=1 if proxy else 0,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
siteoper.add(name=indexer.get("name"),
|
||||
url=domain_url,
|
||||
domain=domain,
|
||||
cookie=cookie,
|
||||
rss=rss_url,
|
||||
proxy=1 if proxy else 0,
|
||||
public=1 if indexer.get("public") else 0)
|
||||
_add_count += 1
|
||||
|
||||
# 通知站点更新
|
||||
@@ -423,7 +418,7 @@ class SiteChain(ChainBase):
|
||||
if _fail_count > 0:
|
||||
ret_msg += f",{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
|
||||
if manual:
|
||||
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||
self.messagehelper.put(ret_msg, title="CookieCloud同步成功", role="system")
|
||||
logger.info(f"CookieCloud同步成功:{ret_msg}")
|
||||
return True, ret_msg
|
||||
|
||||
@@ -442,29 +437,31 @@ class SiteChain(ChainBase):
|
||||
if str(domain).startswith("http"):
|
||||
domain = StringUtils.get_url_domain(domain)
|
||||
# 站点信息
|
||||
siteinfo = self.siteoper.get_by_domain(domain)
|
||||
siteoper = SiteOper()
|
||||
siteshelper = SitesHelper()
|
||||
siteinfo = siteoper.get_by_domain(domain)
|
||||
if not siteinfo:
|
||||
logger.warn(f"未维护站点 {domain} 信息!")
|
||||
return
|
||||
# Cookie
|
||||
cookie = siteinfo.cookie
|
||||
# 索引器
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
indexer = siteshelper.get_indexer(domain)
|
||||
if not indexer:
|
||||
logger.warn(f"站点 {domain} 索引器不存在!")
|
||||
return
|
||||
# 查询站点图标
|
||||
site_icon = self.siteoper.get_icon_by_domain(domain)
|
||||
site_icon = siteoper.get_icon_by_domain(domain)
|
||||
if not site_icon or not site_icon.base64:
|
||||
logger.info(f"开始缓存站点 {indexer.get('name')} 图标 ...")
|
||||
icon_url, icon_base64 = self.__parse_favicon(url=indexer.get("domain"),
|
||||
cookie=cookie,
|
||||
ua=settings.USER_AGENT)
|
||||
if icon_url:
|
||||
self.siteoper.update_icon(name=indexer.get("name"),
|
||||
domain=domain,
|
||||
icon_url=icon_url,
|
||||
icon_base64=icon_base64)
|
||||
siteoper.update_icon(name=indexer.get("name"),
|
||||
domain=domain,
|
||||
icon_url=icon_url,
|
||||
icon_base64=icon_base64)
|
||||
logger.info(f"缓存站点 {indexer.get('name')} 图标成功")
|
||||
else:
|
||||
logger.warn(f"缓存站点 {indexer.get('name')} 图标失败")
|
||||
@@ -484,11 +481,12 @@ class SiteChain(ChainBase):
|
||||
# 获取主域名中间那段
|
||||
domain_host = StringUtils.get_url_host(domain)
|
||||
# 查询以"site.domain_host"开头的配置项,并清除
|
||||
site_keys = self.systemconfig.all().keys()
|
||||
systemconfig = SystemConfigOper()
|
||||
site_keys = systemconfig.all().keys()
|
||||
for key in site_keys:
|
||||
if key.startswith(f"site.{domain_host}"):
|
||||
logger.info(f"清理站点配置:{key}")
|
||||
self.systemconfig.delete(key)
|
||||
systemconfig.delete(key)
|
||||
|
||||
@eventmanager.register(EventType.SiteUpdated)
|
||||
def cache_site_userdata(self, event: Event):
|
||||
@@ -504,7 +502,7 @@ class SiteChain(ChainBase):
|
||||
return
|
||||
if str(domain).startswith("http"):
|
||||
domain = StringUtils.get_url_domain(domain)
|
||||
indexer = self.siteshelper.get_indexer(domain)
|
||||
indexer = SitesHelper().get_indexer(domain)
|
||||
if not indexer:
|
||||
return
|
||||
# 刷新站点用户数据
|
||||
@@ -518,7 +516,8 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
# 检查域名是否可用
|
||||
domain = StringUtils.get_url_domain(url)
|
||||
site_info = self.siteoper.get_by_domain(domain)
|
||||
siteoper = SiteOper()
|
||||
site_info = siteoper.get_by_domain(domain)
|
||||
if not site_info:
|
||||
return False, f"站点【{url}】不存在"
|
||||
|
||||
@@ -535,9 +534,9 @@ class SiteChain(ChainBase):
|
||||
# 统计
|
||||
seconds = (datetime.now() - start_time).seconds
|
||||
if state:
|
||||
self.siteoper.success(domain=domain, seconds=seconds)
|
||||
siteoper.success(domain=domain, seconds=seconds)
|
||||
else:
|
||||
self.siteoper.fail(domain)
|
||||
siteoper.fail(domain)
|
||||
return state, message
|
||||
except Exception as e:
|
||||
return False, f"{str(e)}!"
|
||||
@@ -593,7 +592,7 @@ class SiteChain(ChainBase):
|
||||
"""
|
||||
查询所有站点,发送消息
|
||||
"""
|
||||
site_list = self.siteoper.list()
|
||||
site_list = SiteOper().list()
|
||||
if not site_list:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -633,7 +632,8 @@ class SiteChain(ChainBase):
|
||||
if not arg_str.isdigit():
|
||||
return
|
||||
site_id = int(arg_str)
|
||||
site = self.siteoper.get(site_id)
|
||||
siteoper = SiteOper()
|
||||
site = siteoper.get(site_id)
|
||||
if not site:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
@@ -641,7 +641,7 @@ class SiteChain(ChainBase):
|
||||
userid=userid))
|
||||
return
|
||||
# 禁用站点
|
||||
self.siteoper.update(site_id, {
|
||||
siteoper.update(site_id, {
|
||||
"is_active": False
|
||||
})
|
||||
# 重新发送消息
|
||||
@@ -655,25 +655,27 @@ class SiteChain(ChainBase):
|
||||
if not arg_str:
|
||||
return
|
||||
arg_strs = str(arg_str).split()
|
||||
siteoper = SiteOper()
|
||||
for arg_str in arg_strs:
|
||||
arg_str = arg_str.strip()
|
||||
if not arg_str.isdigit():
|
||||
continue
|
||||
site_id = int(arg_str)
|
||||
site = self.siteoper.get(site_id)
|
||||
site = siteoper.get(site_id)
|
||||
if not site:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
title=f"站点编号 {site_id} 不存在!", userid=userid))
|
||||
return
|
||||
# 禁用站点
|
||||
self.siteoper.update(site_id, {
|
||||
siteoper.update(site_id, {
|
||||
"is_active": True
|
||||
})
|
||||
# 重新发送消息
|
||||
self.remote_list(channel=channel, userid=userid, source=source)
|
||||
|
||||
def update_cookie(self, site_info: Site,
|
||||
@staticmethod
|
||||
def update_cookie(site_info: Site,
|
||||
username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
根据用户名密码更新站点Cookie
|
||||
@@ -684,7 +686,7 @@ class SiteChain(ChainBase):
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
# 更新站点Cookie
|
||||
result = self.cookiehelper.get_site_cookie_ua(
|
||||
result = CookieHelper().get_site_cookie_ua(
|
||||
url=site_info.url,
|
||||
username=username,
|
||||
password=password,
|
||||
@@ -695,7 +697,7 @@ class SiteChain(ChainBase):
|
||||
cookie, ua, msg = result
|
||||
if not cookie:
|
||||
return False, msg
|
||||
self.siteoper.update(site_info.id, {
|
||||
SiteOper().update(site_info.id, {
|
||||
"cookie": cookie,
|
||||
"ua": ua
|
||||
})
|
||||
@@ -737,7 +739,7 @@ class SiteChain(ChainBase):
|
||||
# 站点ID
|
||||
site_id = int(site_id)
|
||||
# 站点信息
|
||||
site_info = self.siteoper.get(site_id)
|
||||
site_info = SiteOper().get(site_id)
|
||||
if not site_info:
|
||||
self.post_message(Notification(
|
||||
channel=channel,
|
||||
|
||||
@@ -14,16 +14,18 @@ class StorageChain(ChainBase):
|
||||
存储处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
|
||||
def save_config(self, storage: str, conf: dict) -> None:
|
||||
"""
|
||||
保存存储配置
|
||||
"""
|
||||
self.run_module("save_config", storage=storage, conf=conf)
|
||||
|
||||
def reset_config(self, storage: str) -> None:
|
||||
"""
|
||||
重置存储配置
|
||||
"""
|
||||
self.run_module("reset_config", storage=storage)
|
||||
|
||||
def generate_qrcode(self, storage: str) -> Optional[Tuple[dict, str]]:
|
||||
"""
|
||||
生成二维码
|
||||
@@ -131,28 +133,43 @@ class StorageChain(ChainBase):
|
||||
"""
|
||||
删除媒体文件,以及不含媒体文件的目录
|
||||
"""
|
||||
|
||||
def __is_bluray_dir(_fileitem: schemas.FileItem) -> bool:
|
||||
"""
|
||||
检查是否蓝光目录
|
||||
"""
|
||||
_dir_files = self.list_files(fileitem=_fileitem, recursion=False)
|
||||
if _dir_files:
|
||||
for _f in _dir_files:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
return True
|
||||
return False
|
||||
|
||||
media_exts = settings.RMT_MEDIAEXT + settings.DOWNLOAD_TMPEXT
|
||||
if fileitem.path == "/" or len(Path(fileitem.path).parts) <= 2:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 根目录或一级目录不允许删除")
|
||||
return False
|
||||
if fileitem.type == "dir":
|
||||
# 本身是目录
|
||||
if _blue_dir := self.list_files(fileitem=fileitem, recursion=False):
|
||||
# 删除蓝光目录
|
||||
for _f in _blue_dir:
|
||||
if _f.type == "dir" and _f.name in ["BDMV", "CERTIFICATE"]:
|
||||
logger.warn(f"【{fileitem.storage}】{_f.path} 删除蓝光目录")
|
||||
self.delete_file(_f)
|
||||
if self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(fileitem)
|
||||
return False
|
||||
if __is_bluray_dir(fileitem):
|
||||
logger.warn(f"正在删除蓝光原盘目录:【{fileitem.storage}】{fileitem.path}")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
elif self.any_files(fileitem, extensions=media_exts) is False:
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 不存在其它媒体文件,正在删除空目录")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
# 不处理父目录
|
||||
return True
|
||||
elif delete_self:
|
||||
# 本身是文件
|
||||
logger.warn(f"正在删除【{fileitem.storage}】{fileitem.path}")
|
||||
# 本身是文件,需要删除文件
|
||||
logger.warn(f"正在删除文件【{fileitem.storage}】{fileitem.path}")
|
||||
if not self.delete_file(fileitem):
|
||||
logger.warn(f"【{fileitem.storage}】{fileitem.path} 删除失败")
|
||||
return False
|
||||
|
||||
if mtype:
|
||||
# 重命名格式
|
||||
rename_format = settings.TV_RENAME_FORMAT \
|
||||
@@ -161,14 +178,17 @@ class StorageChain(ChainBase):
|
||||
rename_format_level = len(rename_format.split("/")) - 1
|
||||
if rename_format_level < 1:
|
||||
return True
|
||||
# 处理上级目录
|
||||
# 处理媒体文件根目录
|
||||
dir_item = self.get_file_item(storage=fileitem.storage,
|
||||
path=Path(fileitem.path).parents[rename_format_level - 1])
|
||||
else:
|
||||
# 处理上级目录
|
||||
dir_item = self.get_parent_item(fileitem)
|
||||
|
||||
# 检查和删除上级目录
|
||||
if dir_item and len(Path(dir_item.path).parts) > 2:
|
||||
# 如何目录是所有下载目录、媒体库目录的上级,则不处理
|
||||
for d in self.directoryhelper.get_dirs():
|
||||
for d in DirectoryHelper().get_dirs():
|
||||
if d.download_path and Path(d.download_path).is_relative_to(Path(dir_item.path)):
|
||||
logger.debug(f"【{dir_item.storage}】{dir_item.path} 是下载目录本级或上级目录,不删除")
|
||||
return True
|
||||
@@ -177,7 +197,9 @@ class StorageChain(ChainBase):
|
||||
return True
|
||||
# 不存在其他媒体文件,删除空目录
|
||||
if self.any_files(dir_item, extensions=media_exts) is False:
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,删除空目录")
|
||||
return self.delete_file(dir_item)
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 不存在其它媒体文件,正在删除空目录")
|
||||
if not self.delete_file(dir_item):
|
||||
logger.warn(f"【{dir_item.storage}】{dir_item.path} 删除失败")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -24,35 +24,20 @@ from app.db.models.subscribe import Subscribe
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.subscribe_oper import SubscribeOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.subscribe import SubscribeHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaRecognizeConvertEventData
|
||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.schemas.types import MediaType, SystemConfigKey, MessageChannel, NotificationType, EventType, ChainEventType, \
|
||||
ContentType
|
||||
|
||||
|
||||
class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
class SubscribeChain(ChainBase):
|
||||
"""
|
||||
订阅管理处理链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._rlock = threading.RLock()
|
||||
self.downloadchain = DownloadChain()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.searchchain = SearchChain()
|
||||
self.subscribeoper = SubscribeOper()
|
||||
self.subscribehelper = SubscribeHelper()
|
||||
self.torrentschain = TorrentsChain()
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.message = MessageHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
self.siteoper = SiteOper()
|
||||
_rlock = threading.RLock()
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -86,11 +71,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if event and event.event_data:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
if event_data.media_dict:
|
||||
mediachain = MediaChain()
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if event_data.convert_type == "themoviedb":
|
||||
return self.mediachain.recognize_media(meta=_meta, tmdbid=new_id)
|
||||
return mediachain.recognize_media(meta=_meta, tmdbid=new_id)
|
||||
elif event_data.convert_type == "douban":
|
||||
return self.mediachain.recognize_media(meta=_meta, doubanid=new_id)
|
||||
return mediachain.recognize_media(meta=_meta, doubanid=new_id)
|
||||
return None
|
||||
|
||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||
@@ -110,7 +96,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if not tmdbid:
|
||||
if doubanid:
|
||||
# 将豆瓣信息转换为TMDB信息
|
||||
tmdbinfo = self.mediachain.get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
|
||||
if tmdbinfo:
|
||||
mediainfo = MediaInfo(tmdb_info=tmdbinfo)
|
||||
elif mediaid:
|
||||
@@ -213,7 +199,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"filter_groups") else kwargs.get("filter_groups")
|
||||
})
|
||||
# 操作数据库
|
||||
sid, err_msg = self.subscribeoper.add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
||||
sid, err_msg = SubscribeOper().add(mediainfo=mediainfo, season=season, username=username, **kwargs)
|
||||
if not sid:
|
||||
logger.error(f'{mediainfo.title_year} {err_msg}')
|
||||
if not exist_ok and message:
|
||||
@@ -228,22 +214,26 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
userid=userid))
|
||||
return None, err_msg
|
||||
elif message:
|
||||
logger.info(f'{mediainfo.title_year} {metainfo.season} 添加订阅成功')
|
||||
if username:
|
||||
text = f"评分:{mediainfo.vote_average},来自用户:{username}"
|
||||
else:
|
||||
text = f"评分:{mediainfo.vote_average}"
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 订阅成功按规则发送消息
|
||||
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||
title=f"{mediainfo.title_year} {metainfo.season} 已添加订阅",
|
||||
text=text,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=username))
|
||||
self.post_message(
|
||||
schemas.Notification(
|
||||
channel=channel,
|
||||
source=source,
|
||||
mtype=NotificationType.Subscribe,
|
||||
ctype=ContentType.SubscribeAdded,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
userid=userid,
|
||||
username=username
|
||||
),
|
||||
meta=metainfo,
|
||||
mediainfo=mediainfo,
|
||||
username=username
|
||||
)
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeAdded, {
|
||||
"subscribe_id": sid,
|
||||
@@ -251,7 +241,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 统计订阅
|
||||
self.subscribehelper.sub_reg_async({
|
||||
SubscribeHelper().sub_reg_async({
|
||||
"name": title,
|
||||
"year": year,
|
||||
"type": metainfo.type.value,
|
||||
@@ -269,13 +259,14 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
# 返回结果
|
||||
return sid, ""
|
||||
|
||||
def exists(self, mediainfo: MediaInfo, meta: MetaBase = None):
|
||||
@staticmethod
|
||||
def exists(mediainfo: MediaInfo, meta: MetaBase = None):
|
||||
"""
|
||||
判断订阅是否已存在
|
||||
"""
|
||||
if self.subscribeoper.exists(tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
season=meta.begin_season if meta else None):
|
||||
if SubscribeOper().exists(tmdbid=mediainfo.tmdb_id,
|
||||
doubanid=mediainfo.douban_id,
|
||||
season=meta.begin_season if meta else None):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -289,11 +280,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
with self._rlock:
|
||||
logger.debug(f"search lock acquired at {datetime.now()}")
|
||||
subscribeoper = SubscribeOper()
|
||||
if sid:
|
||||
subscribe = self.subscribeoper.get(sid)
|
||||
subscribe = subscribeoper.get(sid)
|
||||
subscribes = [subscribe] if subscribe else []
|
||||
else:
|
||||
subscribes = self.subscribeoper.list(self.get_states_for_search(state))
|
||||
subscribes = subscribeoper.list(self.get_states_for_search(state))
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
@@ -348,20 +340,20 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or self.systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups) or []
|
||||
or SystemConfigOper().get(SystemConfigKey.BestVersionFilterRuleGroups) or []
|
||||
else:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or self.systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups) or []
|
||||
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
|
||||
|
||||
# 搜索,同时电视剧会过滤掉不需要的剧集
|
||||
contexts = self.searchchain.process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
area="imdbid" if subscribe.search_imdbid else "title",
|
||||
custom_words=custom_word_list,
|
||||
filter_params=self.get_params(subscribe))
|
||||
contexts = SearchChain().process(mediainfo=mediainfo,
|
||||
keyword=subscribe.keyword,
|
||||
no_exists=no_exists,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
area="imdbid" if subscribe.search_imdbid else "title",
|
||||
custom_words=custom_word_list,
|
||||
filter_params=self.get_params(subscribe))
|
||||
if not contexts:
|
||||
logger.warn(f'订阅 {subscribe.keyword or subscribe.name} 未搜索到资源')
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta,
|
||||
@@ -371,6 +363,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
# 过滤搜索结果
|
||||
matched_contexts = []
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
torrent_meta = context.meta_info
|
||||
torrent_info = context.torrent_info
|
||||
torrent_mediainfo = context.media_info
|
||||
@@ -402,10 +396,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
continue
|
||||
|
||||
# 自动下载
|
||||
downloads, lefts = self.downloadchain.batch_download(
|
||||
downloads, lefts = DownloadChain().batch_download(
|
||||
contexts=matched_contexts,
|
||||
no_exists=no_exists,
|
||||
userid=subscribe.username,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
@@ -413,7 +406,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = self.subscribeoper.get(subscribe.id)
|
||||
subscribe = subscribeoper.get(subscribe.id)
|
||||
|
||||
# 判断是否应完成订阅
|
||||
if subscribe:
|
||||
@@ -422,17 +415,17 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
finally:
|
||||
# 如果状态为N则更新为R
|
||||
if subscribe and subscribe.state == 'N':
|
||||
self.subscribeoper.update(subscribe.id, {'state': 'R'})
|
||||
subscribeoper.update(subscribe.id, {'state': 'R'})
|
||||
|
||||
# 手动触发时发送系统消息
|
||||
if manual:
|
||||
if subscribes:
|
||||
if sid:
|
||||
self.message.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
|
||||
self.messagehelper.put(f'{subscribes[0].name} 搜索完成!', title="订阅搜索", role="system")
|
||||
else:
|
||||
self.message.put('所有订阅搜索完成!', title="订阅搜索", role="system")
|
||||
self.messagehelper.put('所有订阅搜索完成!', title="订阅搜索", role="system")
|
||||
else:
|
||||
self.message.put('没有找到订阅!', title="订阅搜索", role="system")
|
||||
self.messagehelper.put('没有找到订阅!', title="订阅搜索", role="system")
|
||||
logger.debug(f"search Lock released at {datetime.now()}")
|
||||
|
||||
def update_subscribe_priority(self, subscribe: Subscribe, meta: MetaBase,
|
||||
@@ -447,7 +440,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
# 当前下载资源的优先级
|
||||
priority = max([item.torrent_info.pri_order for item in downloads])
|
||||
# 订阅存在待定策略,不管是否已完成,均需更新订阅信息
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
SubscribeOper().update(subscribe.id, {
|
||||
"current_priority": priority,
|
||||
"last_update": datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
@@ -504,17 +497,18 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if sites is None:
|
||||
return
|
||||
self.match(
|
||||
self.torrentschain.refresh(sites=sites)
|
||||
TorrentsChain().refresh(sites=sites)
|
||||
)
|
||||
|
||||
def get_sub_sites(self, subscribe: Subscribe) -> List[int]:
|
||||
@staticmethod
|
||||
def get_sub_sites(subscribe: Subscribe) -> List[int]:
|
||||
"""
|
||||
获取订阅中涉及的站点清单
|
||||
:param subscribe: 订阅信息对象
|
||||
:return: 涉及的站点清单
|
||||
"""
|
||||
# 从系统配置获取默认订阅站点
|
||||
default_sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
default_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
# 如果订阅未指定站点,直接返回默认站点
|
||||
if not subscribe.sites:
|
||||
return default_sites
|
||||
@@ -534,7 +528,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
:return: 返回[]代表所有站点命中,返回None代表没有订阅
|
||||
"""
|
||||
# 查询所有订阅
|
||||
subscribes = self.subscribeoper.list(self.get_states_for_search('R'))
|
||||
subscribes = SubscribeOper().list(self.get_states_for_search('R'))
|
||||
if not subscribes:
|
||||
return None
|
||||
ret_sites = []
|
||||
@@ -559,7 +553,29 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
with self._rlock:
|
||||
logger.debug(f"match lock acquired at {datetime.now()}")
|
||||
# 所有订阅
|
||||
subscribes = self.subscribeoper.list(self.get_states_for_search('R'))
|
||||
subscribes = SubscribeOper().list(self.get_states_for_search('R'))
|
||||
|
||||
# 预识别所有未识别的种子
|
||||
processed_torrents: Dict[str, List[Context]] = {}
|
||||
for domain, contexts in torrents.items():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
processed_torrents[domain] = []
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 如果种子未识别,尝试识别
|
||||
if not context.media_info or (not context.media_info.tmdb_id
|
||||
and not context.media_info.douban_id):
|
||||
re_mediainfo = self.recognize_media(meta=context.meta_info)
|
||||
if re_mediainfo:
|
||||
# 清理多余信息
|
||||
re_mediainfo.clear()
|
||||
# 更新种子缓存
|
||||
context.media_info = re_mediainfo
|
||||
# 添加已预处理
|
||||
processed_torrents[domain].append(context)
|
||||
|
||||
# 遍历订阅
|
||||
for subscribe in subscribes:
|
||||
if global_vars.is_system_stopped:
|
||||
@@ -578,7 +594,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
# 订阅的站点域名列表
|
||||
domains = []
|
||||
if subscribe.sites:
|
||||
domains = self.siteoper.get_domains_by_ids(subscribe.sites)
|
||||
domains = SiteOper().get_domains_by_ids(subscribe.sites)
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(meta=meta, mtype=meta.type,
|
||||
tmdbid=subscribe.tmdbid,
|
||||
@@ -597,23 +613,31 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
if exist_flag:
|
||||
continue
|
||||
|
||||
# 清理多余信息
|
||||
mediainfo.clear()
|
||||
|
||||
# 订阅识别词
|
||||
if subscribe.custom_words:
|
||||
custom_words_list = subscribe.custom_words.split("\n")
|
||||
else:
|
||||
custom_words_list = None
|
||||
|
||||
# 遍历缓存种子
|
||||
# 遍历预识别后的种子
|
||||
_match_context = []
|
||||
for domain, contexts in torrents.items():
|
||||
torrenthelper = TorrentHelper()
|
||||
systemconfig = SystemConfigOper()
|
||||
wordsmatcher = WordsMatcher()
|
||||
for domain, contexts in processed_torrents.items():
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if domains and domain not in domains:
|
||||
continue
|
||||
logger.debug(f'开始匹配站点:{domain},共缓存了 {len(contexts)} 个种子...')
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
# 提取信息
|
||||
_context = copy.deepcopy(context)
|
||||
_context = copy.copy(context)
|
||||
torrent_meta = _context.meta_info
|
||||
torrent_mediainfo = _context.media_info
|
||||
torrent_info = _context.torrent_info
|
||||
@@ -627,8 +651,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
# 有自定义识别词时,需要判断是否需要重新识别
|
||||
if custom_words_list:
|
||||
# 使用org_string,应用一次后理论上不能再次应用
|
||||
_, apply_words = WordsMatcher().prepare(torrent_meta.org_string,
|
||||
custom_words=custom_words_list)
|
||||
_, apply_words = wordsmatcher.prepare(torrent_meta.org_string,
|
||||
custom_words=custom_words_list)
|
||||
if apply_words:
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 因订阅存在自定义识别词,重新识别元数据...')
|
||||
@@ -636,33 +660,31 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
torrent_meta = MetaInfo(title=torrent_info.title, subtitle=torrent_info.description,
|
||||
custom_words=custom_words_list)
|
||||
# 更新元数据缓存
|
||||
context.meta_info = torrent_meta
|
||||
# 媒体信息需要重新识别
|
||||
torrent_mediainfo = None
|
||||
_context.meta_info = torrent_meta
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
|
||||
episode_group=subscribe.episode_group)
|
||||
if torrent_mediainfo:
|
||||
# 清理多余信息
|
||||
torrent_mediainfo.clear()
|
||||
# 更新种子缓存
|
||||
_context.media_info = torrent_mediainfo
|
||||
|
||||
# 先判断是否有没识别的种子,否则重新识别
|
||||
if not torrent_mediainfo \
|
||||
or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
# 重新识别媒体信息
|
||||
torrent_mediainfo = self.recognize_media(meta=torrent_meta,
|
||||
episode_group=subscribe.episode_group)
|
||||
if torrent_mediainfo:
|
||||
# 如果仍然没有识别到媒体信息,尝试标题匹配
|
||||
if not torrent_mediainfo or (not torrent_mediainfo.tmdb_id and not torrent_mediainfo.douban_id):
|
||||
logger.info(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
torrent_mediainfo = mediainfo
|
||||
# 更新种子缓存
|
||||
context.media_info = torrent_mediainfo
|
||||
_context.media_info = mediainfo
|
||||
else:
|
||||
# 通过标题匹配兜底
|
||||
logger.warn(
|
||||
f'{torrent_info.site_name} - {torrent_info.title} 重新识别失败,尝试通过标题匹配...')
|
||||
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
|
||||
torrent_meta=torrent_meta,
|
||||
torrent=torrent_info):
|
||||
# 匹配成功
|
||||
logger.info(
|
||||
f'{mediainfo.title_year} 通过标题匹配到可选资源:{torrent_info.site_name} - {torrent_info.title}')
|
||||
torrent_mediainfo = mediainfo
|
||||
context.media_info = torrent_mediainfo
|
||||
else:
|
||||
continue
|
||||
continue
|
||||
|
||||
# 直接比对媒体信息
|
||||
if torrent_mediainfo and (torrent_mediainfo.tmdb_id or torrent_mediainfo.douban_id):
|
||||
@@ -718,17 +740,17 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
continue
|
||||
|
||||
# 匹配订阅附加参数
|
||||
if not self.torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
filter_params=self.get_params(subscribe)):
|
||||
if not torrenthelper.filter_torrent(torrent_info=torrent_info,
|
||||
filter_params=self.get_params(subscribe)):
|
||||
continue
|
||||
|
||||
# 优先级过滤规则
|
||||
if subscribe.best_version:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or self.systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
|
||||
or systemconfig.get(SystemConfigKey.BestVersionFilterRuleGroups)
|
||||
else:
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or self.systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
|
||||
or systemconfig.get(SystemConfigKey.SubscribeFilterRuleGroups)
|
||||
result: List[TorrentInfo] = self.filter_torrents(
|
||||
rule_groups=rule_groups,
|
||||
torrent_list=[torrent_info],
|
||||
@@ -764,22 +786,22 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 开始批量择优下载
|
||||
logger.info(f'{mediainfo.title_year} 匹配完成,共匹配到{len(_match_context)}个资源')
|
||||
downloads, lefts = self.downloadchain.batch_download(contexts=_match_context,
|
||||
no_exists=no_exists,
|
||||
userid=subscribe.username,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
downloads, lefts = DownloadChain().batch_download(contexts=_match_context,
|
||||
no_exists=no_exists,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe)
|
||||
)
|
||||
|
||||
# 同步外部修改,更新订阅信息
|
||||
subscribe = self.subscribeoper.get(subscribe.id)
|
||||
subscribe = SubscribeOper().get(subscribe.id)
|
||||
|
||||
# 判断是否要完成订阅
|
||||
if subscribe:
|
||||
self.finish_subscribe_or_not(subscribe=subscribe, meta=meta, mediainfo=mediainfo,
|
||||
downloads=downloads, lefts=lefts)
|
||||
|
||||
logger.debug(f"match Lock released at {datetime.now()}")
|
||||
|
||||
def check(self):
|
||||
@@ -787,7 +809,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
定时检查订阅,更新订阅信息
|
||||
"""
|
||||
# 查询所有订阅
|
||||
subscribes = self.subscribeoper.list()
|
||||
subscribeoper = SubscribeOper()
|
||||
subscribes = subscribeoper.list()
|
||||
if not subscribes:
|
||||
# 没有订阅不运行
|
||||
return
|
||||
@@ -826,7 +849,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
total_episode = subscribe.total_episode
|
||||
lack_episode = subscribe.lack_episode
|
||||
# 更新TMDB信息
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
subscribeoper.update(subscribe.id, {
|
||||
"name": mediainfo.title,
|
||||
"year": mediainfo.year,
|
||||
"vote": mediainfo.vote_average,
|
||||
@@ -840,28 +863,32 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
})
|
||||
logger.info(f'{subscribe.name} 订阅元数据更新完成')
|
||||
|
||||
def follow(self):
|
||||
@staticmethod
|
||||
def follow():
|
||||
"""
|
||||
刷新follow的用户分享,并自动添加订阅
|
||||
"""
|
||||
follow_users: List[str] = self.systemconfig.get(SystemConfigKey.FollowSubscribers)
|
||||
follow_users: List[str] = SystemConfigOper().get(SystemConfigKey.FollowSubscribers)
|
||||
if not follow_users:
|
||||
return
|
||||
share_subs = self.subscribehelper.get_shares()
|
||||
share_subs = SubscribeHelper().get_shares()
|
||||
logger.info(f'开始刷新follow用户分享订阅 ...')
|
||||
success_count = 0
|
||||
subscribeoper = SubscribeOper()
|
||||
for share_sub in share_subs:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
uid = share_sub.get("share_uid")
|
||||
if uid and uid in follow_users:
|
||||
# 订阅已存在则跳过
|
||||
if self.subscribeoper.exists(tmdbid=share_sub.get("tmdbid"),
|
||||
doubanid=share_sub.get("doubanid"),
|
||||
season=share_sub.get("season")):
|
||||
if subscribeoper.exists(tmdbid=share_sub.get("tmdbid"),
|
||||
doubanid=share_sub.get("doubanid"),
|
||||
season=share_sub.get("season")):
|
||||
continue
|
||||
# 已经订阅过跳过
|
||||
if self.subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"),
|
||||
doubanid=share_sub.get("doubanid"),
|
||||
season=share_sub.get("season")):
|
||||
if subscribeoper.exist_history(tmdbid=share_sub.get("tmdbid"),
|
||||
doubanid=share_sub.get("doubanid"),
|
||||
season=share_sub.get("season")):
|
||||
continue
|
||||
# 去除无效属性
|
||||
for key in list(share_sub.keys()):
|
||||
@@ -902,7 +929,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
logger.error(f'follow用户分享订阅 {title} 添加失败:{message}')
|
||||
logger.info(f'follow用户分享订阅刷新完成,共添加 {success_count} 个订阅')
|
||||
|
||||
def __update_subscribe_note(self, subscribe: Subscribe, downloads: Optional[List[Context]]):
|
||||
@staticmethod
|
||||
def __update_subscribe_note(subscribe: Subscribe, downloads: Optional[List[Context]]):
|
||||
"""
|
||||
更新已下载信息到note字段
|
||||
"""
|
||||
@@ -934,7 +962,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
note = list(set(note).union(set(items)))
|
||||
# 更新订阅
|
||||
if note:
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
SubscribeOper().update(subscribe.id, {
|
||||
"note": note
|
||||
})
|
||||
|
||||
@@ -958,7 +986,8 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
return note
|
||||
return []
|
||||
|
||||
def __update_lack_episodes(self, lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||
@staticmethod
|
||||
def __update_lack_episodes(lefts: Dict[Union[int, str], Dict[int, schemas.NotExistMediaInfo]],
|
||||
subscribe: Subscribe,
|
||||
mediainfo: MediaInfo,
|
||||
update_date: Optional[bool] = False):
|
||||
@@ -991,7 +1020,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
update_data["lack_episode"] = lack_episode
|
||||
# 更新数据库
|
||||
if update_data:
|
||||
self.subscribeoper.update(subscribe.id, update_data)
|
||||
SubscribeOper().update(subscribe.id, update_data)
|
||||
|
||||
def __finish_subscribe(self, subscribe: Subscribe, mediainfo: MediaInfo, meta: MetaBase):
|
||||
"""
|
||||
@@ -1004,20 +1033,29 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
msgstr = "订阅" if not subscribe.best_version else "洗版"
|
||||
logger.info(f'{mediainfo.title_year} 完成{msgstr}')
|
||||
# 新增订阅历史
|
||||
self.subscribeoper.add_history(**subscribe.to_dict())
|
||||
subscribeoper = SubscribeOper()
|
||||
subscribeoper.add_history(**subscribe.to_dict())
|
||||
# 删除订阅
|
||||
self.subscribeoper.delete(subscribe.id)
|
||||
subscribeoper.delete(subscribe.id)
|
||||
# 发送通知
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 完成订阅按规则发送消息
|
||||
self.post_message(schemas.Notification(mtype=NotificationType.Subscribe,
|
||||
title=f'{mediainfo.title_year} {meta.season} 已完成{msgstr}',
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=subscribe.username))
|
||||
self.post_message(
|
||||
schemas.Notification(
|
||||
mtype=NotificationType.Subscribe,
|
||||
ctype=ContentType.SubscribeComplete,
|
||||
image=mediainfo.get_message_image(),
|
||||
link=link,
|
||||
username=subscribe.username
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
msgstr=msgstr,
|
||||
username=subscribe.username
|
||||
)
|
||||
# 发送事件
|
||||
EventManager().send_event(EventType.SubscribeComplete, {
|
||||
"subscribe_id": subscribe.id,
|
||||
@@ -1025,7 +1063,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"mediainfo": mediainfo.to_dict(),
|
||||
})
|
||||
# 统计订阅
|
||||
self.subscribehelper.sub_done_async({
|
||||
SubscribeHelper().sub_done_async({
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
"doubanid": mediainfo.douban_id
|
||||
})
|
||||
@@ -1035,7 +1073,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
查询订阅并发送消息
|
||||
"""
|
||||
subscribes = self.subscribeoper.list()
|
||||
subscribes = SubscribeOper().list()
|
||||
if not subscribes:
|
||||
self.post_message(schemas.Notification(channel=channel,
|
||||
source=source,
|
||||
@@ -1069,20 +1107,22 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
"[id]为订阅编号", userid=userid))
|
||||
return
|
||||
arg_strs = str(arg_str).split()
|
||||
subscribeoper = SubscribeOper()
|
||||
subscribehelper = SubscribeHelper()
|
||||
for arg_str in arg_strs:
|
||||
arg_str = arg_str.strip()
|
||||
if not arg_str.isdigit():
|
||||
continue
|
||||
subscribe_id = int(arg_str)
|
||||
subscribe = self.subscribeoper.get(subscribe_id)
|
||||
subscribe = subscribeoper.get(subscribe_id)
|
||||
if not subscribe:
|
||||
self.post_message(schemas.Notification(channel=channel, source=source,
|
||||
title=f"订阅编号 {subscribe_id} 不存在!", userid=userid))
|
||||
return
|
||||
# 删除订阅
|
||||
self.subscribeoper.delete(subscribe_id)
|
||||
subscribeoper.delete(subscribe_id)
|
||||
# 统计订阅
|
||||
self.subscribehelper.sub_done_async({
|
||||
subscribehelper.sub_done_async({
|
||||
"tmdbid": subscribe.tmdbid,
|
||||
"doubanid": subscribe.doubanid
|
||||
})
|
||||
@@ -1146,6 +1186,9 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
new_episodes = list(range(max(start_episode, start), total_episode + 1))
|
||||
# 与原集列表取交集
|
||||
episodes = list(set(episode_list).intersection(set(new_episodes)))
|
||||
# 交集为空时,说明订阅的剧集均已入库
|
||||
if not episodes:
|
||||
return True, {}
|
||||
# 更新集合
|
||||
no_exists[mediakey][begin_season] = schemas.NotExistMediaInfo(
|
||||
season=begin_season,
|
||||
@@ -1208,13 +1251,14 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
site_id = event_data.get("site_id")
|
||||
if not site_id:
|
||||
return
|
||||
subscribeoper = SubscribeOper()
|
||||
if site_id == "*":
|
||||
# 站点被重置
|
||||
SystemConfigOper().set(SystemConfigKey.RssSites, [])
|
||||
for subscribe in self.subscribeoper.list():
|
||||
for subscribe in subscribeoper.list():
|
||||
if not subscribe.sites:
|
||||
continue
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
subscribeoper.update(subscribe.id, {
|
||||
"sites": []
|
||||
})
|
||||
return
|
||||
@@ -1224,14 +1268,14 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
selected_sites.remove(site_id)
|
||||
SystemConfigOper().set(SystemConfigKey.RssSites, selected_sites)
|
||||
# 查询所有订阅
|
||||
for subscribe in self.subscribeoper.list():
|
||||
for subscribe in subscribeoper.list():
|
||||
if not subscribe.sites:
|
||||
continue
|
||||
sites = subscribe.sites or []
|
||||
if site_id not in sites:
|
||||
continue
|
||||
sites.remove(site_id)
|
||||
self.subscribeoper.update(subscribe.id, {
|
||||
subscribeoper.update(subscribe.id, {
|
||||
"sites": sites
|
||||
})
|
||||
|
||||
@@ -1256,12 +1300,13 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
return None
|
||||
return value.get(default_config_key) or None
|
||||
|
||||
def get_params(self, subscribe: Subscribe):
|
||||
@staticmethod
|
||||
def get_params(subscribe: Subscribe):
|
||||
"""
|
||||
获取订阅默认参数
|
||||
"""
|
||||
# 默认过滤规则
|
||||
default_rule = self.systemconfig.get(SystemConfigKey.SubscribeDefaultParams) or {}
|
||||
default_rule = SystemConfigOper().get(SystemConfigKey.SubscribeDefaultParams) or {}
|
||||
return {
|
||||
key: value for key, value in {
|
||||
"include": subscribe.include or default_rule.get("include"),
|
||||
@@ -1289,7 +1334,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
episodes: Dict[int, schemas.SubscribeEpisodeInfo] = {}
|
||||
if subscribe.tmdbid and subscribe.type == MediaType.TV.value:
|
||||
# 查询TMDB中的集信息
|
||||
tmdb_episodes = self.tmdbchain.tmdb_episodes(
|
||||
tmdb_episodes = TmdbChain().tmdb_episodes(
|
||||
tmdbid=subscribe.tmdbid,
|
||||
season=subscribe.season,
|
||||
episode_group=subscribe.episode_group
|
||||
@@ -1314,11 +1359,12 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
episodes[0] = info
|
||||
|
||||
# 所有下载记录
|
||||
download_his = self.downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
|
||||
downloadhis = DownloadHistoryOper()
|
||||
download_his = downloadhis.get_by_mediaid(tmdbid=subscribe.tmdbid, doubanid=subscribe.doubanid)
|
||||
if download_his:
|
||||
for his in download_his:
|
||||
# 查询下载文件
|
||||
files = self.downloadhis.get_files_by_hash(his.download_hash)
|
||||
files = downloadhis.get_files_by_hash(his.download_hash)
|
||||
if files:
|
||||
for file in files:
|
||||
# 识别文件名
|
||||
@@ -1412,7 +1458,7 @@ class SubscribeChain(ChainBase, metaclass=Singleton):
|
||||
subscribe.season: subscribe.total_episode
|
||||
}
|
||||
# 查询媒体库缺失的媒体信息
|
||||
exist_flag, no_exists = self.downloadchain.get_no_exists_info(
|
||||
exist_flag, no_exists = DownloadChain().get_no_exists_info(
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
totals=totals
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional
|
||||
|
||||
@@ -8,23 +9,18 @@ from app.core.config import settings
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, MessageChannel
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.system import SystemUtils
|
||||
from app.helper.system import SystemHelper
|
||||
from version import FRONTEND_VERSION, APP_VERSION
|
||||
|
||||
|
||||
class SystemChain(ChainBase, metaclass=Singleton):
|
||||
class SystemChain(ChainBase):
|
||||
"""
|
||||
系统级处理链
|
||||
"""
|
||||
|
||||
_restart_file = "__system_restart__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 重启完成检测
|
||||
self.restart_finish()
|
||||
|
||||
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
|
||||
"""
|
||||
清理系统缓存
|
||||
@@ -37,6 +33,8 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
重启系统
|
||||
"""
|
||||
from app.core.config import global_vars
|
||||
|
||||
if channel and userid:
|
||||
self.post_message(Notification(channel=channel, source=source,
|
||||
title="系统正在重启,请耐心等候!", userid=userid))
|
||||
@@ -45,7 +43,116 @@ class SystemChain(ChainBase, metaclass=Singleton):
|
||||
"channel": channel.value,
|
||||
"userid": userid
|
||||
}, self._restart_file)
|
||||
SystemUtils.restart()
|
||||
# 主动备份一次插件
|
||||
self.backup_plugins()
|
||||
# 设置停止标志,通知所有模块准备停止
|
||||
global_vars.stop_system()
|
||||
# 重启
|
||||
SystemHelper.restart()
|
||||
|
||||
@staticmethod
|
||||
def backup_plugins():
|
||||
"""
|
||||
备份插件到用户配置目录(仅docker环境)
|
||||
"""
|
||||
|
||||
# 非docker环境不处理
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
|
||||
try:
|
||||
# 使用绝对路径确保准确性
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
backup_dir = settings.CONFIG_PATH / "plugins_backup"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
logger.info("插件目录不存在,跳过备份")
|
||||
return
|
||||
|
||||
# 确保备份目录存在
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 需要排除的文件和目录
|
||||
exclude_items = {"__init__.py", "__pycache__", ".DS_Store"}
|
||||
|
||||
# 遍历插件目录,备份除排除项外的所有内容
|
||||
for item in plugins_dir.iterdir():
|
||||
if item.name in exclude_items:
|
||||
continue
|
||||
|
||||
target_path = backup_dir / item.name
|
||||
|
||||
# 如果是目录
|
||||
if item.is_dir():
|
||||
if target_path.exists():
|
||||
continue
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已备份插件目录: {item.name}")
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
if target_path.exists():
|
||||
continue
|
||||
shutil.copy2(item, target_path)
|
||||
logger.info(f"已备份插件文件: {item.name}")
|
||||
|
||||
logger.info(f"插件备份完成,备份位置: {backup_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"插件备份失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def restore_plugins():
|
||||
"""
|
||||
从备份恢复插件到app/plugins目录,恢复完成后删除备份(仅docker环境)
|
||||
"""
|
||||
|
||||
# 非docker环境不处理
|
||||
if not SystemUtils.is_docker():
|
||||
return
|
||||
|
||||
# 使用绝对路径确保准确性
|
||||
plugins_dir = settings.ROOT_PATH / "app" / "plugins"
|
||||
backup_dir = settings.CONFIG_PATH / "plugins_backup"
|
||||
|
||||
if not backup_dir.exists():
|
||||
logger.info("插件备份目录不存在,跳过恢复")
|
||||
return
|
||||
|
||||
# 系统被重置才恢复插件
|
||||
if SystemHelper().is_system_reset():
|
||||
|
||||
# 确保插件目录存在
|
||||
plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 遍历备份目录,恢复所有内容
|
||||
restored_count = 0
|
||||
for item in backup_dir.iterdir():
|
||||
target_path = plugins_dir / item.name
|
||||
try:
|
||||
# 如果是目录,且目录内有内容
|
||||
if item.is_dir() and any(item.iterdir()):
|
||||
if target_path.exists():
|
||||
shutil.rmtree(target_path)
|
||||
shutil.copytree(item, target_path)
|
||||
logger.info(f"已恢复插件目录: {item.name}")
|
||||
restored_count += 1
|
||||
# 如果是文件
|
||||
elif item.is_file():
|
||||
shutil.copy2(item, target_path)
|
||||
logger.info(f"已恢复插件文件: {item.name}")
|
||||
restored_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"恢复插件 {item.name} 时发生错误: {str(e)}")
|
||||
continue
|
||||
|
||||
logger.info(f"插件恢复完成,共恢复 {restored_count} 个项目")
|
||||
|
||||
# 删除备份目录
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
logger.info(f"已删除插件备份目录: {backup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"删除备份目录失败: {str(e)}")
|
||||
|
||||
def __get_version_message(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -3,13 +3,11 @@ from typing import Optional, List
|
||||
|
||||
from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.core.cache import cached
|
||||
from app.core.context import MediaInfo
|
||||
from app.schemas import MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
class TmdbChain(ChainBase):
|
||||
"""
|
||||
TheMovieDB处理链,单例运行
|
||||
"""
|
||||
@@ -145,7 +143,6 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_random_wallpager(self) -> Optional[str]:
|
||||
"""
|
||||
获取随机壁纸,缓存1个小时
|
||||
@@ -159,7 +156,6 @@ class TmdbChain(ChainBase, metaclass=Singleton):
|
||||
return info.backdrop_path
|
||||
return None
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:
|
||||
"""
|
||||
获取所有流行壁纸
|
||||
|
||||
@@ -2,8 +2,6 @@ import re
|
||||
import traceback
|
||||
from typing import Dict, List, Union, Optional
|
||||
|
||||
from cachetools import cached, TTLCache
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.core.config import settings, global_vars
|
||||
@@ -17,11 +15,10 @@ from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification
|
||||
from app.schemas.types import SystemConfigKey, MessageChannel, NotificationType, MediaType
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
class TorrentsChain(ChainBase):
|
||||
"""
|
||||
站点首页或RSS种子处理链,服务于订阅、刷流等
|
||||
"""
|
||||
@@ -29,14 +26,14 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
_spider_file = "__torrents_cache__"
|
||||
_rss_file = "__rss_cache__"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.siteshelper = SitesHelper()
|
||||
self.siteoper = SiteOper()
|
||||
self.rsshelper = RssHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.mediachain = MediaChain()
|
||||
self.torrenthelper = TorrentHelper()
|
||||
@property
|
||||
def cache_file(self) -> str:
|
||||
"""
|
||||
返回缓存文件列表
|
||||
"""
|
||||
if settings.SUBSCRIBE_MODE == 'spider':
|
||||
return self._spider_file
|
||||
return self._rss_file
|
||||
|
||||
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
|
||||
"""
|
||||
@@ -72,39 +69,37 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
self.remove_cache(self._rss_file)
|
||||
logger.info(f'种子缓存数据清理完成')
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=595))
|
||||
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存10分钟
|
||||
浏览站点首页内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
:param keyword: 搜索标题
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
site = SitesHelper().get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
|
||||
@cached(cache=TTLCache(maxsize=128, ttl=295))
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点RSS内容,返回种子清单,TTL缓存5分钟
|
||||
获取站点RSS内容,返回种子清单,TTL缓存3分钟
|
||||
:param domain: 站点域名
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} RSS ...')
|
||||
site = self.siteshelper.get_indexer(domain)
|
||||
site = SitesHelper().get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
if not site.get("rss"):
|
||||
logger.error(f'站点 {domain} 未配置RSS地址!')
|
||||
return []
|
||||
rss_items = self.rsshelper.parse(site.get("rss"), True if site.get("proxy") else False,
|
||||
timeout=int(site.get("timeout") or 30))
|
||||
rss_items = RssHelper().parse(site.get("rss"), True if site.get("proxy") else False,
|
||||
timeout=int(site.get("timeout") or 30))
|
||||
if rss_items is None:
|
||||
# rss过期,尝试保留原配置生成新的rss
|
||||
self.__renew_rss_url(domain=domain, site=site)
|
||||
@@ -147,7 +142,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 刷新站点
|
||||
if not sites:
|
||||
sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
|
||||
sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
|
||||
|
||||
# 读取缓存
|
||||
torrents_cache = self.get_torrents()
|
||||
@@ -155,12 +150,13 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 缓存过滤掉无效种子
|
||||
for _domain, _torrents in torrents_cache.items():
|
||||
torrents_cache[_domain] = [_torrent for _torrent in _torrents
|
||||
if not self.torrenthelper.is_invalid(_torrent.torrent_info.enclosure)]
|
||||
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
|
||||
|
||||
# 所有站点索引
|
||||
indexers = self.siteshelper.get_indexers()
|
||||
indexers = SitesHelper().get_indexers()
|
||||
# 需要刷新的站点domain
|
||||
domains = []
|
||||
|
||||
# 遍历站点缓存资源
|
||||
for indexer in indexers:
|
||||
if global_vars.is_system_stopped:
|
||||
@@ -179,13 +175,13 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 按pubdate降序排列
|
||||
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
|
||||
# 取前N条
|
||||
torrents = torrents[:settings.CACHE_CONF["refresh"]]
|
||||
torrents = torrents[:settings.CONF["refresh"]]
|
||||
if torrents:
|
||||
# 过滤出没有处理过的种子
|
||||
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
|
||||
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []}
|
||||
torrents = [torrent for torrent in torrents
|
||||
if f'{torrent.title}{torrent.description}'
|
||||
not in [f'{t.torrent_info.title}{t.torrent_info.description}'
|
||||
for t in torrents_cache.get(domain) or []]]
|
||||
if f'{torrent.title}{torrent.description}' not in cached_signatures]
|
||||
if torrents:
|
||||
logger.info(f'{indexer.get("name")} 有 {len(torrents)} 个新种子')
|
||||
else:
|
||||
@@ -204,12 +200,12 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_by_meta(meta)
|
||||
mediainfo: MediaInfo = MediaChain().recognize_by_meta(meta)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据
|
||||
# 清理多余数据,减少内存占用
|
||||
mediainfo.clear()
|
||||
# 上下文
|
||||
context = Context(meta_info=meta, media_info=mediainfo, torrent_info=torrent)
|
||||
@@ -219,10 +215,8 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
else:
|
||||
torrents_cache[domain].append(context)
|
||||
# 如果超过了限制条数则移除掉前面的
|
||||
if len(torrents_cache[domain]) > settings.CACHE_CONF["torrents"]:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF["torrents"]:]
|
||||
# 回收资源
|
||||
del torrents
|
||||
if len(torrents_cache[domain]) > settings.CONF["torrents"]:
|
||||
torrents_cache[domain] = torrents_cache[domain][-settings.CONF["torrents"]:]
|
||||
else:
|
||||
logger.info(f'{indexer.get("name")} 没有获取到种子')
|
||||
|
||||
@@ -235,6 +229,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 去除不在站点范围内的缓存种子
|
||||
if sites and torrents_cache:
|
||||
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
|
||||
|
||||
return torrents_cache
|
||||
|
||||
def __renew_rss_url(self, domain: str, site: dict):
|
||||
@@ -245,7 +240,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# RSS链接过期
|
||||
logger.error(f"站点 {domain} RSS链接已过期,正在尝试自动获取!")
|
||||
# 自动生成rss地址
|
||||
rss_url, errmsg = self.rsshelper.get_rss_link(
|
||||
rss_url, errmsg = RssHelper().get_rss_link(
|
||||
url=site.get("url"),
|
||||
cookie=site.get("cookie"),
|
||||
ua=site.get("ua") or settings.USER_AGENT,
|
||||
@@ -259,7 +254,7 @@ class TorrentsChain(ChainBase, metaclass=Singleton):
|
||||
# 获取过期rss除去passkey部分
|
||||
new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss"))
|
||||
logger.info(f"更新站点 {domain} RSS地址 ...")
|
||||
self.siteoper.update_rss(domain=domain, rss=new_rss)
|
||||
SiteOper().update_rss(domain=domain, rss=new_rss)
|
||||
else:
|
||||
# 发送消息
|
||||
self.post_message(
|
||||
|
||||
245
app/chain/transfer.py
Normal file → Executable file
245
app/chain/transfer.py
Normal file → Executable file
@@ -17,6 +17,7 @@ from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
from app.core.event import eventmanager
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.downloadhistory import DownloadHistory
|
||||
from app.db.models.transferhistory import TransferHistory
|
||||
@@ -29,7 +30,8 @@ from app.log import logger
|
||||
from app.schemas import TransferInfo, TransferTorrent, Notification, EpisodeFormat, FileItem, TransferDirectoryConf, \
|
||||
TransferTask, TransferQueue, TransferJob, TransferJobTask
|
||||
from app.schemas.types import TorrentStatus, EventType, MediaType, ProgressKey, NotificationType, MessageChannel, \
|
||||
SystemConfigKey
|
||||
SystemConfigKey, ChainEventType, ContentType
|
||||
from app.schemas import StorageOperSelectionEventData
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -326,7 +328,8 @@ class JobManager:
|
||||
# 计算状态为完成的任务数
|
||||
if __mediaid__ not in self._job_view:
|
||||
return 0
|
||||
return sum([task.fileitem.size for task in self._job_view[__mediaid__].tasks if task.state == "completed" and task.fileitem.size is not None])
|
||||
return sum([task.fileitem.size for task in self._job_view[__mediaid__].tasks if
|
||||
task.state == "completed" and task.fileitem.size is not None])
|
||||
|
||||
def total(self) -> int:
|
||||
"""
|
||||
@@ -369,14 +372,6 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.downloadhis = DownloadHistoryOper()
|
||||
self.transferhis = TransferHistoryOper()
|
||||
self.progress = ProgressHelper()
|
||||
self.mediachain = MediaChain()
|
||||
self.tmdbchain = TmdbChain()
|
||||
self.storagechain = StorageChain()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.directoryhelper = DirectoryHelper()
|
||||
self.jobview = JobManager()
|
||||
|
||||
# 启动整理任务
|
||||
@@ -395,11 +390,12 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
整理完成后处理
|
||||
"""
|
||||
transferhis = TransferHistoryOper()
|
||||
if not transferinfo.success:
|
||||
# 转移失败
|
||||
logger.warn(f"{task.fileitem.name} 入库失败:{transferinfo.message}")
|
||||
# 新增转移失败历史记录
|
||||
self.transferhis.add_fail(
|
||||
transferhis.add_fail(
|
||||
fileitem=task.fileitem,
|
||||
mode=transferinfo.transfer_type if transferinfo else '',
|
||||
downloader=task.downloader,
|
||||
@@ -426,7 +422,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
logger.info(f"{task.fileitem.name} 入库成功:{transferinfo.target_diritem.path}")
|
||||
|
||||
# 新增转移成功历史记录
|
||||
self.transferhis.add_success(
|
||||
transferhis.add_success(
|
||||
fileitem=task.fileitem,
|
||||
mode=transferinfo.transfer_type if transferinfo else '',
|
||||
downloader=task.downloader,
|
||||
@@ -455,6 +451,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
tasks = self.jobview.success_tasks(task.mediainfo, task.meta.begin_season)
|
||||
# 记录已处理的种子hash
|
||||
processed_hashes = set()
|
||||
storagechain = StorageChain()
|
||||
for t in tasks:
|
||||
# 下载器hash
|
||||
if t.download_hash and t.download_hash not in processed_hashes:
|
||||
@@ -463,7 +460,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
logger.info(f"移动模式删除种子成功:{t.download_hash} ")
|
||||
# 删除残留目录
|
||||
if t.fileitem:
|
||||
self.storagechain.delete_media_file(t.fileitem, delete_self=False)
|
||||
storagechain.delete_media_file(t.fileitem, delete_self=False)
|
||||
# 整理完成且有成功的任务时
|
||||
if self.jobview.is_finished(task):
|
||||
# 发送通知,实时手动整理时不发
|
||||
@@ -541,6 +538,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 失败数量
|
||||
fail_num = 0
|
||||
|
||||
progress = ProgressHelper()
|
||||
|
||||
while not global_vars.is_system_stopped:
|
||||
try:
|
||||
item: TransferQueue = self._queue.get(block=False)
|
||||
@@ -554,24 +553,24 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
if __queue_start:
|
||||
logger.info("开始整理队列处理...")
|
||||
# 启动进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
progress.start(ProgressKey.FileTransfer)
|
||||
# 重置计数
|
||||
processed_num = 0
|
||||
fail_num = 0
|
||||
total_num = self.jobview.total()
|
||||
__process_msg = f"开始整理队列处理,当前共 {total_num} 个文件 ..."
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=0,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.update(value=0,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 队列已开始
|
||||
__queue_start = False
|
||||
# 更新进度
|
||||
__process_msg = f"正在整理 {fileitem.name} ..."
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.update(value=processed_num / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 整理
|
||||
state, err_msg = self.__handle_transfer(task=task, callback=item.callback)
|
||||
if not state:
|
||||
@@ -581,18 +580,18 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
processed_num += 1
|
||||
__process_msg = f"{fileitem.name} 整理完成"
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=processed_num / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.update(value=processed_num / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
except queue.Empty:
|
||||
if not __queue_start:
|
||||
# 结束进度
|
||||
__end_msg = f"整理队列处理完成,共整理 {processed_num} 个文件,失败 {fail_num} 个"
|
||||
logger.info(__end_msg)
|
||||
self.progress.update(value=100,
|
||||
text=__end_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
progress.update(value=100,
|
||||
text=__end_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.end(ProgressKey.FileTransfer)
|
||||
# 重置计数
|
||||
processed_num = 0
|
||||
fail_num = 0
|
||||
@@ -612,6 +611,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
try:
|
||||
# 识别
|
||||
transferhis = TransferHistoryOper()
|
||||
if not task.mediainfo:
|
||||
mediainfo = None
|
||||
download_history = task.download_history
|
||||
@@ -631,7 +631,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
mediainfo.category = download_history.media_category
|
||||
else:
|
||||
# 识别媒体信息
|
||||
mediainfo = self.mediachain.recognize_by_meta(task.meta)
|
||||
mediainfo = MediaChain().recognize_by_meta(task.meta)
|
||||
|
||||
# 更新媒体图片
|
||||
if mediainfo:
|
||||
@@ -639,7 +639,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
if not mediainfo:
|
||||
# 新增整理失败历史记录
|
||||
his = self.transferhis.add_fail(
|
||||
his = transferhis.add_fail(
|
||||
fileitem=task.fileitem,
|
||||
mode=task.transfer_type,
|
||||
meta=task.meta,
|
||||
@@ -659,8 +659,8 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 如果未开启新增已入库媒体是否跟随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)
|
||||
transfer_history = transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id,
|
||||
mtype=mediainfo.type.value)
|
||||
if transfer_history:
|
||||
mediainfo.title = transfer_history.title
|
||||
|
||||
@@ -680,7 +680,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 默认值1
|
||||
if season_num is None:
|
||||
season_num = 1
|
||||
task.episodes_info = self.tmdbchain.tmdb_episodes(
|
||||
task.episodes_info = TmdbChain().tmdb_episodes(
|
||||
tmdbid=task.mediainfo.tmdb_id,
|
||||
season=season_num,
|
||||
episode_group=task.mediainfo.episode_group
|
||||
@@ -690,19 +690,45 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
if not task.target_directory:
|
||||
if task.target_path:
|
||||
# 指定目标路径,`手动整理`场景下使用,忽略源目录匹配,使用指定目录匹配
|
||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||
dest_path=task.target_path,
|
||||
target_storage=task.target_storage)
|
||||
task.target_directory = DirectoryHelper().get_dir(media=task.mediainfo,
|
||||
dest_path=task.target_path,
|
||||
target_storage=task.target_storage)
|
||||
else:
|
||||
# 启用源目录匹配时,根据源目录匹配下载目录,否则按源目录同盘优先原则,如无源目录,则根据媒体信息获取目标目录
|
||||
task.target_directory = self.directoryhelper.get_dir(media=task.mediainfo,
|
||||
storage=task.fileitem.storage,
|
||||
src_path=Path(task.fileitem.path),
|
||||
target_storage=task.target_storage)
|
||||
task.target_directory = DirectoryHelper().get_dir(media=task.mediainfo,
|
||||
storage=task.fileitem.storage,
|
||||
src_path=Path(task.fileitem.path),
|
||||
target_storage=task.target_storage)
|
||||
if not task.target_storage and task.target_directory:
|
||||
task.target_storage = task.target_directory.library_storage
|
||||
|
||||
# 正在处理
|
||||
self.jobview.running_task(task)
|
||||
|
||||
# 广播事件,请示额外的源存储支持
|
||||
source_oper = None
|
||||
source_event_data = StorageOperSelectionEventData(
|
||||
storage=task.fileitem.storage,
|
||||
)
|
||||
source_event = eventmanager.send_event(ChainEventType.StorageOperSelection, source_event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if source_event and source_event.event_data:
|
||||
source_event_data: StorageOperSelectionEventData = source_event.event_data
|
||||
if source_event_data.storage_oper:
|
||||
source_oper = source_event_data.storage_oper
|
||||
|
||||
# 广播事件,请示额外的目标存储支持
|
||||
target_oper = None
|
||||
target_event_data = StorageOperSelectionEventData(
|
||||
storage=task.target_storage,
|
||||
)
|
||||
target_event = eventmanager.send_event(ChainEventType.StorageOperSelection, target_event_data)
|
||||
# 使用事件返回的上下文数据
|
||||
if target_event and target_event.event_data:
|
||||
target_event_data: StorageOperSelectionEventData = target_event.event_data
|
||||
if target_event_data.storage_oper:
|
||||
target_oper = target_event_data.storage_oper
|
||||
|
||||
# 执行整理
|
||||
transferinfo: TransferInfo = self.transfer(fileitem=task.fileitem,
|
||||
meta=task.meta,
|
||||
@@ -714,7 +740,9 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
episodes_info=task.episodes_info,
|
||||
scrape=task.scrape,
|
||||
library_type_folder=task.library_type_folder,
|
||||
library_category_folder=task.library_category_folder)
|
||||
library_category_folder=task.library_category_folder,
|
||||
source_oper=source_oper,
|
||||
target_oper=target_oper)
|
||||
if not transferinfo:
|
||||
logger.error("文件整理模块运行失败")
|
||||
return False, "文件整理模块运行失败"
|
||||
@@ -754,7 +782,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 全局锁,避免重复处理
|
||||
with downloader_lock:
|
||||
# 获取下载器监控目录
|
||||
download_dirs = self.directoryhelper.get_download_dirs()
|
||||
download_dirs = DirectoryHelper().get_download_dirs()
|
||||
# 如果没有下载器监控的目录则不处理
|
||||
if not any(dir_info.monitor_type == "downloader" and dir_info.storage == "local"
|
||||
for dir_info in download_dirs):
|
||||
@@ -790,7 +818,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
logger.debug(f"文件 {file_path} 不在下载器监控目录中,不通过下载器进行整理")
|
||||
continue
|
||||
# 查询下载记录识别情况
|
||||
downloadhis: DownloadHistory = self.downloadhis.get_by_hash(torrent.hash)
|
||||
downloadhis: DownloadHistory = DownloadHistoryOper().get_by_hash(torrent.hash)
|
||||
if downloadhis:
|
||||
# 类型
|
||||
try:
|
||||
@@ -829,26 +857,31 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
)
|
||||
|
||||
# 设置下载任务状态
|
||||
if state:
|
||||
self.transfer_completed(hashs=torrent.hash)
|
||||
if not state:
|
||||
logger.warn(f"整理下载器任务失败:{torrent.hash} - {errmsg}")
|
||||
self.transfer_completed(hashs=torrent.hash, downloader=torrent.downloader)
|
||||
|
||||
# 结束
|
||||
logger.info("所有下载器中下载完成的文件已整理完成")
|
||||
return True
|
||||
|
||||
def __get_trans_fileitems(self, fileitem: FileItem) -> List[Tuple[FileItem, bool]]:
|
||||
def __get_trans_fileitems(
|
||||
self, fileitem: FileItem, depth: int = 1
|
||||
) -> List[Tuple[FileItem, bool]]:
|
||||
"""
|
||||
获取整理目录或文件列表
|
||||
|
||||
:param fileitem: 文件项
|
||||
:param depth: 递归深度,默认为1
|
||||
"""
|
||||
storagechain = StorageChain()
|
||||
|
||||
def __is_bluray_dir(_fileitem: FileItem) -> bool:
|
||||
def __contains_bluray_sub(_fileitems: List[FileItem]) -> bool:
|
||||
"""
|
||||
判断是不是蓝光目录
|
||||
判断是否包含蓝光子目录
|
||||
"""
|
||||
subs = self.storagechain.list_files(_fileitem)
|
||||
if subs:
|
||||
for sub in subs:
|
||||
if _fileitems:
|
||||
for sub in _fileitems:
|
||||
if sub.type == "dir" and sub.name in ["BDMV", "CERTIFICATE"]:
|
||||
return True
|
||||
return False
|
||||
@@ -865,10 +898,10 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
"""
|
||||
for p in _path.parents:
|
||||
if p.name == "BDMV":
|
||||
return self.storagechain.get_file_item(storage=_storage, path=p.parent)
|
||||
return storagechain.get_file_item(storage=_storage, path=p.parent)
|
||||
return None
|
||||
|
||||
if not self.storagechain.get_item(fileitem):
|
||||
if not storagechain.get_item(fileitem):
|
||||
logger.warn(f"目录或文件不存在:{fileitem.path}")
|
||||
return []
|
||||
|
||||
@@ -883,25 +916,22 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
return [(fileitem, False)]
|
||||
|
||||
# 蓝光原盘根目录
|
||||
if __is_bluray_dir(fileitem):
|
||||
sub_items = storagechain.list_files(fileitem) or []
|
||||
if __contains_bluray_sub(sub_items):
|
||||
return [(fileitem, True)]
|
||||
|
||||
# 需要整理的文件项列表
|
||||
trans_items = []
|
||||
# 先检查当前目录的下级目录,以支持合集的情况
|
||||
for sub_dir in self.storagechain.list_files(fileitem):
|
||||
for sub_dir in sub_items if depth >= 1 else []:
|
||||
if sub_dir.type == "dir":
|
||||
if __is_bluray_dir(sub_dir):
|
||||
trans_items.append((sub_dir, True))
|
||||
else:
|
||||
trans_items.append((sub_dir, False))
|
||||
trans_items.extend(self.__get_trans_fileitems(sub_dir, depth=depth - 1))
|
||||
|
||||
if not trans_items:
|
||||
# 没有有效子目录,直接整理当前目录
|
||||
trans_items.append((fileitem, False))
|
||||
else:
|
||||
# 有子目录时,把当前目录的文件添加到整理任务中
|
||||
sub_items = self.storagechain.list_files(fileitem)
|
||||
if sub_items:
|
||||
trans_items.extend([(f, False) for f in sub_items if f.type == "file"])
|
||||
|
||||
@@ -963,11 +993,13 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
offset=epformat.offset) if epformat else None
|
||||
|
||||
# 整理屏蔽词
|
||||
transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords)
|
||||
transfer_exclude_words = SystemConfigOper().get(SystemConfigKey.TransferExcludeWords)
|
||||
# 汇总错误信息
|
||||
err_msgs: List[str] = []
|
||||
# 待整理目录或文件项
|
||||
trans_items = self.__get_trans_fileitems(fileitem)
|
||||
trans_items = self.__get_trans_fileitems(
|
||||
fileitem, depth=2 # 为解决 issue#4371 深度至少需要>=2
|
||||
)
|
||||
# 待整理的文件列表
|
||||
file_items: List[Tuple[FileItem, bool]] = []
|
||||
|
||||
@@ -980,7 +1012,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 如果是目录且不是⼀蓝光原盘,获取所有文件并整理
|
||||
if trans_item.type == "dir" and not bluray_dir:
|
||||
# 遍历获取下载目录所有文件(递归)
|
||||
if files := self.storagechain.list_files(trans_item, recursion=True):
|
||||
if files := StorageChain().list_files(trans_item, recursion=True):
|
||||
file_items.extend([(file, False) for file in files])
|
||||
else:
|
||||
file_items.append((trans_item, bluray_dir))
|
||||
@@ -1029,7 +1061,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 整理成功的不再处理
|
||||
if not force:
|
||||
transferd = self.transferhis.get_by_src(file_item.path, storage=file_item.storage)
|
||||
transferd = TransferHistoryOper().get_by_src(file_item.path, storage=file_item.storage)
|
||||
if transferd:
|
||||
if not transferd.status:
|
||||
all_success = False
|
||||
@@ -1065,14 +1097,15 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
|
||||
# 根据父路径获取下载历史
|
||||
download_history = None
|
||||
downloadhis = DownloadHistoryOper()
|
||||
if bluray_dir:
|
||||
# 蓝光原盘,按目录名查询
|
||||
download_history = self.downloadhis.get_by_path(str(file_path))
|
||||
download_history = downloadhis.get_by_path(str(file_path))
|
||||
else:
|
||||
# 按文件全路径查询
|
||||
download_file = self.downloadhis.get_file_by_fullpath(str(file_path))
|
||||
download_file = downloadhis.get_file_by_fullpath(str(file_path))
|
||||
if download_file:
|
||||
download_history = self.downloadhis.get_by_hash(download_file.download_hash)
|
||||
download_history = downloadhis.get_by_hash(download_file.download_hash)
|
||||
|
||||
# 获取下载Hash
|
||||
if download_history and (not downloader or not download_hash):
|
||||
@@ -1115,12 +1148,13 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
fail_num = 0
|
||||
|
||||
# 启动进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.FileTransfer)
|
||||
__process_msg = f"开始整理,共 {total_num} 个文件 ..."
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=0,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.update(value=0,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
|
||||
for transfer_task in transfer_tasks:
|
||||
if global_vars.is_system_stopped:
|
||||
@@ -1130,9 +1164,9 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 更新进度
|
||||
__process_msg = f"正在整理 ({processed_num + fail_num + 1}/{total_num}){transfer_task.fileitem.name} ..."
|
||||
logger.info(__process_msg)
|
||||
self.progress.update(value=(processed_num + fail_num) / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.update(value=(processed_num + fail_num) / total_num * 100,
|
||||
text=__process_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
state, err_msg = self.__handle_transfer(
|
||||
task=transfer_task,
|
||||
callback=self.__default_callback
|
||||
@@ -1148,10 +1182,10 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 整理结束
|
||||
__end_msg = f"整理队列处理完成,共整理 {total_num} 个文件,失败 {fail_num} 个"
|
||||
logger.info(__end_msg)
|
||||
self.progress.update(value=100,
|
||||
text=__end_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
progress.update(value=100,
|
||||
text=__end_msg,
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress.end(ProgressKey.FileTransfer)
|
||||
|
||||
return all_success, ",".join(err_msgs)
|
||||
|
||||
@@ -1206,7 +1240,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
:param mediaid: TMDB ID/豆瓣ID
|
||||
"""
|
||||
# 查询历史记录
|
||||
history: TransferHistory = self.transferhis.get(logid)
|
||||
history: TransferHistory = TransferHistoryOper().get(logid)
|
||||
if not history:
|
||||
logger.error(f"整理记录不存在,ID:{logid}")
|
||||
return False, "整理记录不存在"
|
||||
@@ -1222,7 +1256,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
else:
|
||||
mediainfo = self.mediachain.recognize_by_path(str(src_path), episode_group=history.episode_group)
|
||||
mediainfo = MediaChain().recognize_by_path(str(src_path), episode_group=history.episode_group)
|
||||
if not mediainfo:
|
||||
return False, f"未识别到媒体信息,类型:{mtype.value},id:{mediaid}"
|
||||
# 重新执行整理
|
||||
@@ -1232,7 +1266,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
if history.dest_fileitem:
|
||||
# 解析目标文件对象
|
||||
dest_fileitem = FileItem(**history.dest_fileitem)
|
||||
self.storagechain.delete_file(dest_fileitem)
|
||||
StorageChain().delete_file(dest_fileitem)
|
||||
|
||||
# 强制整理
|
||||
if history.src_fileitem:
|
||||
@@ -1287,18 +1321,19 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
if tmdbid or doubanid:
|
||||
# 有输入TMDBID时单个识别
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.mediachain.recognize_media(tmdbid=tmdbid, doubanid=doubanid,
|
||||
mtype=mtype, episode_group=episode_group)
|
||||
mediainfo: MediaInfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid,
|
||||
mtype=mtype, episode_group=episode_group)
|
||||
if not mediainfo:
|
||||
return False, f"媒体信息识别失败,tmdbid:{tmdbid},doubanid:{doubanid},type: {mtype.value}"
|
||||
else:
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 开始进度
|
||||
self.progress.start(ProgressKey.FileTransfer)
|
||||
self.progress.update(value=0,
|
||||
text=f"开始整理 {fileitem.path} ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
progress = ProgressHelper()
|
||||
progress.start(ProgressKey.FileTransfer)
|
||||
progress.update(value=0,
|
||||
text=f"开始整理 {fileitem.path} ...",
|
||||
key=ProgressKey.FileTransfer)
|
||||
# 开始整理
|
||||
state, errmsg = self.do_transfer(
|
||||
fileitem=fileitem,
|
||||
@@ -1319,7 +1354,7 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
if not state:
|
||||
return False, errmsg
|
||||
|
||||
self.progress.end(ProgressKey.FileTransfer)
|
||||
progress.end(ProgressKey.FileTransfer)
|
||||
logger.info(f"{fileitem.path} 整理完成")
|
||||
return True, ""
|
||||
else:
|
||||
@@ -1340,26 +1375,22 @@ class TransferChain(ChainBase, metaclass=Singleton):
|
||||
return state, errmsg
|
||||
|
||||
def send_transfer_message(self, meta: MetaBase, mediainfo: MediaInfo,
|
||||
transferinfo: TransferInfo, season_episode: Optional[str] = None, username: Optional[str] = None):
|
||||
transferinfo: TransferInfo, season_episode: Optional[str] = None,
|
||||
username: Optional[str] = None):
|
||||
"""
|
||||
发送入库成功的消息
|
||||
"""
|
||||
msg_title = f"{mediainfo.title_year} {meta.season_episode if not season_episode else season_episode} 已入库"
|
||||
if mediainfo.vote_average:
|
||||
msg_str = f"评分:{mediainfo.vote_average},类型:{mediainfo.type.value}"
|
||||
else:
|
||||
msg_str = f"类型:{mediainfo.type.value}"
|
||||
if mediainfo.category:
|
||||
msg_str = f"{msg_str},类别:{mediainfo.category}"
|
||||
if meta.resource_term:
|
||||
msg_str = f"{msg_str},质量:{meta.resource_term}"
|
||||
msg_str = f"{msg_str},共{transferinfo.file_count}个文件," \
|
||||
f"大小:{StringUtils.str_filesize(transferinfo.total_size)}"
|
||||
if transferinfo.message:
|
||||
msg_str = f"{msg_str},以下文件处理失败:\n{transferinfo.message}"
|
||||
# 发送
|
||||
self.post_message(Notification(
|
||||
mtype=NotificationType.Organize,
|
||||
title=msg_title, text=msg_str, image=mediainfo.get_message_image(),
|
||||
username=username,
|
||||
link=settings.MP_DOMAIN('#/history')))
|
||||
self.post_message(
|
||||
Notification(
|
||||
mtype=NotificationType.Organize,
|
||||
ctype=ContentType.OrganizeSuccess,
|
||||
image=mediainfo.get_message_image(),
|
||||
username=username,
|
||||
link=settings.MP_DOMAIN('#/history')
|
||||
),
|
||||
meta=meta,
|
||||
mediainfo=mediainfo,
|
||||
transferinfo=transferinfo,
|
||||
season_episode=season_episode,
|
||||
username=username
|
||||
)
|
||||
|
||||
13
app/chain/tvdb.py
Normal file
13
app/chain/tvdb.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from typing import List
|
||||
|
||||
from app.chain import ChainBase
|
||||
|
||||
|
||||
class TvdbChain(ChainBase):
|
||||
"""
|
||||
Tvdb处理链,单例运行
|
||||
"""
|
||||
|
||||
def get_tvdbid_by_name(self, title: str) -> List[int]:
|
||||
tvdb_info_list = self.run_module("search_tvdb", title=title)
|
||||
return [int(item["tvdb_id"]) for item in tvdb_info_list]
|
||||
@@ -10,20 +10,15 @@ from app.log import logger
|
||||
from app.schemas import AuthCredentials, AuthInterceptCredentials
|
||||
from app.schemas.types import ChainEventType
|
||||
from app.utils.otp import OtpUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名或密码或二次校验码不正确"
|
||||
|
||||
|
||||
class UserChain(ChainBase, metaclass=Singleton):
|
||||
class UserChain(ChainBase):
|
||||
"""
|
||||
用户链,处理多种认证协议
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.user_oper = UserOper()
|
||||
|
||||
def user_authenticate(
|
||||
self,
|
||||
username: Optional[str] = None,
|
||||
@@ -90,7 +85,8 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
logger.debug(f"辅助认证未启用,认证类型 {grant_type} 未实现")
|
||||
return False, "不支持的认证类型"
|
||||
|
||||
def password_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
|
||||
@staticmethod
|
||||
def password_authenticate(credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
|
||||
"""
|
||||
密码认证
|
||||
|
||||
@@ -103,7 +99,7 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
logger.info("密码认证失败,认证类型不匹配")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
|
||||
user = self.user_oper.get_by_name(name=credentials.username)
|
||||
user = UserOper().get_by_name(name=credentials.username)
|
||||
if not user:
|
||||
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
@@ -131,8 +127,9 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
return False, "认证凭证无效"
|
||||
|
||||
# 检查是否因为用户被禁用
|
||||
useroper = UserOper()
|
||||
if credentials.username:
|
||||
user = self.user_oper.get_by_name(name=credentials.username)
|
||||
user = useroper.get_by_name(name=credentials.username)
|
||||
if user and not user.is_active:
|
||||
logger.info(f"用户 {user.name} 已被禁用,跳过后续身份校验")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
@@ -156,7 +153,7 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
success = self._process_auth_success(username=credentials.username, credentials=credentials)
|
||||
if success:
|
||||
logger.info(f"用户 {credentials.username} 辅助认证通过")
|
||||
return True, self.user_oper.get_by_name(credentials.username)
|
||||
return True, useroper.get_by_name(credentials.username)
|
||||
else:
|
||||
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
|
||||
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
|
||||
@@ -213,7 +210,8 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
return False
|
||||
|
||||
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
|
||||
user = self.user_oper.get_by_name(name=username)
|
||||
useroper = UserOper()
|
||||
user = useroper.get_by_name(name=username)
|
||||
if user:
|
||||
# 如果用户存在,但是已经被禁用,则直接响应
|
||||
if not user.is_active:
|
||||
@@ -226,8 +224,8 @@ class UserChain(ChainBase, metaclass=Singleton):
|
||||
return True
|
||||
else:
|
||||
if credentials.grant_type == "password":
|
||||
self.user_oper.add(name=username, is_active=True, is_superuser=False,
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(16)))
|
||||
useroper.add(name=username, is_active=True, is_superuser=False,
|
||||
hashed_password=get_password_hash(secrets.token_urlsafe(16)))
|
||||
logger.info(f"用户 {username} 不存在,已通过 {credentials.grant_type} 认证并已创建普通用户")
|
||||
return True
|
||||
else:
|
||||
|
||||
@@ -2,10 +2,9 @@ from typing import Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class WebhookChain(ChainBase, metaclass=Singleton):
|
||||
class WebhookChain(ChainBase):
|
||||
"""
|
||||
Webhook处理链
|
||||
"""
|
||||
|
||||
@@ -188,16 +188,14 @@ class WorkflowChain(ChainBase):
|
||||
工作流链
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.workflowoper = WorkflowOper()
|
||||
|
||||
def process(self, workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]:
|
||||
@staticmethod
|
||||
def process(workflow_id: int, from_begin: Optional[bool] = True) -> Tuple[bool, str]:
|
||||
"""
|
||||
处理工作流
|
||||
:param workflow_id: 工作流ID
|
||||
:param from_begin: 是否从头开始,默认为True
|
||||
"""
|
||||
workflowoper = WorkflowOper()
|
||||
|
||||
def save_step(action: Action, context: ActionContext):
|
||||
"""
|
||||
@@ -207,16 +205,16 @@ class WorkflowChain(ChainBase):
|
||||
serialized_data = pickle.dumps(context)
|
||||
# 使用Base64编码字节流
|
||||
encoded_data = base64.b64encode(serialized_data).decode('utf-8')
|
||||
self.workflowoper.step(workflow_id, action_id=action.id, context={
|
||||
workflowoper.step(workflow_id, action_id=action.id, context={
|
||||
"content": encoded_data
|
||||
})
|
||||
|
||||
# 重置工作流
|
||||
if from_begin:
|
||||
self.workflowoper.reset(workflow_id)
|
||||
workflowoper.reset(workflow_id)
|
||||
|
||||
# 查询工作流数据
|
||||
workflow = self.workflowoper.get(workflow_id)
|
||||
workflow = workflowoper.get(workflow_id)
|
||||
if not workflow:
|
||||
logger.warn(f"工作流 {workflow_id} 不存在")
|
||||
return False, "工作流不存在"
|
||||
@@ -228,7 +226,7 @@ class WorkflowChain(ChainBase):
|
||||
return False, "工作流无流程"
|
||||
|
||||
logger.info(f"开始处理 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
|
||||
self.workflowoper.start(workflow_id)
|
||||
workflowoper.start(workflow_id)
|
||||
|
||||
# 执行工作流
|
||||
executor = WorkflowExecutor(workflow, step_callback=save_step)
|
||||
@@ -236,15 +234,16 @@ class WorkflowChain(ChainBase):
|
||||
|
||||
if not executor.success:
|
||||
logger.info(f"工作流 {workflow.name} 执行失败:{executor.errmsg}")
|
||||
self.workflowoper.fail(workflow_id, result=executor.errmsg)
|
||||
workflowoper.fail(workflow_id, result=executor.errmsg)
|
||||
return False, executor.errmsg
|
||||
else:
|
||||
logger.info(f"工作流 {workflow.name} 执行完成")
|
||||
self.workflowoper.success(workflow_id)
|
||||
workflowoper.success(workflow_id)
|
||||
return True, ""
|
||||
|
||||
def get_workflows(self) -> List[Workflow]:
|
||||
@staticmethod
|
||||
def get_workflows() -> List[Workflow]:
|
||||
"""
|
||||
获取工作流列表
|
||||
"""
|
||||
return self.workflowoper.list_enabled()
|
||||
return WorkflowOper().list_enabled()
|
||||
|
||||
@@ -196,7 +196,7 @@ class CacheToolsBackend(CacheBackend):
|
||||
return None
|
||||
return region_cache.get(key)
|
||||
|
||||
def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION) -> None:
|
||||
def delete(self, key: str, region: Optional[str] = DEFAULT_CACHE_REGION):
|
||||
"""
|
||||
删除缓存
|
||||
|
||||
@@ -205,7 +205,7 @@ class CacheToolsBackend(CacheBackend):
|
||||
"""
|
||||
region_cache = self.__get_region_cache(region)
|
||||
if region_cache is None:
|
||||
return None
|
||||
return
|
||||
with lock:
|
||||
del region_cache[key]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
@@ -24,7 +24,7 @@ class ConfigModel(BaseModel):
|
||||
extra = "ignore" # 忽略未定义的配置项
|
||||
|
||||
# 项目名称
|
||||
PROJECT_NAME = "MoviePilot"
|
||||
PROJECT_NAME: str = "MoviePilot"
|
||||
# 域名 格式;https://movie-pilot.org
|
||||
APP_DOMAIN: str = ""
|
||||
# API路径
|
||||
@@ -69,8 +69,8 @@ class ConfigModel(BaseModel):
|
||||
DB_MAX_OVERFLOW: int = 500
|
||||
# SQLite 的 busy_timeout 参数,默认为 60 秒
|
||||
DB_TIMEOUT: int = 60
|
||||
# SQLite 是否启用 WAL 模式,默认关闭
|
||||
DB_WAL_ENABLE: bool = False
|
||||
# SQLite 是否启用 WAL 模式,默认开启
|
||||
DB_WAL_ENABLE: bool = True
|
||||
# 缓存类型,支持 cachetools 和 redis,默认使用 cachetools
|
||||
CACHE_BACKEND_TYPE: str = "cachetools"
|
||||
# 缓存连接字符串,仅外部缓存(如 Redis、Memcached)需要
|
||||
@@ -85,10 +85,12 @@ class ConfigModel(BaseModel):
|
||||
AUXILIARY_AUTH_ENABLE: bool = False
|
||||
# API密钥,需要更换
|
||||
API_TOKEN: Optional[str] = None
|
||||
# 网络代理 IP:PORT
|
||||
# 网络代理服务器地址
|
||||
PROXY_HOST: Optional[str] = None
|
||||
# 登录页面电影海报,tmdb/bing/mediaserver
|
||||
WALLPAPER: str = "tmdb"
|
||||
# 自定义壁纸api地址
|
||||
CUSTOMIZE_WALLPAPER_API_URL: Optional[str] = None
|
||||
# 媒体搜索来源 themoviedb/douban/bangumi,多个用,分隔
|
||||
SEARCH_SOURCE: str = "themoviedb,douban,bangumi"
|
||||
# 媒体识别来源 themoviedb/douban
|
||||
@@ -101,12 +103,19 @@ class ConfigModel(BaseModel):
|
||||
TMDB_IMAGE_DOMAIN: str = "image.tmdb.org"
|
||||
# TMDB API地址
|
||||
TMDB_API_DOMAIN: str = "api.themoviedb.org"
|
||||
# TMDB元数据语言
|
||||
TMDB_LOCALE: str = "zh"
|
||||
# 刮削使用TMDB原始语种图片
|
||||
TMDB_SCRAP_ORIGINAL_IMAGE: bool = False
|
||||
# TMDB API Key
|
||||
TMDB_API_KEY: str = "db55323b8d3e4154498498a75642b381"
|
||||
# TVDB API Key
|
||||
TVDB_API_KEY: str = "6b481081-10aa-440c-99f2-21d17717ee02"
|
||||
TVDB_V4_API_KEY: str = "ed2aa66b-7899-4677-92a7-67bc9ce3d93a"
|
||||
TVDB_V4_API_PIN: str = ""
|
||||
# Fanart开关
|
||||
FANART_ENABLE: bool = True
|
||||
# Fanart语言
|
||||
FANART_LANG: str = "zh,en"
|
||||
# Fanart API Key
|
||||
FANART_API_KEY: str = "d2d31f9ecabea050fc7d68aa3146015f"
|
||||
# 115 AppId
|
||||
@@ -116,9 +125,11 @@ class ConfigModel(BaseModel):
|
||||
# 元数据识别缓存过期时间(小时)
|
||||
META_CACHE_EXPIRE: int = 0
|
||||
# 电视剧动漫的分类genre_ids
|
||||
ANIME_GENREIDS = [16]
|
||||
ANIME_GENREIDS: List[int] = Field(default=[16])
|
||||
# 用户认证站点
|
||||
AUTH_SITE: str = ""
|
||||
# 重启自动升级
|
||||
MOVIEPILOT_AUTO_UPDATE: str = 'release'
|
||||
# 自动检查和更新站点资源包(站点索引、认证等)
|
||||
AUTO_UPDATE_RESOURCE: bool = True
|
||||
# 是否启用DOH解析域名
|
||||
@@ -130,6 +141,7 @@ class ConfigModel(BaseModel):
|
||||
"api.github.com,"
|
||||
"github.com,"
|
||||
"raw.githubusercontent.com,"
|
||||
"codeload.github.com,"
|
||||
"api.telegram.org")
|
||||
# DOH 解析服务器列表
|
||||
DOH_RESOLVERS: str = "1.0.0.1,1.1.1.1,9.9.9.9,149.112.112.112"
|
||||
@@ -194,7 +206,7 @@ class ConfigModel(BaseModel):
|
||||
# CookieCloud同步黑名单,多个域名,分割
|
||||
COOKIECLOUD_BLACKLIST: Optional[str] = None
|
||||
# CookieCloud对应的浏览器UA
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
USER_AGENT: str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
|
||||
# 电影重命名格式
|
||||
MOVIE_RENAME_FORMAT: str = "{{title}}{% if year %} ({{year}}){% endif %}" \
|
||||
"/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}" \
|
||||
@@ -213,7 +225,17 @@ class ConfigModel(BaseModel):
|
||||
"https://github.com/thsrite/MoviePilot-Plugins,"
|
||||
"https://github.com/honue/MoviePilot-Plugins,"
|
||||
"https://github.com/InfinityPacer/MoviePilot-Plugins,"
|
||||
"https://github.com/DDS-Derek/MoviePilot-Plugins")
|
||||
"https://github.com/DDS-Derek/MoviePilot-Plugins,"
|
||||
"https://github.com/madrays/MoviePilot-Plugins,"
|
||||
"https://github.com/justzerock/MoviePilot-Plugins,"
|
||||
"https://github.com/KoWming/MoviePilot-Plugins,"
|
||||
"https://github.com/wikrin/MoviePilot-Plugins,"
|
||||
"https://github.com/HankunYu/MoviePilot-Plugins,"
|
||||
"https://github.com/baozaodetudou/MoviePilot-Plugins,"
|
||||
"https://github.com/Aqr-K/MoviePilot-Plugins,"
|
||||
"https://github.com/hotlcc/MoviePilot-Plugins-Third,"
|
||||
"https://github.com/gxterry/MoviePilot-Plugins,"
|
||||
"https://github.com/DzAvril/MoviePilot-Plugins")
|
||||
# 插件安装数据共享
|
||||
PLUGIN_STATISTIC_SHARE: bool = True
|
||||
# 是否开启插件热加载
|
||||
@@ -222,12 +244,18 @@ class ConfigModel(BaseModel):
|
||||
GITHUB_TOKEN: Optional[str] = None
|
||||
# Github代理服务器,格式:https://mirror.ghproxy.com/
|
||||
GITHUB_PROXY: Optional[str] = ''
|
||||
# pip镜像站点,格式:https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
# pip镜像站点,格式:https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
PIP_PROXY: Optional[str] = ''
|
||||
# 指定的仓库Github token,多个仓库使用,分隔,格式:{user1}/{repo1}:ghp_****,{user2}/{repo2}:github_pat_****
|
||||
REPO_GITHUB_TOKEN: Optional[str] = None
|
||||
# 大内存模式
|
||||
BIG_MEMORY_MODE: bool = False
|
||||
# 是否启用内存监控
|
||||
MEMORY_ANALYSIS: bool = False
|
||||
# 内存快照间隔(分钟)
|
||||
MEMORY_SNAPSHOT_INTERVAL: int = 60
|
||||
# 保留的内存快照文件数量
|
||||
MEMORY_SNAPSHOT_KEEP_COUNT: int = 20
|
||||
# 全局图片缓存,将媒体图片缓存到本地
|
||||
GLOBAL_IMAGE_CACHE: bool = False
|
||||
# 是否启用编码探测的性能模式
|
||||
@@ -235,33 +263,30 @@ class ConfigModel(BaseModel):
|
||||
# 编码探测的最低置信度阈值
|
||||
ENCODING_DETECTION_MIN_CONFIDENCE: float = 0.8
|
||||
# 允许的图片缓存域名
|
||||
SECURITY_IMAGE_DOMAINS: List[str] = Field(
|
||||
default_factory=lambda: ["image.tmdb.org",
|
||||
"static-mdb.v.geilijiasu.com",
|
||||
"doubanio.com",
|
||||
"lain.bgm.tv",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
"thetvdb.com",
|
||||
"cctvpic.com",
|
||||
"iqiyipic.com",
|
||||
"hdslb.com",
|
||||
"cmvideo.cn",
|
||||
"ykimg.com",
|
||||
"qpic.cn"]
|
||||
)
|
||||
SECURITY_IMAGE_DOMAINS: list = Field(default=[
|
||||
"image.tmdb.org",
|
||||
"static-mdb.v.geilijiasu.com",
|
||||
"bing.com",
|
||||
"doubanio.com",
|
||||
"lain.bgm.tv",
|
||||
"raw.githubusercontent.com",
|
||||
"github.com",
|
||||
"thetvdb.com",
|
||||
"cctvpic.com",
|
||||
"iqiyipic.com",
|
||||
"hdslb.com",
|
||||
"cmvideo.cn",
|
||||
"ykimg.com",
|
||||
"qpic.cn"
|
||||
])
|
||||
# 允许的图片文件后缀格式
|
||||
SECURITY_IMAGE_SUFFIXES: List[str] = Field(
|
||||
default_factory=lambda: [".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"]
|
||||
)
|
||||
SECURITY_IMAGE_SUFFIXES: list = Field(default=[".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"])
|
||||
# 重命名时支持的S0别名
|
||||
RENAME_FORMAT_S0_NAMES: List[str] = Field(
|
||||
default_factory=lambda: ["Specials", "SPs"]
|
||||
)
|
||||
# 启用分词搜索
|
||||
TOKENIZED_SEARCH: bool = False
|
||||
RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"])
|
||||
# 为指定默认字幕添加.default后缀
|
||||
DEFAULT_SUB: Optional[str] = "zh-cn"
|
||||
# Docker Client API地址
|
||||
DOCKER_CLIENT_API: Optional[str] = "tcp://127.0.0.1:38379"
|
||||
|
||||
|
||||
class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
@@ -308,6 +333,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
raise_exception: bool = False) -> Tuple[Any, bool]:
|
||||
"""
|
||||
通用类型转换函数,根据预期类型转换值。如果转换失败,返回默认值
|
||||
:return: 元组 (转换后的值, 是否需要更新)
|
||||
"""
|
||||
if isinstance(value, (list, dict, set)):
|
||||
value = copy.deepcopy(value)
|
||||
@@ -348,19 +374,17 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
converted = float(value)
|
||||
return converted, str(converted) != str(original_value)
|
||||
elif expected_type is str:
|
||||
# 清理 value 中所有空白字符的字段
|
||||
fields_not_keep_spaces = {"AUTO_DOWNLOAD_USER", "REPO_GITHUB_TOKEN", "PLUGIN_MARKET"}
|
||||
if field_name in fields_not_keep_spaces:
|
||||
value = re.sub(r"\s+", "", value)
|
||||
return value, str(value) != str(original_value)
|
||||
# # 后续考虑支持 list 类型的处理
|
||||
# elif expected_type is list:
|
||||
# if isinstance(value, list):
|
||||
# return value, False
|
||||
# if isinstance(value, str):
|
||||
# items = [item.strip() for item in value.split(",") if item.strip()]
|
||||
# return items, items != original_value.split(",")
|
||||
# 可根据需要添加更多类型处理
|
||||
converted = str(value).strip()
|
||||
return converted, converted != str(original_value)
|
||||
elif expected_type is list:
|
||||
if isinstance(value, list):
|
||||
return value, str(value) != str(original_value)
|
||||
if isinstance(value, str):
|
||||
items = json.loads(value)
|
||||
if isinstance(original_value, list):
|
||||
return items, items != original_value
|
||||
else:
|
||||
return items, str(items) != str(original_value)
|
||||
else:
|
||||
return value, str(value) != str(original_value)
|
||||
except (ValueError, TypeError) as e:
|
||||
@@ -400,14 +424,24 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
logger.warning(message)
|
||||
return False, message
|
||||
else:
|
||||
set_key(SystemUtils.get_env_path(), field.name, str(converted_value) if converted_value is not None else "")
|
||||
# 如果是列表、字典或集合类型,将其转换为JSON字符串
|
||||
if isinstance(converted_value, (list, dict, set)):
|
||||
value_to_write = json.dumps(converted_value)
|
||||
else:
|
||||
value_to_write = str(converted_value) if converted_value is not None else ""
|
||||
|
||||
set_key(dotenv_path=SystemUtils.get_env_path(), key_to_set=field.name, value_to_set=value_to_write,
|
||||
quote_mode="always")
|
||||
if is_converted:
|
||||
logger.info(f"配置项 '{field.name}' 已自动修正并写入到 'app.env' 文件")
|
||||
return True, message
|
||||
|
||||
def update_setting(self, key: str, value: Any) -> Tuple[bool, str]:
|
||||
def update_setting(self, key: str, value: Any) -> Tuple[Optional[bool], str]:
|
||||
"""
|
||||
更新单个配置项
|
||||
:param key: 配置项的名称
|
||||
:param value: 配置项的新值
|
||||
:return: (是否成功 True 成功/False 失败/None 无需更新, 错误信息)
|
||||
"""
|
||||
if not hasattr(self, key):
|
||||
return False, f"配置项 '{key}' 不存在"
|
||||
@@ -418,8 +452,11 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if field.name == "API_TOKEN":
|
||||
converted_value, needs_update = self.validate_api_token(value, original_value)
|
||||
else:
|
||||
converted_value, needs_update = self.generic_type_converter(value, original_value, field.type_,
|
||||
field.default, key)
|
||||
converted_value, needs_update = self.generic_type_converter(value,
|
||||
original_value,
|
||||
field.type_,
|
||||
field.default,
|
||||
key)
|
||||
# 如果没有抛出异常,则统一使用 converted_value 进行更新
|
||||
if needs_update or str(value) != str(converted_value):
|
||||
success, message = self.update_env_config(field, value, converted_value)
|
||||
@@ -429,30 +466,17 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
if hasattr(log_settings, key):
|
||||
setattr(log_settings, key, converted_value)
|
||||
return success, message
|
||||
return True, ""
|
||||
return None, ""
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[bool, str]]:
|
||||
def update_settings(self, env: Dict[str, Any]) -> Dict[str, Tuple[Optional[bool], str]]:
|
||||
"""
|
||||
更新多个配置项
|
||||
"""
|
||||
results = {}
|
||||
log_updated, plugin_monitor_updated = False, False
|
||||
for k, v in env.items():
|
||||
results[k] = self.update_setting(k, v)
|
||||
if hasattr(log_settings, k):
|
||||
log_updated = True
|
||||
if k in ["PLUGIN_AUTO_RELOAD", "DEV"]:
|
||||
plugin_monitor_updated = True
|
||||
# 本次更新存在日志配置项更新,需要重新加载日志配置
|
||||
if log_updated:
|
||||
logger.update_loggers()
|
||||
# 本次更新存在插件监控配置项更新,需要重新加载插件监控
|
||||
if plugin_monitor_updated:
|
||||
# 解决顶层循环导入问题
|
||||
from app.core.plugin import PluginManager
|
||||
PluginManager().reload_monitor()
|
||||
return results
|
||||
|
||||
@property
|
||||
@@ -501,7 +525,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return self.CONFIG_PATH / "cookies"
|
||||
|
||||
@property
|
||||
def CACHE_CONF(self):
|
||||
def CONF(self):
|
||||
"""
|
||||
{
|
||||
"torrents": "缓存种子数量",
|
||||
@@ -509,7 +533,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"tmdb": "TMDB请求缓存数量",
|
||||
"douban": "豆瓣请求缓存数量",
|
||||
"fanart": "Fanart请求缓存数量",
|
||||
"meta": "元数据缓存过期时间(秒)"
|
||||
"meta": "元数据缓存过期时间(秒)",
|
||||
"memory": "最大占用内存(MB)",
|
||||
"scheduler": "调度器缓存数量"
|
||||
"threadpool": "线程池数量"
|
||||
}
|
||||
"""
|
||||
if self.BIG_MEMORY_MODE:
|
||||
@@ -520,7 +547,9 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"douban": 512,
|
||||
"bangumi": 512,
|
||||
"fanart": 512,
|
||||
"meta": (self.META_CACHE_EXPIRE or 24) * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 24) * 3600,
|
||||
"scheduler": 100,
|
||||
"threadpool": 100
|
||||
}
|
||||
return {
|
||||
"torrents": 100,
|
||||
@@ -529,7 +558,9 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"douban": 256,
|
||||
"bangumi": 256,
|
||||
"fanart": 128,
|
||||
"meta": (self.META_CACHE_EXPIRE or 2) * 3600
|
||||
"meta": (self.META_CACHE_EXPIRE or 2) * 3600,
|
||||
"scheduler": 50,
|
||||
"threadpool": 50
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -547,6 +578,7 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return {
|
||||
"server": self.PROXY_HOST
|
||||
}
|
||||
return None
|
||||
|
||||
@property
|
||||
def GITHUB_HEADERS(self):
|
||||
@@ -555,7 +587,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
if self.GITHUB_TOKEN:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}"
|
||||
"Authorization": f"Bearer {self.GITHUB_TOKEN}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -583,7 +616,8 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
print(f"无效的令牌或仓库信息: {token_pair}")
|
||||
continue
|
||||
headers[repo_info] = {
|
||||
"Authorization": f"Bearer {token}"
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": self.USER_AGENT,
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"处理令牌对 '{token_pair}' 时出错: {e}")
|
||||
@@ -604,6 +638,10 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
return UrlUtils.combine_url(host=self.APP_DOMAIN, path=url)
|
||||
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class GlobalVar(object):
|
||||
"""
|
||||
全局标识
|
||||
@@ -661,8 +699,5 @@ class GlobalVar(object):
|
||||
return self.is_system_stopped or workflow_id in self.EMERGENCY_STOP_WORKFLOWS
|
||||
|
||||
|
||||
# 实例化配置
|
||||
settings = Settings()
|
||||
|
||||
# 全局标识
|
||||
global_vars = GlobalVar()
|
||||
|
||||
@@ -10,7 +10,6 @@ from functools import lru_cache
|
||||
from queue import Empty, PriorityQueue
|
||||
from typing import Callable, Dict, List, Optional, Union
|
||||
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.thread import ThreadHelper
|
||||
from app.log import logger
|
||||
from app.schemas import ChainEventData
|
||||
@@ -75,7 +74,6 @@ class EventManager(metaclass=Singleton):
|
||||
__event = threading.Event()
|
||||
|
||||
def __init__(self):
|
||||
self.__messagehelper = MessageHelper()
|
||||
self.__executor = ThreadHelper() # 动态线程池,用于消费事件
|
||||
self.__consumer_threads = [] # 用于保存启动的事件消费者线程
|
||||
self.__event_queue = PriorityQueue() # 优先级队列
|
||||
@@ -140,11 +138,12 @@ class EventManager(metaclass=Singleton):
|
||||
"""
|
||||
event = Event(etype, data, priority)
|
||||
if isinstance(etype, EventType):
|
||||
self.__trigger_broadcast_event(event)
|
||||
return self.__trigger_broadcast_event(event)
|
||||
elif isinstance(etype, ChainEventType):
|
||||
return self.__trigger_chain_event(event)
|
||||
else:
|
||||
logger.error(f"Unknown event type: {etype}")
|
||||
return None
|
||||
|
||||
def add_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable,
|
||||
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
|
||||
@@ -293,7 +292,7 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 对于类实例(实现了 __call__ 方法)
|
||||
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
|
||||
handler_cls = handler.__class__ # noqa
|
||||
handler_cls = handler.__class__ # noqa
|
||||
return cls.__get_handler_identifier(handler_cls)
|
||||
|
||||
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
|
||||
@@ -303,6 +302,7 @@ class EventManager(metaclass=Singleton):
|
||||
module = inspect.getmodule(handler)
|
||||
module_name = module.__name__ if module else "unknown_module"
|
||||
return f"{module_name}.{class_name}"
|
||||
return None
|
||||
|
||||
def __is_handler_enabled(self, handler: Callable) -> bool:
|
||||
"""
|
||||
@@ -398,16 +398,28 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
try:
|
||||
from app.core.plugin import PluginManager
|
||||
from app.core.module import ModuleManager
|
||||
|
||||
if class_name in PluginManager().get_plugin_ids():
|
||||
# 定义一个插件调用函数
|
||||
def plugin_callable():
|
||||
"""
|
||||
插件调用函数
|
||||
"""
|
||||
PluginManager().run_plugin_method(class_name, method_name, event_to_process)
|
||||
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(plugin_callable)
|
||||
else:
|
||||
plugin_callable()
|
||||
elif class_name in ModuleManager().get_module_ids():
|
||||
module = ModuleManager().get_running_module(class_name)
|
||||
if module:
|
||||
method = getattr(module, method_name, None)
|
||||
if method:
|
||||
if is_broadcast_event:
|
||||
self.__executor.submit(method, event_to_process)
|
||||
else:
|
||||
method(event_to_process)
|
||||
else:
|
||||
# 获取全局对象或模块类的实例
|
||||
class_obj = self.__get_class_instance(class_name)
|
||||
@@ -438,22 +450,25 @@ class EventManager(metaclass=Singleton):
|
||||
|
||||
# 如果类不在全局变量中,尝试动态导入模块并创建实例
|
||||
try:
|
||||
if class_name == "Command":
|
||||
module_name = "app.command"
|
||||
if class_name.endswith("Manager"):
|
||||
module_name = f"app.core.{class_name[:-7].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
elif class_name.endswith("Chain"):
|
||||
module_name = f"app.chain.{class_name[:-5].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
elif class_name.endswith("Helper"):
|
||||
module_name = f"app.helper.{class_name[:-6].lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
else:
|
||||
logger.debug(f"事件处理出错:无效的 Chain 类名: {class_name},类名必须以 'Chain' 结尾")
|
||||
return None
|
||||
module_name = f"app.{class_name.lower()}"
|
||||
module = importlib.import_module(module_name)
|
||||
if hasattr(module, class_name):
|
||||
class_obj = getattr(module, class_name)()
|
||||
return class_obj
|
||||
else:
|
||||
logger.debug(f"事件处理出错:模块 {module_name} 中没有找到类 {class_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
logger.debug(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
def __broadcast_consumer_loop(self):
|
||||
@@ -491,9 +506,11 @@ class EventManager(metaclass=Singleton):
|
||||
names = handler.__qualname__.split(".")
|
||||
class_name, method_name = names[0], names[1]
|
||||
|
||||
self.__messagehelper.put(title=f"{event.event_type} 事件处理出错",
|
||||
message=f"{class_name}.{method_name}:{str(e)}",
|
||||
role="system")
|
||||
# 发送系统错误通知
|
||||
from app.helper.message import MessageHelper
|
||||
MessageHelper().put(title=f"{event.event_type} 事件处理出错",
|
||||
message=f"{class_name}.{method_name}:{str(e)}",
|
||||
role="system")
|
||||
self.send_event(
|
||||
EventType.SystemError,
|
||||
{
|
||||
|
||||
@@ -582,6 +582,12 @@ class MetaBase(object):
|
||||
# Part
|
||||
if not self.part:
|
||||
self.part = meta.part
|
||||
# tmdbid
|
||||
if not self.tmdbid and meta.tmdbid:
|
||||
self.tmdbid = meta.tmdbid
|
||||
# doubanid
|
||||
if not self.doubanid and meta.doubanid:
|
||||
self.doubanid = meta.doubanid
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,7 @@ class MetaVideo(MetaBase):
|
||||
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
|
||||
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
|
||||
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$|^REMUX$|^UHD$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
|
||||
_effect_re = r"^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$|^HLG$|^HDR10(\+|Plus)$"
|
||||
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
|
||||
_name_no_begin_re = r"^[\[【].+?[\]】]"
|
||||
_name_no_chinese_re = r".*版|.*字幕"
|
||||
@@ -50,8 +50,8 @@ class MetaVideo(MetaBase):
|
||||
r"|CD[\s.]*[1-9]|DVD[\s.]*[1-9]|DISK[\s.]*[1-9]|DISC[\s.]*[1-9]|\s+GB"
|
||||
_resources_pix_re = r"^[SBUHD]*(\d{3,4}[PI]+)|\d{3,4}X(\d{3,4})"
|
||||
_resources_pix_re2 = r"(^[248]+K)"
|
||||
_video_encode_re = r"^[HX]26[45]$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^HDR\d*$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$"
|
||||
_video_encode_re = r"^(H26[45])$|^(x26[45])$|^AVC$|^HEVC$|^VC\d?$|^MPEG\d?$|^Xvid$|^DivX$|^AV1$|^HDR\d*$|^AVS(\+|[23])$"
|
||||
_audio_encode_re = r"^DTS\d?$|^DTSHD$|^DTSHDMA$|^Atmos$|^TrueHD\d?$|^AC3$|^\dAudios?$|^DDP\d?$|^DD\+\d?$|^DD\d?$|^LPCM\d?$|^AAC\d?$|^FLAC\d?$|^HD\d?$|^MA\d?$|^HR\d?$|^Opus\d?$|^Vorbis\d?$"
|
||||
|
||||
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
|
||||
"""
|
||||
@@ -592,7 +592,12 @@ class MetaVideo(MetaBase):
|
||||
self._stop_name_flag = True
|
||||
self._last_token_type = "videoencode"
|
||||
if not self.video_encode:
|
||||
self.video_encode = re_res.group(1).upper()
|
||||
if re_res.group(2):
|
||||
self.video_encode = re_res.group(2).upper()
|
||||
elif re_res.group(3):
|
||||
self.video_encode = re_res.group(3).lower()
|
||||
else:
|
||||
self.video_encode = re_res.group(1).upper()
|
||||
self._last_token = self.video_encode
|
||||
elif self.video_encode == "10bit":
|
||||
self.video_encode = f"{re_res.group(1).upper()} 10bit"
|
||||
|
||||
@@ -81,7 +81,6 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
release_groups = []
|
||||
for site_groups in self.RELEASE_GROUPS.values():
|
||||
for release_group in site_groups:
|
||||
@@ -98,7 +97,7 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
|
||||
return ""
|
||||
if not groups:
|
||||
# 自定义组
|
||||
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
|
||||
custom_release_groups = SystemConfigOper().get(SystemConfigKey.CustomReleaseGroups)
|
||||
if isinstance(custom_release_groups, list):
|
||||
custom_release_groups = list(filter(None, custom_release_groups))
|
||||
if custom_release_groups:
|
||||
|
||||
@@ -120,41 +120,69 @@ def find_metainfo(title: str) -> Tuple[str, dict]:
|
||||
return title, metainfo
|
||||
# 从标题中提取媒体信息 格式为{[tmdbid=xxx;type=xxx;s=xxx;e=xxx]}
|
||||
results = re.findall(r'(?<={\[)[\W\w]+(?=]})', title)
|
||||
if not results:
|
||||
return title, metainfo
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
if mtype[0] == "movies":
|
||||
metainfo['type'] = MediaType.MOVIE
|
||||
elif mtype[0] == "tv":
|
||||
metainfo['type'] = MediaType.TV
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
if results:
|
||||
for result in results:
|
||||
# 查找tmdbid信息
|
||||
tmdbid = re.findall(r'(?<=tmdbid=)\d+', result)
|
||||
if tmdbid and tmdbid[0].isdigit():
|
||||
metainfo['tmdbid'] = tmdbid[0]
|
||||
# 查找豆瓣id信息
|
||||
doubanid = re.findall(r'(?<=doubanid=)\d+', result)
|
||||
if doubanid and doubanid[0].isdigit():
|
||||
metainfo['doubanid'] = doubanid[0]
|
||||
# 查找媒体类型
|
||||
mtype = re.findall(r'(?<=type=)\w+', result)
|
||||
if mtype:
|
||||
if mtype[0] == "movies":
|
||||
metainfo['type'] = MediaType.MOVIE
|
||||
elif mtype[0] == "tv":
|
||||
metainfo['type'] = MediaType.TV
|
||||
# 查找季信息
|
||||
begin_season = re.findall(r'(?<=s=)\d+', result)
|
||||
if begin_season and begin_season[0].isdigit():
|
||||
metainfo['begin_season'] = int(begin_season[0])
|
||||
end_season = re.findall(r'(?<=s=\d+-)\d+', result)
|
||||
if end_season and end_season[0].isdigit():
|
||||
metainfo['end_season'] = int(end_season[0])
|
||||
# 查找集信息
|
||||
begin_episode = re.findall(r'(?<=e=)\d+', result)
|
||||
if begin_episode and begin_episode[0].isdigit():
|
||||
metainfo['begin_episode'] = int(begin_episode[0])
|
||||
end_episode = re.findall(r'(?<=e=\d+-)\d+', result)
|
||||
if end_episode and end_episode[0].isdigit():
|
||||
metainfo['end_episode'] = int(end_episode[0])
|
||||
# 去除title中该部分
|
||||
if tmdbid or mtype or begin_season or end_season or begin_episode or end_episode:
|
||||
title = title.replace(f"{{[{result}]}}", '')
|
||||
|
||||
# 支持Emby格式的ID标签
|
||||
# 1. [tmdbid=xxxx] 或 [tmdbid-xxxx] 格式
|
||||
tmdb_match = re.search(r'\[tmdbid[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdbid[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
# 2. [tmdb=xxxx] 或 [tmdb-xxxx] 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\[tmdb[=\-](\d+)\]', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\[tmdb[=\-](\d+)\]', '', title).strip()
|
||||
|
||||
# 3. {tmdbid=xxxx} 或 {tmdbid-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdbid[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdbid[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
# 4. {tmdb=xxxx} 或 {tmdb-xxxx} 格式
|
||||
if not metainfo['tmdbid']:
|
||||
tmdb_match = re.search(r'\{tmdb[=\-](\d+)\}', title)
|
||||
if tmdb_match:
|
||||
metainfo['tmdbid'] = tmdb_match.group(1)
|
||||
title = re.sub(r'\{tmdb[=\-](\d+)\}', '', title).strip()
|
||||
|
||||
# 计算季集总数
|
||||
if metainfo.get('begin_season') and metainfo.get('end_season'):
|
||||
if metainfo['begin_season'] > metainfo['end_season']:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import traceback
|
||||
from typing import Generator, Optional, Tuple, Any, Union
|
||||
from typing import Generator, Optional, Tuple, Any, Union, List
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
@@ -164,3 +164,9 @@ class ModuleManager(metaclass=Singleton):
|
||||
获取模块列表
|
||||
"""
|
||||
return self._modules
|
||||
|
||||
def get_module_ids(self) -> List[str]:
|
||||
"""
|
||||
获取模块id列表
|
||||
"""
|
||||
return list(self._modules.keys())
|
||||
|
||||
@@ -7,14 +7,16 @@ import time
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
|
||||
from typing import Any, Dict, List, Optional, Type, Union, Callable, Tuple
|
||||
|
||||
from fastapi import HTTPException
|
||||
from starlette import status
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
from app import schemas
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.db.plugindata_oper import PluginDataOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.module import ModuleHelper
|
||||
@@ -85,7 +87,6 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
插件管理器
|
||||
"""
|
||||
systemconfig: SystemConfigOper = None
|
||||
|
||||
# 插件列表
|
||||
_plugins: dict = {}
|
||||
@@ -97,10 +98,6 @@ class PluginManager(metaclass=Singleton):
|
||||
_observer: Observer = None
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.pluginhelper = PluginHelper()
|
||||
self.systemconfig = SystemConfigOper()
|
||||
self.plugindata = PluginDataOper()
|
||||
# 开发者模式监测插件修改
|
||||
if settings.DEV or settings.PLUGIN_AUTO_RELOAD:
|
||||
self.__start_monitor()
|
||||
@@ -125,6 +122,8 @@ class PluginManager(metaclass=Singleton):
|
||||
return False
|
||||
return True
|
||||
|
||||
# 已安装插件
|
||||
installed_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 扫描插件目录
|
||||
if pid:
|
||||
# 加载指定插件
|
||||
@@ -133,13 +132,11 @@ class PluginManager(metaclass=Singleton):
|
||||
filter_func=lambda name, obj: check_module(obj) and name == pid
|
||||
)
|
||||
else:
|
||||
# 加载所有插件
|
||||
# 加载已安装插件
|
||||
plugins = ModuleHelper.load(
|
||||
"app.plugins",
|
||||
filter_func=lambda _, obj: check_module(obj)
|
||||
filter_func=lambda name, obj: check_module(obj) and name in installed_plugins
|
||||
)
|
||||
# 已安装插件
|
||||
installed_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 排序
|
||||
plugins.sort(key=lambda x: x.plugin_order if hasattr(x, "plugin_order") else 0)
|
||||
for plugin in plugins:
|
||||
@@ -155,11 +152,6 @@ class PluginManager(metaclass=Singleton):
|
||||
continue
|
||||
# 存储Class
|
||||
self._plugins[plugin_id] = plugin
|
||||
# 未安装的不加载
|
||||
if plugin_id not in installed_plugins:
|
||||
# 设置事件状态为不可用
|
||||
eventmanager.disable_event_handler(plugin)
|
||||
continue
|
||||
# 生成实例
|
||||
plugin_obj = plugin()
|
||||
# 生效插件配置
|
||||
@@ -202,24 +194,57 @@ class PluginManager(metaclass=Singleton):
|
||||
# 停止插件
|
||||
if pid:
|
||||
logger.info(f"正在停止插件 {pid}...")
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
if not plugin_obj:
|
||||
logger.debug(f"插件 {pid} 不存在或未加载")
|
||||
return
|
||||
plugins = {pid: plugin_obj}
|
||||
else:
|
||||
logger.info("正在停止所有插件...")
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
if pid and plugin_id != pid:
|
||||
continue
|
||||
plugins = self._running_plugins
|
||||
for plugin_id, plugin in plugins.items():
|
||||
eventmanager.disable_event_handler(type(plugin))
|
||||
self.__stop_plugin(plugin)
|
||||
# 清空对像
|
||||
if pid:
|
||||
# 清空指定插件
|
||||
if pid in self._running_plugins:
|
||||
self._running_plugins.pop(pid)
|
||||
self._plugins.pop(pid, None)
|
||||
self._running_plugins.pop(pid, None)
|
||||
else:
|
||||
# 清空
|
||||
self._plugins = {}
|
||||
self._running_plugins = {}
|
||||
logger.info("插件停止完成")
|
||||
|
||||
@property
|
||||
def running_plugins(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取运行态插件列表
|
||||
:return: 运行态插件列表
|
||||
"""
|
||||
return self._running_plugins
|
||||
|
||||
@property
|
||||
def plugins(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取插件列表
|
||||
:return: 插件列表
|
||||
"""
|
||||
return self._plugins
|
||||
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
"""
|
||||
处理配置变更事件
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ['DEV', 'PLUGIN_AUTO_RELOAD']:
|
||||
return
|
||||
self.reload_monitor()
|
||||
|
||||
def reload_monitor(self):
|
||||
"""
|
||||
重新加载插件文件修改监测
|
||||
@@ -261,12 +286,15 @@ class PluginManager(metaclass=Singleton):
|
||||
停止插件
|
||||
:param plugin: 插件实例
|
||||
"""
|
||||
# 关闭数据库
|
||||
if hasattr(plugin, "close"):
|
||||
plugin.close()
|
||||
# 关闭插件
|
||||
if hasattr(plugin, "stop_service"):
|
||||
plugin.stop_service()
|
||||
try:
|
||||
# 关闭数据库
|
||||
if hasattr(plugin, "close"):
|
||||
plugin.close()
|
||||
# 关闭插件
|
||||
if hasattr(plugin, "stop_service"):
|
||||
plugin.stop_service()
|
||||
except Exception as e:
|
||||
logger.warn(f"停止插件 {plugin.get_name()} 时发生错误: {str(e)}")
|
||||
|
||||
def remove_plugin(self, plugin_id: str):
|
||||
"""
|
||||
@@ -275,6 +303,13 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
self.stop(plugin_id)
|
||||
|
||||
# 从模块列表中移除插件
|
||||
from sys import modules
|
||||
try:
|
||||
del modules[f"app.plugins.{plugin_id.lower()}"]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def reload_plugin(self, plugin_id: str):
|
||||
"""
|
||||
将一个插件重新加载到内存
|
||||
@@ -289,12 +324,12 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
def sync(self) -> List[str]:
|
||||
"""
|
||||
安装本地不存在的在线插件
|
||||
安装本地不存在或需要更新的插件
|
||||
"""
|
||||
|
||||
def install_plugin(plugin):
|
||||
start_time = time.time()
|
||||
state, msg = self.pluginhelper.install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True)
|
||||
state, msg = PluginHelper().install(pid=plugin.id, repo_url=plugin.repo_url, force_install=True)
|
||||
elapsed_time = time.time() - start_time
|
||||
if state:
|
||||
logger.info(
|
||||
@@ -309,13 +344,14 @@ class PluginManager(metaclass=Singleton):
|
||||
return []
|
||||
|
||||
# 获取已安装插件列表
|
||||
install_plugins = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 获取在线插件列表
|
||||
online_plugins = self.get_online_plugins()
|
||||
# 确定需要安装的插件
|
||||
plugins_to_install = [
|
||||
plugin for plugin in online_plugins
|
||||
if plugin.id in install_plugins and not self.is_plugin_exists(plugin.id)
|
||||
if plugin.id in install_plugins
|
||||
and not self.is_plugin_exists(plugin.id, plugin.plugin_version)
|
||||
]
|
||||
|
||||
if not plugins_to_install:
|
||||
@@ -345,19 +381,21 @@ class PluginManager(metaclass=Singleton):
|
||||
)
|
||||
return sync_plugins
|
||||
|
||||
def install_plugin_missing_dependencies(self) -> List[str]:
|
||||
@staticmethod
|
||||
def install_plugin_missing_dependencies() -> List[str]:
|
||||
"""
|
||||
安装插件中缺失或不兼容的依赖项
|
||||
"""
|
||||
pluginhelper = PluginHelper()
|
||||
# 第一步:获取需要安装的依赖项列表
|
||||
missing_dependencies = self.pluginhelper.find_missing_dependencies()
|
||||
missing_dependencies = pluginhelper.find_missing_dependencies()
|
||||
if not missing_dependencies:
|
||||
return missing_dependencies
|
||||
logger.debug(f"检测到缺失的依赖项: {missing_dependencies}")
|
||||
logger.info(f"开始安装缺失的依赖项,共 {len(missing_dependencies)} 个...")
|
||||
# 第二步:安装依赖项并返回结果
|
||||
total_start_time = time.time()
|
||||
success, message = self.pluginhelper.install_dependencies(missing_dependencies)
|
||||
success, message = pluginhelper.install_dependencies(missing_dependencies)
|
||||
total_elapsed_time = time.time() - total_start_time
|
||||
if success:
|
||||
logger.info(f"已完成 {len(missing_dependencies)} 个依赖项安装,总耗时:{total_elapsed_time:.2f} 秒")
|
||||
@@ -372,21 +410,23 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return {}
|
||||
conf = self.systemconfig.get(self._config_key % pid)
|
||||
conf = SystemConfigOper().get(self._config_key % pid)
|
||||
if conf:
|
||||
# 去掉空Key
|
||||
return {k: v for k, v in conf.items() if k}
|
||||
return {}
|
||||
|
||||
def save_plugin_config(self, pid: str, conf: dict) -> bool:
|
||||
def save_plugin_config(self, pid: str, conf: dict, force: bool = False) -> bool:
|
||||
"""
|
||||
保存插件配置
|
||||
:param pid: 插件ID
|
||||
:param conf: 配置
|
||||
:param force: 强制保存
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
if not force and not self._plugins.get(pid):
|
||||
return False
|
||||
return self.systemconfig.set(self._config_key % pid, conf)
|
||||
SystemConfigOper().set(self._config_key % pid, conf)
|
||||
return True
|
||||
|
||||
def delete_plugin_config(self, pid: str) -> bool:
|
||||
"""
|
||||
@@ -395,7 +435,7 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return False
|
||||
return self.systemconfig.delete(self._config_key % pid)
|
||||
return SystemConfigOper().delete(self._config_key % pid)
|
||||
|
||||
def delete_plugin_data(self, pid: str) -> bool:
|
||||
"""
|
||||
@@ -404,71 +444,9 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
if not self._plugins.get(pid):
|
||||
return False
|
||||
self.plugindata.del_data(pid)
|
||||
PluginDataOper().del_data(pid)
|
||||
return True
|
||||
|
||||
def get_plugin_form(self, pid: str) -> Tuple[List[dict], Dict[str, Any]]:
|
||||
"""
|
||||
获取插件表单
|
||||
:param pid: 插件ID
|
||||
"""
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return [], {}
|
||||
if hasattr(plugin, "get_form"):
|
||||
return plugin.get_form() or ([], {})
|
||||
return [], {}
|
||||
|
||||
def get_plugin_page(self, pid: str) -> List[dict]:
|
||||
"""
|
||||
获取插件页面
|
||||
:param pid: 插件ID
|
||||
"""
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return []
|
||||
if hasattr(plugin, "get_page"):
|
||||
return plugin.get_page() or []
|
||||
return []
|
||||
|
||||
def get_plugin_dashboard(self, pid: str, key: Optional[str] = None, **kwargs) -> Optional[schemas.PluginDashboard]:
|
||||
"""
|
||||
获取插件仪表盘
|
||||
:param pid: 插件ID
|
||||
:param key: 仪表盘key
|
||||
"""
|
||||
|
||||
def __get_params_count(func: Callable):
|
||||
"""
|
||||
获取函数的参数信息
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
return len(signature.parameters)
|
||||
|
||||
plugin = self._running_plugins.get(pid)
|
||||
if not plugin:
|
||||
return None
|
||||
if hasattr(plugin, "get_dashboard"):
|
||||
# 检查方法的参数个数
|
||||
params_count = __get_params_count(plugin.get_dashboard)
|
||||
if params_count > 1:
|
||||
dashboard: Tuple = plugin.get_dashboard(key=key, **kwargs)
|
||||
elif params_count > 0:
|
||||
dashboard: Tuple = plugin.get_dashboard(**kwargs)
|
||||
else:
|
||||
dashboard: Tuple = plugin.get_dashboard()
|
||||
if dashboard:
|
||||
cols, attrs, elements = dashboard
|
||||
return schemas.PluginDashboard(
|
||||
id=pid,
|
||||
name=plugin.plugin_name,
|
||||
key=key or "",
|
||||
cols=cols or {},
|
||||
elements=elements,
|
||||
attrs=attrs or {}
|
||||
)
|
||||
return None
|
||||
|
||||
def get_plugin_state(self, pid: str) -> bool:
|
||||
"""
|
||||
获取插件状态
|
||||
@@ -489,7 +467,9 @@ class PluginManager(metaclass=Singleton):
|
||||
}]
|
||||
"""
|
||||
ret_commands = []
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
# 创建字典快照避免并发修改
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_command") and ObjectUtils.check_method(plugin.get_command):
|
||||
@@ -517,16 +497,20 @@ class PluginManager(metaclass=Singleton):
|
||||
}]
|
||||
"""
|
||||
ret_apis = []
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
if pid:
|
||||
plugins = {pid: self._running_plugins.get(pid)}
|
||||
else:
|
||||
plugins = self._running_plugins
|
||||
for plugin_id, plugin in plugins.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_api") and ObjectUtils.check_method(plugin.get_api):
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
apis = plugin.get_api() or []
|
||||
for api in apis:
|
||||
api["path"] = f"/{plugin_id}{api['path']}"
|
||||
if not api.get("auth"):
|
||||
api["auth"] = "apikey"
|
||||
ret_apis.extend(apis)
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {plugin_id} API出错:{str(e)}")
|
||||
@@ -545,7 +529,9 @@ class PluginManager(metaclass=Singleton):
|
||||
}]
|
||||
"""
|
||||
ret_services = []
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
# 创建字典快照避免并发修改
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_service") and ObjectUtils.check_method(plugin.get_service):
|
||||
@@ -558,12 +544,105 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.error(f"获取插件 {plugin_id} 服务出错:{str(e)}")
|
||||
return ret_services
|
||||
|
||||
def get_plugin_dashboard_meta(self):
|
||||
def get_plugin_modules(self, pid: Optional[str] = None) -> Dict[tuple, Dict[str, Any]]:
|
||||
"""
|
||||
获取插件模块
|
||||
{
|
||||
plugin_id: {
|
||||
method: function
|
||||
}
|
||||
}
|
||||
"""
|
||||
ret_modules = {}
|
||||
# 创建字典快照避免并发修改
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_module") and ObjectUtils.check_method(plugin.get_module):
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
plugin_module = plugin.get_module() or []
|
||||
ret_modules[(plugin_id, plugin.get_name())] = plugin_module
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {plugin_id} 模块出错:{str(e)}")
|
||||
return ret_modules
|
||||
|
||||
def get_plugin_actions(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件动作
|
||||
[{
|
||||
"id": "动作ID",
|
||||
"name": "动作名称",
|
||||
"func": self.xxx,
|
||||
"kwargs": {} # 需要附加传递的参数
|
||||
}]
|
||||
"""
|
||||
ret_actions = []
|
||||
# 创建字典快照避免并发修改
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_actions") and ObjectUtils.check_method(plugin.get_actions):
|
||||
try:
|
||||
if not plugin.get_state():
|
||||
continue
|
||||
actions = plugin.get_actions()
|
||||
if actions:
|
||||
ret_actions.append({
|
||||
"plugin_id": plugin_id,
|
||||
"plugin_name": plugin.plugin_name,
|
||||
"actions": actions
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"获取插件 {plugin_id} 动作出错:{str(e)}")
|
||||
return ret_actions
|
||||
|
||||
@staticmethod
|
||||
def get_plugin_remote_entry(plugin_id: str, dist_path: str) -> str:
|
||||
"""
|
||||
获取插件的远程入口地址
|
||||
:param plugin_id: 插件 ID
|
||||
:param dist_path: 插件的分发路径
|
||||
:return: 远程入口地址
|
||||
"""
|
||||
if dist_path.startswith("/"):
|
||||
dist_path = dist_path[1:]
|
||||
if dist_path.endswith("/"):
|
||||
dist_path = dist_path[:-1]
|
||||
return f"/plugin/file/{plugin_id.lower()}/{dist_path}/remoteEntry.js"
|
||||
|
||||
def get_plugin_remotes(self, pid: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取插件联邦组件列表
|
||||
"""
|
||||
remotes = []
|
||||
# 创建字典快照避免并发修改
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if pid and pid != plugin_id:
|
||||
continue
|
||||
if hasattr(plugin, "get_render_mode"):
|
||||
render_mode, dist_path = plugin.get_render_mode()
|
||||
if render_mode != "vue":
|
||||
continue
|
||||
remotes.append({
|
||||
"id": plugin_id,
|
||||
"url": self.get_plugin_remote_entry(plugin_id, dist_path),
|
||||
"name": plugin.plugin_name,
|
||||
})
|
||||
return remotes
|
||||
|
||||
def get_plugin_dashboard_meta(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
获取所有插件仪表盘元信息
|
||||
"""
|
||||
dashboard_meta = []
|
||||
for plugin_id, plugin in self._running_plugins.items():
|
||||
# 创建字典快照避免并发修改
|
||||
running_plugins_snapshot = dict(self._running_plugins)
|
||||
for plugin_id, plugin in running_plugins_snapshot.items():
|
||||
if not hasattr(plugin, "get_dashboard") or not ObjectUtils.check_method(plugin.get_dashboard):
|
||||
continue
|
||||
try:
|
||||
@@ -588,6 +667,50 @@ class PluginManager(metaclass=Singleton):
|
||||
logger.error(f"获取插件[{plugin_id}]仪表盘元数据出错:{str(e)}")
|
||||
return dashboard_meta
|
||||
|
||||
def get_plugin_dashboard(self, pid: str, key: str, user_agent: str = None) -> schemas.PluginDashboard:
|
||||
"""
|
||||
获取插件仪表盘
|
||||
"""
|
||||
|
||||
def __get_params_count(func: Callable):
|
||||
"""
|
||||
获取函数的参数信息
|
||||
"""
|
||||
signature = inspect.signature(func)
|
||||
return len(signature.parameters)
|
||||
|
||||
# 获取插件实例
|
||||
plugin_instance = self.running_plugins.get(pid)
|
||||
if not plugin_instance:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"插件 {pid} 不存在或未加载")
|
||||
|
||||
# 渲染模式
|
||||
render_mode, _ = plugin_instance.get_render_mode()
|
||||
# 获取插件仪表板
|
||||
try:
|
||||
# 检查方法的参数个数
|
||||
params_count = __get_params_count(plugin_instance.get_dashboard)
|
||||
if params_count > 1:
|
||||
dashboard: Tuple = plugin_instance.get_dashboard(key=key, user_agent=user_agent)
|
||||
elif params_count > 0:
|
||||
dashboard: Tuple = plugin_instance.get_dashboard(user_agent=user_agent)
|
||||
else:
|
||||
dashboard: Tuple = plugin_instance.get_dashboard()
|
||||
except Exception as e:
|
||||
logger.error(f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"插件 {pid} 调用方法 get_dashboard 出错: {str(e)}")
|
||||
cols, attrs, elements = dashboard
|
||||
return schemas.PluginDashboard(
|
||||
id=pid,
|
||||
name=plugin_instance.plugin_name,
|
||||
key=key,
|
||||
render_mode=render_mode,
|
||||
cols=cols or {},
|
||||
attrs=attrs or {},
|
||||
elements=elements
|
||||
)
|
||||
|
||||
def get_plugin_attr(self, pid: str, attr: str) -> Any:
|
||||
"""
|
||||
获取插件属性
|
||||
@@ -628,7 +751,7 @@ class PluginManager(metaclass=Singleton):
|
||||
"""
|
||||
return list(self._running_plugins.keys())
|
||||
|
||||
def get_online_plugins(self) -> List[schemas.Plugin]:
|
||||
def get_online_plugins(self, force: bool = False) -> List[schemas.Plugin]:
|
||||
"""
|
||||
获取所有在线插件信息
|
||||
"""
|
||||
@@ -649,12 +772,13 @@ class PluginManager(metaclass=Singleton):
|
||||
if not m:
|
||||
continue
|
||||
# 提交任务获取 v1 版本插件,存储 future 到 version 的映射
|
||||
base_future = executor.submit(self.get_plugins_from_market, m, None)
|
||||
base_future = executor.submit(self.get_plugins_from_market, m, None, force)
|
||||
futures_to_version[base_future] = "base_version"
|
||||
|
||||
# 提交任务获取高版本插件(如 v2、v3),存储 future 到 version 的映射
|
||||
if settings.VERSION_FLAG:
|
||||
higher_version_future = executor.submit(self.get_plugins_from_market, m, settings.VERSION_FLAG)
|
||||
higher_version_future = executor.submit(self.get_plugins_from_market, m,
|
||||
settings.VERSION_FLAG, force)
|
||||
futures_to_version[higher_version_future] = "higher_version"
|
||||
|
||||
# 按照完成顺序处理结果
|
||||
@@ -695,7 +819,7 @@ class PluginManager(metaclass=Singleton):
|
||||
# 返回值
|
||||
plugins = []
|
||||
# 已安装插件
|
||||
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
for pid, plugin_class in self._plugins.items():
|
||||
# 运行状插件
|
||||
plugin_obj = self._running_plugins.get(pid)
|
||||
@@ -762,10 +886,11 @@ class PluginManager(metaclass=Singleton):
|
||||
return plugins
|
||||
|
||||
@staticmethod
|
||||
def is_plugin_exists(pid: str) -> bool:
|
||||
def is_plugin_exists(pid: str, version: str = None) -> bool:
|
||||
"""
|
||||
判断插件是否在本地包中存在
|
||||
判断插件是否存在,并满足版本要求(有传入version时)
|
||||
:param pid: 插件ID
|
||||
:param version: 插件版本
|
||||
"""
|
||||
if not pid:
|
||||
return False
|
||||
@@ -776,26 +901,41 @@ class PluginManager(metaclass=Singleton):
|
||||
spec = importlib.util.find_spec(package_name)
|
||||
package_exists = spec is not None and spec.origin is not None
|
||||
logger.debug(f"{pid} exists: {package_exists}")
|
||||
return package_exists
|
||||
if not package_exists:
|
||||
return False
|
||||
|
||||
local_version = PluginManager().get_plugin_attr(pid=pid, attr="plugin_version")
|
||||
if not local_version:
|
||||
return False
|
||||
|
||||
if version and not StringUtils.compare_version(local_version, ">=", version):
|
||||
logger.warn(f"Plugin {pid} version: {local_version} (older than version: {version})")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"获取插件是否在本地包中存在失败,{e}")
|
||||
return False
|
||||
|
||||
def get_plugins_from_market(self, market: str, package_version: Optional[str] = None) -> Optional[List[schemas.Plugin]]:
|
||||
def get_plugins_from_market(self, market: str,
|
||||
package_version: Optional[str] = None,
|
||||
force: bool = False) -> Optional[List[schemas.Plugin]]:
|
||||
"""
|
||||
从指定的市场获取插件信息
|
||||
:param market: 市场的 URL 或标识
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
:param force: 是否强制刷新(忽略缓存)
|
||||
:return: 返回插件的列表,若获取失败返回 []
|
||||
"""
|
||||
if not market:
|
||||
return []
|
||||
# 已安装插件
|
||||
installed_apps = self.systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
installed_apps = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
# 获取在线插件
|
||||
online_plugins = self.pluginhelper.get_plugins(market, package_version)
|
||||
online_plugins = PluginHelper().get_plugins(market, package_version, force)
|
||||
if online_plugins is None:
|
||||
logger.warning(f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接")
|
||||
logger.warning(
|
||||
f"获取{package_version if package_version else ''}插件库失败:{market},请检查 GitHub 网络连接")
|
||||
return []
|
||||
ret_plugins = []
|
||||
add_time = len(online_plugins)
|
||||
@@ -879,7 +1019,8 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
return ret_plugins
|
||||
|
||||
def __set_and_check_auth_level(self, plugin: Union[schemas.Plugin, Type[Any]],
|
||||
@staticmethod
|
||||
def __set_and_check_auth_level(plugin: Union[schemas.Plugin, Type[Any]],
|
||||
source: Optional[Union[dict, Type[Any]]] = None) -> bool:
|
||||
"""
|
||||
设置并检查插件的认证级别
|
||||
@@ -903,7 +1044,8 @@ class PluginManager(metaclass=Singleton):
|
||||
# 3 - 站点&密钥认证可见
|
||||
# 99 - 站点&特殊密钥认证可见
|
||||
# 如果当前站点认证级别大于 1 且插件级别为 99,并存在插件公钥,说明为特殊密钥认证,通过密钥匹配进行认证
|
||||
if self.siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"):
|
||||
siteshelper = SitesHelper()
|
||||
if siteshelper.auth_level > 1 and plugin.auth_level == 99 and hasattr(plugin, "plugin_public_key"):
|
||||
plugin_id = plugin.id if isinstance(plugin, schemas.Plugin) else plugin.__name__
|
||||
public_key = plugin.plugin_public_key
|
||||
if public_key:
|
||||
@@ -911,7 +1053,7 @@ class PluginManager(metaclass=Singleton):
|
||||
verify = RSAUtils.verify_rsa_keys(public_key=public_key, private_key=private_key)
|
||||
return verify
|
||||
# 如果当前站点认证级别小于插件级别,则返回 False
|
||||
if self.siteshelper.auth_level < plugin.auth_level:
|
||||
if siteshelper.auth_level < plugin.auth_level:
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -930,3 +1072,334 @@ class PluginManager(metaclass=Singleton):
|
||||
except Exception as e:
|
||||
logger.debug(f"获取插件 {plugin_id} 的私钥时发生错误:{e}")
|
||||
return None
|
||||
|
||||
def clone_plugin(self, plugin_id: str, suffix: str, name: str, description: str,
|
||||
version: str = None, icon: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
创建插件分身
|
||||
:param plugin_id: 原插件ID
|
||||
:param suffix: 分身后缀
|
||||
:param name: 分身名称
|
||||
:param description: 分身描述
|
||||
:param version: 自定义版本号
|
||||
:param icon: 自定义图标URL
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
try:
|
||||
# 验证参数
|
||||
if not plugin_id or not suffix:
|
||||
return False, "插件ID和分身后缀不能为空"
|
||||
|
||||
# 检查原插件是否存在
|
||||
if plugin_id not in self._plugins:
|
||||
return False, f"原插件 {plugin_id} 不存在"
|
||||
|
||||
# 生成分身插件ID
|
||||
clone_id = f"{plugin_id}{suffix.lower()}"
|
||||
|
||||
# 检查分身插件是否已存在
|
||||
if self.is_plugin_exists(clone_id):
|
||||
return False, f"分身插件 {clone_id} 已存在"
|
||||
|
||||
# 获取原插件目录
|
||||
original_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
|
||||
if not original_plugin_dir.exists():
|
||||
return False, f"原插件目录 {original_plugin_dir} 不存在"
|
||||
|
||||
# 创建分身插件目录
|
||||
clone_plugin_dir = Path(settings.ROOT_PATH) / "app" / "plugins" / clone_id.lower()
|
||||
|
||||
# 复制插件目录
|
||||
import shutil
|
||||
shutil.copytree(original_plugin_dir, clone_plugin_dir)
|
||||
logger.info(f"已复制插件目录:{original_plugin_dir} -> {clone_plugin_dir}")
|
||||
|
||||
# 修改插件文件内容
|
||||
success, msg = self._modify_plugin_files(
|
||||
plugin_dir=clone_plugin_dir,
|
||||
original_id=plugin_id,
|
||||
suffix=suffix.lower(),
|
||||
name=name,
|
||||
description=description,
|
||||
version=version,
|
||||
icon=icon
|
||||
)
|
||||
|
||||
if not success:
|
||||
# 如果修改失败,清理已创建的目录
|
||||
if clone_plugin_dir.exists():
|
||||
shutil.rmtree(clone_plugin_dir)
|
||||
return False, msg
|
||||
|
||||
# 将分身插件添加到已安装列表
|
||||
systemconfig = SystemConfigOper()
|
||||
installed_plugins = systemconfig.get(SystemConfigKey.UserInstalledPlugins) or []
|
||||
if clone_id not in installed_plugins:
|
||||
installed_plugins.append(clone_id)
|
||||
systemconfig.set(SystemConfigKey.UserInstalledPlugins, installed_plugins)
|
||||
|
||||
# 为分身插件创建初始配置(从原插件复制配置)
|
||||
logger.info(f"正在为分身插件 {clone_id} 创建初始配置...")
|
||||
original_config = self.get_plugin_config(plugin_id)
|
||||
if original_config:
|
||||
# 复制原插件配置作为分身插件的初始配置
|
||||
clone_config = original_config.copy()
|
||||
# 可以在这里修改一些默认值,比如禁用分身插件
|
||||
# 默认禁用分身插件,让用户手动配置
|
||||
clone_config['enable'] = False
|
||||
clone_config['enabled'] = False
|
||||
self.save_plugin_config(clone_id, clone_config, force=True)
|
||||
logger.info(f"已为分身插件 {clone_id} 设置初始配置")
|
||||
else:
|
||||
logger.info(f"原插件 {plugin_id} 没有配置,分身插件 {clone_id} 将使用默认配置")
|
||||
|
||||
# 注册分身插件的API和服务
|
||||
logger.info(f"正在注册分身插件 {clone_id} ...")
|
||||
PluginManager().reload_plugin(clone_id)
|
||||
# 确保分身插件正确初始化配置
|
||||
if clone_id in self._running_plugins:
|
||||
clone_instance = self._running_plugins[clone_id]
|
||||
clone_config = self.get_plugin_config(clone_id)
|
||||
if clone_config:
|
||||
logger.info(f"正在为分身插件 {clone_id} 重新初始化配置...")
|
||||
clone_instance.init_plugin(clone_config)
|
||||
logger.info(f"分身插件 {clone_id} 配置重新初始化完成")
|
||||
|
||||
logger.info(f"插件分身 {clone_id} 创建成功")
|
||||
return True, clone_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建插件分身失败:{str(e)}")
|
||||
return False, f"创建插件分身失败:{str(e)}"
|
||||
|
||||
def _modify_plugin_files(self, plugin_dir: Path, original_id: str, suffix: str,
|
||||
name: str, description: str, version: str = None,
|
||||
icon: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
修改插件文件中的类名和相关信息
|
||||
:param plugin_dir: 插件目录
|
||||
:param original_id: 原插件ID
|
||||
:param suffix: 分身后缀
|
||||
:param name: 分身名称
|
||||
:param description: 分身描述
|
||||
:param version: 自定义版本号
|
||||
:param icon: 自定义图标URL
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
try:
|
||||
# 获取原插件类
|
||||
original_plugin_class = self._plugins.get(original_id)
|
||||
if not original_plugin_class:
|
||||
return False, f"无法获取原插件类 {original_id}"
|
||||
|
||||
# 获取原类名
|
||||
original_class_name = original_plugin_class.__name__
|
||||
clone_class_name = f"{original_class_name}{suffix}"
|
||||
|
||||
# 修改 __init__.py 文件
|
||||
init_file = plugin_dir / "__init__.py"
|
||||
if init_file.exists():
|
||||
success, msg = self._modify_python_file(
|
||||
file_path=init_file,
|
||||
original_class_name=original_class_name,
|
||||
clone_class_name=clone_class_name,
|
||||
name=name,
|
||||
description=description,
|
||||
version=version,
|
||||
icon=icon
|
||||
)
|
||||
if not success:
|
||||
return False, msg
|
||||
|
||||
# 检查是否为联邦插件(存在dist目录)
|
||||
dist_dir = plugin_dir / "dist"
|
||||
if dist_dir.exists():
|
||||
success, msg = self._modify_federation_files(
|
||||
dist_dir=dist_dir,
|
||||
original_class_name=original_class_name,
|
||||
clone_class_name=clone_class_name
|
||||
)
|
||||
if not success:
|
||||
return False, msg
|
||||
|
||||
return True, "文件修改成功"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"修改插件文件失败:{str(e)}")
|
||||
return False, f"修改插件文件失败:{str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _modify_python_file(file_path: Path, original_class_name: str,
|
||||
clone_class_name: str, name: str, description: str,
|
||||
version: str = None, icon: str = None) -> Tuple[bool, str]:
|
||||
"""
|
||||
修改Python文件中的类名和插件信息
|
||||
"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换类名
|
||||
content = content.replace(f"class {original_class_name}", f"class {clone_class_name}")
|
||||
|
||||
# 替换插件名称和描述
|
||||
import re
|
||||
|
||||
# 替换 plugin_name
|
||||
if name:
|
||||
content = re.sub(
|
||||
r'plugin_name\s*=\s*["\'][^"\']*["\']',
|
||||
f'plugin_name = "{name}"',
|
||||
content
|
||||
)
|
||||
|
||||
# 替换 plugin_desc
|
||||
if description:
|
||||
content = re.sub(
|
||||
r'plugin_desc\s*=\s*["\'][^"\']*["\']',
|
||||
f'plugin_desc = "{description}"',
|
||||
content
|
||||
)
|
||||
|
||||
# 替换 plugin_config_prefix(如果存在)
|
||||
content = re.sub(
|
||||
r'plugin_config_prefix\s*=\s*["\'][^"\']*["\']',
|
||||
f'plugin_config_prefix = "{clone_class_name.lower()}_"',
|
||||
content
|
||||
)
|
||||
|
||||
# 替换 plugin_version(如果提供了自定义版本)
|
||||
if version:
|
||||
content = re.sub(
|
||||
r'plugin_version\s*=\s*["\'][^"\']*["\']',
|
||||
f'plugin_version = "{version}"',
|
||||
content
|
||||
)
|
||||
|
||||
# 替换 plugin_icon(如果提供了自定义图标)
|
||||
if icon and icon.strip():
|
||||
old_content = content
|
||||
content = re.sub(
|
||||
r'plugin_icon\s*=\s*["\'][^"\']*["\']',
|
||||
f'plugin_icon = "{icon}"',
|
||||
content
|
||||
)
|
||||
if old_content != content:
|
||||
logger.info(f"已替换插件图标为: {icon}")
|
||||
else:
|
||||
logger.warning(f"插件图标替换失败,未找到匹配的图标设置")
|
||||
else:
|
||||
logger.info("未提供自定义图标,保持原插件图标")
|
||||
|
||||
# 添加分身标志
|
||||
if "def init_plugin(self" in content:
|
||||
init_index = content.index("def init_plugin(self")
|
||||
# 在 def init_plugin(self 前添加 is_clone = True
|
||||
content = content[:init_index] + "is_clone = True\n\n " + content[init_index:]
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
logger.debug(f"已修改Python文件:{file_path}")
|
||||
return True, "Python文件修改成功"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"修改Python文件失败:{str(e)}")
|
||||
return False, f"修改Python文件失败:{str(e)}"
|
||||
|
||||
def _modify_federation_files(self, dist_dir: Path, original_class_name: str,
|
||||
clone_class_name: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
修改联邦插件的前端文件
|
||||
"""
|
||||
try:
|
||||
# 获取原始插件名(从类名推导)
|
||||
original_plugin_name = original_class_name
|
||||
clone_plugin_name = clone_class_name
|
||||
|
||||
# 遍历dist目录下的所有文件
|
||||
for file_path in dist_dir.rglob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
|
||||
# 处理JS文件
|
||||
if file_path.suffix == '.js':
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换类名引用(精确匹配)
|
||||
content = content.replace(original_class_name, clone_class_name)
|
||||
# 替换插件名引用(如果存在)
|
||||
content = content.replace(f'"{original_plugin_name}"', f'"{clone_plugin_name}"')
|
||||
content = content.replace(f"'{original_plugin_name}'", f"'{clone_plugin_name}'")
|
||||
# 替换CSS key中的类名(联邦插件特有)
|
||||
content = content.replace(f'css__{original_class_name}__', f'css__{clone_class_name}__')
|
||||
# 替换可能的小写类名引用
|
||||
content = content.replace(original_class_name.lower(), clone_class_name.lower())
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
logger.debug(f"已修改联邦插件JS文件:{file_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"修改联邦插件文件 {file_path} 失败:{str(e)}")
|
||||
continue
|
||||
|
||||
# 处理CSS文件
|
||||
elif file_path.suffix == '.css':
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换CSS中可能的类名引用
|
||||
content = content.replace(original_class_name.lower(), clone_class_name.lower())
|
||||
content = content.replace(original_class_name, clone_class_name)
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
logger.debug(f"已修改联邦插件CSS文件:{file_path}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"修改联邦插件CSS文件 {file_path} 失败:{str(e)}")
|
||||
continue
|
||||
|
||||
# 重命名构建文件(如果需要)
|
||||
self._rename_federation_assets(dist_dir, original_class_name, clone_class_name)
|
||||
|
||||
return True, "联邦插件文件修改完成"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"修改联邦插件文件失败:{str(e)}")
|
||||
return False, f"修改联邦插件文件失败:{str(e)}"
|
||||
|
||||
@staticmethod
|
||||
def _rename_federation_assets(dist_dir: Path, original_class_name: str, clone_class_name: str):
|
||||
"""
|
||||
重命名联邦插件的资源文件,避免文件名冲突
|
||||
"""
|
||||
try:
|
||||
# 查找包含原类名的文件并重命名
|
||||
for file_path in dist_dir.glob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
|
||||
file_name = file_path.name
|
||||
# 如果文件名包含原类名,则重命名
|
||||
if original_class_name.lower() in file_name.lower():
|
||||
new_name = file_name.replace(
|
||||
original_class_name.lower(),
|
||||
clone_class_name.lower()
|
||||
)
|
||||
new_path = file_path.parent / new_name
|
||||
|
||||
# 避免重命名冲突
|
||||
if not new_path.exists():
|
||||
file_path.rename(new_path)
|
||||
logger.debug(f"重命名联邦插件文件:{file_name} -> {new_name}")
|
||||
|
||||
except Exception as e:
|
||||
# 重命名失败不影响整体流程
|
||||
logger.warning(f"重命名联邦插件资源文件失败:{str(e)}")
|
||||
|
||||
@@ -236,7 +236,6 @@ class DbOper:
|
||||
"""
|
||||
数据库操作基类
|
||||
"""
|
||||
_db: Session = None
|
||||
|
||||
def __init__(self, db: Session = None):
|
||||
self._db = db
|
||||
|
||||
@@ -113,6 +113,7 @@ class DownloadHistoryOper(DbOper):
|
||||
season: Optional[str] = None, episode: Optional[str] = None, tmdbid=None) -> List[DownloadHistory]:
|
||||
"""
|
||||
按类型、标题、年份、季集查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
return DownloadHistory.get_last_by(db=self._db,
|
||||
mtype=mtype,
|
||||
|
||||
@@ -9,3 +9,4 @@ from .transferhistory import TransferHistory
|
||||
from .user import User
|
||||
from .userconfig import UserConfig
|
||||
from .workflow import Workflow
|
||||
from .userrequest import UserRequest
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON
|
||||
from sqlalchemy import Column, Integer, String, Sequence, JSON, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db import db_query, db_update, Base
|
||||
@@ -65,8 +65,11 @@ class DownloadHistory(Base):
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_by_mediaid(db: Session, tmdbid: int, doubanid: str):
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.doubanid == doubanid).all()
|
||||
if tmdbid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).all()
|
||||
elif doubanid:
|
||||
return db.query(DownloadHistory).filter(DownloadHistory.doubanid == doubanid).all()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
@@ -81,49 +84,58 @@ class DownloadHistory(Base):
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
def get_last_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
def get_last_by(db: Session, mtype: Optional[str] = None, title: Optional[str] = None,
|
||||
year: Optional[str] = None, season: Optional[str] = None,
|
||||
episode: Optional[str] = None, tmdbid: Optional[int] = None):
|
||||
"""
|
||||
据tmdbid、season、season_episode查询转移记录
|
||||
据tmdbid、season、season_episode查询下载记录
|
||||
tmdbid + mtype 或 title + year
|
||||
"""
|
||||
result = None
|
||||
if tmdbid and not season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
if tmdbid and season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧所有季集|电影
|
||||
if not season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
if season and not episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.type == mtype,
|
||||
DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# TMDBID + 类型
|
||||
if tmdbid and mtype:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.tmdbid == tmdbid,
|
||||
DownloadHistory.type == mtype).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 标题 + 年份
|
||||
elif title and year:
|
||||
# 电视剧某季某集
|
||||
if season and episode:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season,
|
||||
DownloadHistory.episodes == episode).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
# 电视剧某季
|
||||
elif season:
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year,
|
||||
DownloadHistory.seasons == season).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
else:
|
||||
# 电视剧所有季集/电影
|
||||
result = db.query(DownloadHistory).filter(DownloadHistory.title == title,
|
||||
DownloadHistory.year == year).order_by(
|
||||
DownloadHistory.id.desc()).all()
|
||||
|
||||
if result:
|
||||
return list(result)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
@db_query
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Union
|
||||
import copy
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from app.db import DbOper
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
@@ -7,34 +8,44 @@ from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
# 配置对象
|
||||
__SYSTEMCONF: dict = {}
|
||||
|
||||
"""
|
||||
系统配置管理
|
||||
"""
|
||||
def __init__(self):
|
||||
"""
|
||||
加载配置到内存
|
||||
"""
|
||||
super().__init__()
|
||||
self.__SYSTEMCONF = {}
|
||||
for item in SystemConfig.list(self._db):
|
||||
self.__SYSTEMCONF[item.key] = item.value
|
||||
|
||||
def set(self, key: Union[str, SystemConfigKey], value: Any):
|
||||
def set(self, key: Union[str, SystemConfigKey], value: Any) -> Optional[bool]:
|
||||
"""
|
||||
设置系统设置
|
||||
:param key: 配置键
|
||||
:param value: 配置值
|
||||
:return: 是否设置成功(True 成功/False 失败/None 无需更新)
|
||||
"""
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
# 更新内存
|
||||
self.__SYSTEMCONF[key] = value
|
||||
# 旧值
|
||||
old_value = self.__SYSTEMCONF.get(key)
|
||||
# 更新内存(deepcopy避免内存共享)
|
||||
self.__SYSTEMCONF[key] = copy.deepcopy(value)
|
||||
conf = SystemConfig.get_by_key(self._db, key)
|
||||
if conf:
|
||||
if value:
|
||||
conf.update(self._db, {"value": value})
|
||||
else:
|
||||
conf.delete(self._db, conf.id)
|
||||
if old_value != value:
|
||||
if value:
|
||||
conf.update(self._db, {"value": value})
|
||||
else:
|
||||
conf.delete(self._db, conf.id)
|
||||
return True
|
||||
return None
|
||||
else:
|
||||
conf = SystemConfig(key=key, value=value)
|
||||
conf.create(self._db)
|
||||
return True
|
||||
|
||||
def get(self, key: Union[str, SystemConfigKey] = None) -> Any:
|
||||
"""
|
||||
@@ -43,16 +54,18 @@ class SystemConfigOper(DbOper, metaclass=Singleton):
|
||||
if isinstance(key, SystemConfigKey):
|
||||
key = key.value
|
||||
if not key:
|
||||
return self.__SYSTEMCONF
|
||||
return self.__SYSTEMCONF.get(key)
|
||||
return self.all()
|
||||
# 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动
|
||||
return copy.deepcopy(self.__SYSTEMCONF.get(key))
|
||||
|
||||
def all(self):
|
||||
"""
|
||||
获取所有系统设置
|
||||
"""
|
||||
return self.__SYSTEMCONF or {}
|
||||
# 避免将__SYSTEMCONF内的值引用出去,会导致set时误判没有变动
|
||||
return copy.deepcopy(self.__SYSTEMCONF)
|
||||
|
||||
def delete(self, key: Union[str, SystemConfigKey]):
|
||||
def delete(self, key: Union[str, SystemConfigKey]) -> bool:
|
||||
"""
|
||||
删除系统设置
|
||||
"""
|
||||
|
||||
@@ -7,14 +7,15 @@ from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class UserConfigOper(DbOper, metaclass=Singleton):
|
||||
# 配置缓存
|
||||
__USERCONF: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
"""
|
||||
用户配置管理
|
||||
"""
|
||||
def __init__(self):
|
||||
"""
|
||||
加载配置到内存
|
||||
"""
|
||||
super().__init__()
|
||||
self.__USERCONF = {}
|
||||
for item in UserConfig.list(self._db):
|
||||
self.__set_config_cache(username=item.username, key=item.key, value=item.value)
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
from .doh import doh_query_json
|
||||
from .cloudflare import under_challenge
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from typing import Callable, Any, Optional
|
||||
|
||||
from playwright.sync_api import sync_playwright, Page
|
||||
from cf_clearance import sync_cf_retry, sync_stealth
|
||||
from playwright.sync_api import sync_playwright, Page
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
@@ -35,26 +36,41 @@ class PlaywrightHelper:
|
||||
:param headless: 是否无头模式
|
||||
:param timeout: 超时时间
|
||||
"""
|
||||
result = None
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright[self.browser_type].launch(headless=headless)
|
||||
context = browser.new_context(user_agent=ua, proxy=proxies)
|
||||
page = context.new_page()
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
browser = None
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
browser = playwright[self.browser_type].launch(headless=headless)
|
||||
context = browser.new_context(user_agent=ua, proxy=proxies)
|
||||
page = context.new_page()
|
||||
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
|
||||
if not self.__pass_cloudflare(url, page):
|
||||
logger.warn("cloudflare challenge fail!")
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
|
||||
# 回调函数
|
||||
return callback(page)
|
||||
result = callback(page)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"网页操作失败: {str(e)}")
|
||||
finally:
|
||||
browser.close()
|
||||
# 确保资源被正确清理
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
context.close()
|
||||
if browser:
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"网页操作失败: {str(e)}")
|
||||
return None
|
||||
logger.error(f"Playwright初始化失败: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
def get_page_source(self, url: str,
|
||||
cookies: Optional[str] = None,
|
||||
@@ -71,26 +87,40 @@ class PlaywrightHelper:
|
||||
:param headless: 是否无头模式
|
||||
:param timeout: 超时时间
|
||||
"""
|
||||
source = ""
|
||||
source = None
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright[self.browser_type].launch(headless=headless)
|
||||
context = browser.new_context(user_agent=ua, proxy=proxies)
|
||||
page = context.new_page()
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
browser = None
|
||||
context = None
|
||||
page = None
|
||||
try:
|
||||
browser = playwright[self.browser_type].launch(headless=headless)
|
||||
context = browser.new_context(user_agent=ua, proxy=proxies)
|
||||
page = context.new_page()
|
||||
|
||||
if cookies:
|
||||
page.set_extra_http_headers({"cookie": cookies})
|
||||
|
||||
if not self.__pass_cloudflare(url, page):
|
||||
logger.warn("cloudflare challenge fail!")
|
||||
page.wait_for_load_state("networkidle", timeout=timeout * 1000)
|
||||
|
||||
source = page.content()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取网页源码失败: {str(e)}")
|
||||
source = None
|
||||
finally:
|
||||
browser.close()
|
||||
# 确保资源被正确清理
|
||||
if page:
|
||||
page.close()
|
||||
if context:
|
||||
context.close()
|
||||
if browser:
|
||||
browser.close()
|
||||
except Exception as e:
|
||||
logger.error(f"获取网页源码失败: {str(e)}")
|
||||
logger.error(f"Playwright初始化失败: {str(e)}")
|
||||
|
||||
return source
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ class CookieCloudHelper:
|
||||
|
||||
def __init__(self):
|
||||
self.__sync_setting()
|
||||
self._req = RequestUtils(content_type="application/json")
|
||||
|
||||
def __sync_setting(self):
|
||||
"""
|
||||
@@ -46,7 +45,7 @@ class CookieCloudHelper:
|
||||
return {}, "未从本地CookieCloud服务加载到cookie数据,请检查服务器设置、用户KEY及加密密码是否正确"
|
||||
else:
|
||||
req_url = UrlUtils.combine_url(host=self._server, path=f"get/{self._key}")
|
||||
ret = self._req.get_res(url=req_url)
|
||||
ret = RequestUtils(content_type="application/json").get_res(url=req_url)
|
||||
if ret and ret.status_code == 200:
|
||||
try:
|
||||
result = ret.json()
|
||||
|
||||
@@ -13,14 +13,12 @@ class DirectoryHelper:
|
||||
下载目录/媒体库目录帮助类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def get_dirs(self) -> List[schemas.TransferDirectoryConf]:
|
||||
@staticmethod
|
||||
def get_dirs() -> List[schemas.TransferDirectoryConf]:
|
||||
"""
|
||||
获取所有下载目录
|
||||
"""
|
||||
dir_confs: List[dict] = self.systemconfig.get(SystemConfigKey.Directories)
|
||||
dir_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Directories)
|
||||
if not dir_confs:
|
||||
return []
|
||||
return [schemas.TransferDirectoryConf(**d) for d in dir_confs]
|
||||
|
||||
@@ -10,10 +10,15 @@ import socket
|
||||
import struct
|
||||
import urllib
|
||||
import urllib.request
|
||||
from threading import Lock
|
||||
from typing import Dict, Optional
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import Event, eventmanager
|
||||
from app.log import logger
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
# 定义一个全局线程池执行器
|
||||
_executor = concurrent.futures.ThreadPoolExecutor()
|
||||
@@ -21,41 +26,63 @@ _executor = concurrent.futures.ThreadPoolExecutor()
|
||||
# 定义默认的DoH配置
|
||||
_doh_timeout = 5
|
||||
_doh_cache: Dict[str, str] = {}
|
||||
_doh_lock = Lock()
|
||||
# 保存原始的 socket.getaddrinfo 方法
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
def _patched_getaddrinfo(host, *args, **kwargs):
|
||||
def enable_doh(enable: bool):
|
||||
"""
|
||||
socket.getaddrinfo的补丁版本。
|
||||
对 socket.getaddrinfo 进行补丁
|
||||
"""
|
||||
if host not in settings.DOH_DOMAINS.split(","):
|
||||
|
||||
def _patched_getaddrinfo(host, *args, **kwargs):
|
||||
"""
|
||||
socket.getaddrinfo的补丁版本。
|
||||
"""
|
||||
if host not in settings.DOH_DOMAINS.split(","):
|
||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||
# 检查主机是否已解析
|
||||
with _doh_lock:
|
||||
ip = _doh_cache.get("host", None)
|
||||
if ip is not None:
|
||||
logger.info("已解析 [%s] 为 [%s] (缓存)", host, ip)
|
||||
return _orig_getaddrinfo(ip, *args, **kwargs)
|
||||
# 使用DoH解析主机
|
||||
futures = []
|
||||
for resolver in settings.DOH_RESOLVERS.split(","):
|
||||
futures.append(_executor.submit(_doh_query, resolver, host))
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
ip = future.result()
|
||||
if ip is not None:
|
||||
logger.info("已解析 [%s] 为 [%s]", host, ip)
|
||||
with _doh_lock:
|
||||
_doh_cache[host] = ip
|
||||
host = ip
|
||||
break
|
||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||
|
||||
# 检查主机是否已解析
|
||||
if host in _doh_cache:
|
||||
ip = _doh_cache[host]
|
||||
logger.info("已解析 [%s] 为 [%s] (缓存)", host, ip)
|
||||
return _orig_getaddrinfo(ip, *args, **kwargs)
|
||||
if enable:
|
||||
# 替换 socket.getaddrinfo 方法
|
||||
socket.getaddrinfo = _patched_getaddrinfo
|
||||
else:
|
||||
socket.getaddrinfo = _orig_getaddrinfo
|
||||
|
||||
# 使用DoH解析主机
|
||||
futures = []
|
||||
for resolver in settings.DOH_RESOLVERS.split(","):
|
||||
futures.append(_executor.submit(_doh_query, resolver, host))
|
||||
class DohHelper(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
enable_doh(settings.DOH_ENABLE)
|
||||
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
ip = future.result()
|
||||
if ip is not None:
|
||||
logger.info("已解析 [%s] 为 [%s]", host, ip)
|
||||
_doh_cache[host] = ip
|
||||
host = ip
|
||||
break
|
||||
|
||||
return _orig_getaddrinfo(host, *args, **kwargs)
|
||||
|
||||
|
||||
# 对 socket.getaddrinfo 进行补丁
|
||||
if settings.DOH_ENABLE:
|
||||
_orig_getaddrinfo = socket.getaddrinfo
|
||||
socket.getaddrinfo = _patched_getaddrinfo
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
if not event:
|
||||
return
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ["DOH_ENABLE", "DOH_DOMAINS", "DOH_RESOLVERS"]:
|
||||
return
|
||||
with _doh_lock:
|
||||
# DOH配置有变动的情况下,清空缓存
|
||||
_doh_cache.clear()
|
||||
enable_doh(settings.DOH_ENABLE)
|
||||
|
||||
|
||||
def _doh_query(resolver: str, host: str) -> Optional[str]:
|
||||
|
||||
457
app/helper/memory.py
Normal file
457
app/helper/memory.py
Normal file
@@ -0,0 +1,457 @@
|
||||
import gc
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
from pympler import muppy, summary, asizeof
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class MemoryHelper(metaclass=Singleton):
|
||||
"""
|
||||
内存管理工具类,用于监控和优化内存使用
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 检查间隔(秒) - 从配置获取,默认5分钟
|
||||
self._check_interval = settings.MEMORY_SNAPSHOT_INTERVAL * 60
|
||||
self._monitoring = False
|
||||
self._monitor_thread: Optional[threading.Thread] = None
|
||||
# 内存快照保存目录
|
||||
self._memory_snapshot_dir = settings.LOG_PATH / "memory_snapshots"
|
||||
# 保留的快照文件数量
|
||||
self._keep_count = settings.MEMORY_SNAPSHOT_KEEP_COUNT
|
||||
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
"""
|
||||
处理配置变更事件,更新内存监控设置
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ['MEMORY_ANALYSIS', 'MEMORY_SNAPSHOT_INTERVAL', 'MEMORY_SNAPSHOT_KEEP_COUNT']:
|
||||
return
|
||||
|
||||
# 更新配置
|
||||
if event_data.key == 'MEMORY_SNAPSHOT_INTERVAL':
|
||||
self._check_interval = settings.MEMORY_SNAPSHOT_INTERVAL * 60
|
||||
elif event_data.key == 'MEMORY_SNAPSHOT_KEEP_COUNT':
|
||||
self._keep_count = settings.MEMORY_SNAPSHOT_KEEP_COUNT
|
||||
self.stop_monitoring()
|
||||
self.start_monitoring()
|
||||
|
||||
def start_monitoring(self):
|
||||
"""
|
||||
开始内存监控
|
||||
"""
|
||||
if not settings.MEMORY_ANALYSIS:
|
||||
return
|
||||
if self._monitoring:
|
||||
return
|
||||
|
||||
# 创建内存快照目录
|
||||
self._memory_snapshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 初始化内存分析器
|
||||
self._monitoring = True
|
||||
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
logger.info("内存监控已启动")
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""
|
||||
停止内存监控
|
||||
"""
|
||||
self._monitoring = False
|
||||
if self._monitor_thread:
|
||||
self._monitor_thread.join(timeout=5)
|
||||
logger.info("内存监控已停止")
|
||||
|
||||
def _monitor_loop(self):
|
||||
"""
|
||||
内存监控循环
|
||||
"""
|
||||
logger.info("内存监控循环开始")
|
||||
while self._monitoring:
|
||||
try:
|
||||
# 生成内存快照
|
||||
self._create_memory_snapshot()
|
||||
time.sleep(self._check_interval)
|
||||
except Exception as e:
|
||||
logger.error(f"内存监控出错: {e}")
|
||||
# 出错后等待1分钟再继续
|
||||
time.sleep(60)
|
||||
logger.info("内存监控循环结束")
|
||||
|
||||
def _create_memory_snapshot(self):
|
||||
"""
|
||||
创建内存快照并保存到文件
|
||||
"""
|
||||
try:
|
||||
# 获取当前时间戳
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
snapshot_file = self._memory_snapshot_dir / f"memory_snapshot_{timestamp}.txt"
|
||||
|
||||
# 获取系统内存使用情况
|
||||
memory_usage = psutil.Process().memory_info().rss
|
||||
|
||||
logger.info(f"开始创建内存快照: {snapshot_file}")
|
||||
|
||||
# 第一步:写入基本信息和对象类型统计
|
||||
self._write_basic_info(snapshot_file, memory_usage)
|
||||
|
||||
# 第二步:分析并写入类实例内存使用情况
|
||||
self._append_class_analysis(snapshot_file)
|
||||
|
||||
# 第三步:分析并写入大内存变量详情
|
||||
self._append_variable_analysis(snapshot_file)
|
||||
|
||||
logger.info(f"内存快照已保存: {snapshot_file}, 当前内存使用: {memory_usage / 1024 / 1024:.2f} MB")
|
||||
|
||||
# 清理过期的快照文件(保留最近30个)
|
||||
self._cleanup_old_snapshots()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建内存快照失败: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _write_basic_info(snapshot_file, memory_usage):
|
||||
"""
|
||||
写入基本信息和对象类型统计
|
||||
"""
|
||||
# 获取当前进程的内存使用情况
|
||||
all_objects = muppy.get_objects()
|
||||
sum1 = summary.summarize(all_objects)
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(f"内存快照时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||||
f.write(f"当前进程内存使用: {memory_usage / 1024 / 1024:.2f} MB\n")
|
||||
f.write("=" * 80 + "\n")
|
||||
f.write("对象类型统计:\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
|
||||
# 写入对象统计信息
|
||||
for line in summary.format_(sum1):
|
||||
f.write(line + "\n")
|
||||
|
||||
# 立即刷新到磁盘
|
||||
f.flush()
|
||||
|
||||
logger.debug("基本信息已写入快照文件")
|
||||
|
||||
def _append_class_analysis(self, snapshot_file):
|
||||
"""
|
||||
分析并追加类实例内存使用情况
|
||||
"""
|
||||
with open(snapshot_file, 'a', encoding='utf-8') as f:
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
f.write("类实例内存使用情况 (按内存大小排序):\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
f.write("正在分析中...\n")
|
||||
# 立即刷新,让用户知道这部分开始了
|
||||
f.flush()
|
||||
|
||||
try:
|
||||
logger.debug("开始分析类实例内存使用情况")
|
||||
class_objects = self._get_class_memory_usage()
|
||||
|
||||
# 重新打开文件,移除"正在分析中..."并写入实际结果
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换"正在分析中..."
|
||||
content = content.replace("正在分析中...\n", "")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
if class_objects:
|
||||
# 只显示前100个类
|
||||
for i, class_info in enumerate(class_objects[:100], 1):
|
||||
f.write(f"{i:3d}. {class_info['name']:<50} "
|
||||
f"{class_info['size_mb']:>8.2f} MB ({class_info['count']} 个实例)\n")
|
||||
else:
|
||||
f.write("未找到有效的类实例信息\n")
|
||||
|
||||
f.flush()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取类实例信息失败: {e}")
|
||||
|
||||
# 即使出错也要更新文件
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace("正在分析中...\n", f"获取类实例信息失败: {e}\n")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
|
||||
logger.debug("类实例分析已完成并写入")
|
||||
|
||||
def _append_variable_analysis(self, snapshot_file):
|
||||
"""
|
||||
分析并追加大内存变量详情
|
||||
"""
|
||||
with open(snapshot_file, 'a', encoding='utf-8') as f:
|
||||
f.write("\n" + "=" * 80 + "\n")
|
||||
f.write("大内存变量详情 (前100个):\n")
|
||||
f.write("-" * 80 + "\n")
|
||||
f.write("正在分析中...\n")
|
||||
# 立即刷新,让用户知道这部分开始了
|
||||
f.flush()
|
||||
|
||||
try:
|
||||
logger.debug("开始分析大内存变量")
|
||||
large_variables = self._get_large_variables(100)
|
||||
|
||||
# 重新打开文件,移除"正在分析中..."并写入实际结果
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# 替换最后的"正在分析中..."
|
||||
content = content.replace("正在分析中...\n", "")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
if large_variables:
|
||||
for i, var_info in enumerate(large_variables, 1):
|
||||
f.write(
|
||||
f"{i:3d}. {var_info['name']:<30} {var_info['type']:<15} {var_info['size_mb']:>8.2f} MB\n")
|
||||
else:
|
||||
f.write("未找到大内存变量\n")
|
||||
|
||||
f.flush()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取大内存变量信息失败: {e}")
|
||||
|
||||
# 即使出错也要更新文件
|
||||
with open(snapshot_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
content = content.replace("正在分析中...\n", f"获取变量信息失败: {e}\n")
|
||||
|
||||
with open(snapshot_file, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
f.flush()
|
||||
|
||||
logger.debug("大内存变量分析已完成并写入")
|
||||
|
||||
def _cleanup_old_snapshots(self):
|
||||
"""
|
||||
清理过期的内存快照文件,只保留最近的指定数量文件
|
||||
"""
|
||||
try:
|
||||
snapshot_files = list(self._memory_snapshot_dir.glob("memory_snapshot_*.txt"))
|
||||
if len(snapshot_files) > self._keep_count:
|
||||
# 按修改时间排序,删除最旧的文件
|
||||
snapshot_files.sort(key=lambda x: x.stat().st_mtime)
|
||||
for old_file in snapshot_files[:-self._keep_count]:
|
||||
old_file.unlink()
|
||||
logger.debug(f"已删除过期内存快照: {old_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"清理过期快照失败: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _get_class_memory_usage():
|
||||
"""
|
||||
获取所有类实例的内存使用情况,按内存大小排序
|
||||
"""
|
||||
class_info = {}
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
|
||||
# 获取所有对象
|
||||
all_objects = muppy.get_objects()
|
||||
logger.debug(f"开始分析 {len(all_objects)} 个对象的类实例内存使用情况")
|
||||
|
||||
for obj in all_objects:
|
||||
try:
|
||||
# 跳过类对象本身,统计类的实例
|
||||
if isinstance(obj, type):
|
||||
continue
|
||||
|
||||
# 获取对象的类名 - 这里可能会出错
|
||||
obj_class = type(obj)
|
||||
|
||||
# 安全地获取类名
|
||||
try:
|
||||
if hasattr(obj_class, '__module__') and hasattr(obj_class, '__name__'):
|
||||
class_name = f"{obj_class.__module__}.{obj_class.__name__}"
|
||||
else:
|
||||
class_name = str(obj_class)
|
||||
except Exception as e:
|
||||
# 如果获取类名失败,使用简单的类型描述
|
||||
class_name = f"<unknown_class_{id(obj_class)}>"
|
||||
logger.debug(f"获取类名失败: {e}")
|
||||
|
||||
# 计算对象本身的内存使用(不包括引用对象,避免重复计算)
|
||||
size_bytes = sys.getsizeof(obj)
|
||||
if size_bytes < 100: # 跳过太小的对象
|
||||
continue
|
||||
|
||||
size_mb = size_bytes / 1024 / 1024
|
||||
processed_count += 1
|
||||
|
||||
if class_name in class_info:
|
||||
class_info[class_name]['size_mb'] += size_mb
|
||||
class_info[class_name]['count'] += 1
|
||||
else:
|
||||
class_info[class_name] = {
|
||||
'name': class_name,
|
||||
'size_mb': size_mb,
|
||||
'count': 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 捕获所有可能的异常,包括SQLAlchemy、ORM等框架的异常
|
||||
error_count += 1
|
||||
if error_count <= 5: # 只记录前5个错误,避免日志过多
|
||||
logger.debug(f"分析对象时出错: {e}")
|
||||
continue
|
||||
|
||||
logger.debug(f"类实例分析完成: 处理了 {processed_count} 个对象, 遇到 {error_count} 个错误")
|
||||
|
||||
# 按内存大小排序
|
||||
sorted_classes = sorted(class_info.values(), key=lambda x: x['size_mb'], reverse=True)
|
||||
return sorted_classes
|
||||
|
||||
def _get_large_variables(self, limit=100):
|
||||
"""
|
||||
获取大内存变量信息,按内存大小排序
|
||||
使用已计算对象集合避免重复计算
|
||||
"""
|
||||
large_vars = []
|
||||
processed_count = 0
|
||||
calculated_objects = set() # 避免重复计算
|
||||
|
||||
# 获取所有对象
|
||||
all_objects = muppy.get_objects()
|
||||
logger.debug(f"开始分析 {len(all_objects)} 个对象的内存使用情况")
|
||||
|
||||
for obj in all_objects:
|
||||
# 跳过类对象
|
||||
if isinstance(obj, type):
|
||||
continue
|
||||
|
||||
# 跳过已经计算过的对象
|
||||
obj_id = id(obj)
|
||||
if obj_id in calculated_objects:
|
||||
continue
|
||||
|
||||
try:
|
||||
# 首先使用 sys.getsizeof 快速筛选
|
||||
shallow_size = sys.getsizeof(obj)
|
||||
if shallow_size < 1024: # 只处理大于1KB的对象
|
||||
continue
|
||||
|
||||
# 对于较大的对象,使用 asizeof 进行深度计算
|
||||
size_bytes = asizeof.asizeof(obj)
|
||||
|
||||
# 只处理大于10KB的对象,提高分析效率
|
||||
if size_bytes < 10240:
|
||||
continue
|
||||
|
||||
size_mb = size_bytes / 1024 / 1024
|
||||
processed_count += 1
|
||||
calculated_objects.add(obj_id)
|
||||
|
||||
# 获取对象信息
|
||||
var_info = self._get_variable_info(obj, size_mb)
|
||||
if var_info:
|
||||
large_vars.append(var_info)
|
||||
|
||||
# 如果已经找到足够多的大对象,可以提前结束
|
||||
if len(large_vars) >= limit * 2: # 多收集一些,后面排序筛选
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# 更广泛的异常捕获
|
||||
logger.debug(f"分析对象失败: {e}")
|
||||
continue
|
||||
|
||||
logger.debug(f"处理了 {processed_count} 个大对象,找到 {len(large_vars)} 个有效变量")
|
||||
|
||||
# 按内存大小排序并返回前N个
|
||||
large_vars.sort(key=lambda x: x['size_mb'], reverse=True)
|
||||
return large_vars[:limit]
|
||||
|
||||
def _get_variable_info(self, obj, size_mb):
|
||||
"""
|
||||
获取变量的描述信息
|
||||
"""
|
||||
try:
|
||||
obj_type = type(obj).__name__
|
||||
|
||||
# 尝试获取变量名
|
||||
var_name = self._get_variable_name(obj)
|
||||
|
||||
# 生成描述性信息
|
||||
if isinstance(obj, dict):
|
||||
key_count = len(obj)
|
||||
if key_count > 0:
|
||||
sample_keys = list(obj.keys())[:3]
|
||||
var_name += f" ({key_count}项, 键: {sample_keys})"
|
||||
elif isinstance(obj, (list, tuple, set)):
|
||||
var_name += f" ({len(obj)}个元素)"
|
||||
elif isinstance(obj, str):
|
||||
if len(obj) > 50:
|
||||
var_name += f" (长度: {len(obj)}, 内容: '{obj[:50]}...')"
|
||||
else:
|
||||
var_name += f" ('{obj}')"
|
||||
elif hasattr(obj, '__class__') and hasattr(obj.__class__, '__name__'):
|
||||
if hasattr(obj, '__dict__'):
|
||||
attr_count = len(obj.__dict__)
|
||||
var_name += f" ({attr_count}个属性)"
|
||||
|
||||
return {
|
||||
'name': var_name,
|
||||
'type': obj_type,
|
||||
'size_mb': size_mb
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取变量信息失败: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _get_variable_name(obj):
|
||||
"""
|
||||
尝试获取变量名
|
||||
"""
|
||||
try:
|
||||
# 尝试通过gc获取引用该对象的变量名
|
||||
referrers = gc.get_referrers(obj)
|
||||
|
||||
for referrer in referrers:
|
||||
if isinstance(referrer, dict):
|
||||
# 检查是否在某个模块的全局变量中
|
||||
for name, value in referrer.items():
|
||||
if value is obj and isinstance(name, str):
|
||||
return name
|
||||
elif hasattr(referrer, '__dict__'):
|
||||
# 检查是否在某个实例的属性中
|
||||
for name, value in referrer.__dict__.items():
|
||||
if value is obj and isinstance(name, str):
|
||||
return f"{type(referrer).__name__}.{name}"
|
||||
|
||||
# 如果找不到变量名,返回对象类型和id
|
||||
return f"{type(obj).__name__}_{id(obj)}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取变量名失败: {e}")
|
||||
return f"{type(obj).__name__}_{id(obj)}"
|
||||
@@ -1,18 +1,537 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import queue
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Union
|
||||
from typing import List, Optional, Callable
|
||||
from typing import Any, Literal, Optional, List, Dict, Union
|
||||
from typing import Callable
|
||||
|
||||
from cachetools import TTLCache
|
||||
from jinja2 import Template
|
||||
|
||||
from app.core.config import global_vars
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.message import Notification
|
||||
from app.schemas.tmdb import TmdbEpisode
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.utils.singleton import Singleton, SingletonClass
|
||||
from app.log import logger
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
|
||||
class TemplateContextBuilder:
|
||||
"""
|
||||
模板上下文构建器
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._context = {}
|
||||
|
||||
def build(
|
||||
self,
|
||||
meta: Optional[MetaBase] = None,
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
torrentinfo: Optional[TorrentInfo] = None,
|
||||
transferinfo: Optional[TransferInfo] = None,
|
||||
file_extension: Optional[str] = None,
|
||||
episodes_info: Optional[List[TmdbEpisode]] = None,
|
||||
include_raw_objects: bool = True,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
:param meta: 媒体信息
|
||||
:param mediainfo: 媒体信息
|
||||
:param torrentinfo: 种子信息
|
||||
:param transferinfo: 传输信息
|
||||
:param file_extension: 文件扩展名
|
||||
:param episodes_info: 剧集信息
|
||||
:param include_raw_objects: 是否包含原始对象
|
||||
:return: 渲染上下文字典
|
||||
"""
|
||||
self._context.clear()
|
||||
self._add_episode_details(meta, episodes_info)
|
||||
self._add_media_info(mediainfo)
|
||||
self._add_transfer_info(transferinfo)
|
||||
self._add_torrent_info(torrentinfo)
|
||||
self._add_file_info(file_extension)
|
||||
if kwargs:
|
||||
self._context.update(kwargs)
|
||||
|
||||
if include_raw_objects:
|
||||
self._add_raw_objects(meta, mediainfo, torrentinfo, transferinfo, episodes_info)
|
||||
|
||||
# 移除空值
|
||||
return {k: v for k, v in self._context.items() if v is not None}
|
||||
|
||||
def _add_media_info(self, mediainfo: MediaInfo):
|
||||
"""
|
||||
增加媒体信息
|
||||
"""
|
||||
if not mediainfo:
|
||||
return
|
||||
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
|
||||
base_info = {
|
||||
# 标题
|
||||
"title": self.__convert_invalid_characters(mediainfo.title),
|
||||
# 英文标题
|
||||
"en_title": self.__convert_invalid_characters(mediainfo.en_title),
|
||||
# 原语种标题
|
||||
"original_title": self.__convert_invalid_characters(mediainfo.original_title),
|
||||
# 季号
|
||||
"season": self._context.get("season") or mediainfo.season,
|
||||
# Sxx
|
||||
"season_fmt": self._context.get("season_fmt") or season_fmt,
|
||||
# 年份
|
||||
"year": mediainfo.year or self._context.get("year"),
|
||||
# 媒体标题 + 年份
|
||||
"title_year": mediainfo.title_year or self._context.get("title_year"),
|
||||
}
|
||||
|
||||
_meta_season = self._context.get("season")
|
||||
media_info = {
|
||||
# 类型
|
||||
"type": mediainfo.type.value,
|
||||
# 类别
|
||||
"category": mediainfo.category,
|
||||
# 评分
|
||||
"vote_average": mediainfo.vote_average,
|
||||
# 海报
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
# 背景图
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
# 季年份根据season值获取
|
||||
"season_year": mediainfo.season_years.get(
|
||||
int(_meta_season),
|
||||
None) if (mediainfo.season_years and _meta_season) else None,
|
||||
# 演员
|
||||
"actors": '、 '.join([actor['name'] for actor in mediainfo.actors[:5]]),
|
||||
# 简介
|
||||
"overview": mediainfo.overview,
|
||||
# TMDBID
|
||||
"tmdbid": mediainfo.tmdb_id,
|
||||
# IMDBID
|
||||
"imdbid": mediainfo.imdb_id,
|
||||
# 豆瓣ID
|
||||
"doubanid": mediainfo.douban_id,
|
||||
}
|
||||
self._context.update({**base_info, **media_info})
|
||||
|
||||
def _add_episode_details(self, meta: Optional[MetaBase], episodes: Optional[List[TmdbEpisode]]):
|
||||
"""
|
||||
添加剧集详细信息
|
||||
"""
|
||||
if not meta:
|
||||
return
|
||||
|
||||
episode_data = {"episode_title": None, "episode_date": None}
|
||||
if meta.begin_episode and episodes:
|
||||
for episode in episodes:
|
||||
if episode.episode_number == meta.begin_episode:
|
||||
episode_data.update({
|
||||
"episode_title": self.__convert_invalid_characters(episode.name),
|
||||
"episode_date": episode.air_date if episode.air_date else None
|
||||
})
|
||||
break
|
||||
|
||||
meta_info = {
|
||||
# 原文件名
|
||||
"original_name": meta.title,
|
||||
# 识别名称(优先使用中文)
|
||||
"name": meta.name,
|
||||
# 识别的英文名称(可能为空)
|
||||
"en_name": meta.en_name,
|
||||
# 年份
|
||||
"year": meta.year,
|
||||
# 名字 + 年份
|
||||
"title_year": self._context.get("title_year") or "%s (%s)" % (
|
||||
meta.name, meta.year) if meta.year else meta.name,
|
||||
# 季号
|
||||
"season": meta.season_seq,
|
||||
# Sxx
|
||||
"season_fmt": meta.season,
|
||||
# 集号
|
||||
"episode": meta.episode_seqs,
|
||||
# 季集 SxxExx
|
||||
"season_episode": "%s%s" % (meta.season, meta.episode),
|
||||
# 段/节
|
||||
"part": meta.part,
|
||||
# 自定义占位符
|
||||
"customization": meta.customization,
|
||||
}
|
||||
|
||||
tech_metadata = {
|
||||
# 资源类型
|
||||
"resourceType": meta.resource_type,
|
||||
# 特效
|
||||
"effect": meta.resource_effect,
|
||||
# 版本
|
||||
"edition": meta.edition,
|
||||
# 分辨率
|
||||
"videoFormat": meta.resource_pix,
|
||||
# 质量
|
||||
"resource_term": meta.resource_term,
|
||||
# 制作组/字幕组
|
||||
"releaseGroup": meta.resource_team,
|
||||
# 视频编码
|
||||
"videoCodec": meta.video_encode,
|
||||
# 音频编码
|
||||
"audioCodec": meta.audio_encode,
|
||||
}
|
||||
self._context.update({**meta_info, **tech_metadata, **episode_data})
|
||||
|
||||
def _add_torrent_info(self, torrentinfo: Optional[TorrentInfo]):
|
||||
"""
|
||||
添加种子信息
|
||||
"""
|
||||
if not torrentinfo:
|
||||
return
|
||||
if torrentinfo.size:
|
||||
if str(torrentinfo.size).replace(".", "").isdigit():
|
||||
size = StringUtils.str_filesize(torrentinfo.size)
|
||||
else:
|
||||
size = torrentinfo.size
|
||||
else:
|
||||
size = 0
|
||||
|
||||
if torrentinfo.description:
|
||||
html_re = re.compile(r'<[^>]+>', re.S)
|
||||
description = html_re.sub('', torrentinfo.description)
|
||||
torrentinfo.description = re.sub(r'<[^>]+>', '', description)
|
||||
|
||||
torrent_info = {
|
||||
# 种子标题
|
||||
"torrent_title": torrentinfo.title,
|
||||
# 发布时间
|
||||
"pubdate": torrentinfo.pubdate,
|
||||
# 免费剩余时间
|
||||
"freedate": torrentinfo.freedate_diff,
|
||||
# 做种数
|
||||
"seeders": torrentinfo.seeders,
|
||||
# 促销信息
|
||||
"volume_factor": torrentinfo.volume_factor,
|
||||
# Hit&Run
|
||||
"hit_and_run": "是" if torrentinfo.hit_and_run else "否",
|
||||
# 种子标签
|
||||
"labels": ' '.join(torrentinfo.labels),
|
||||
# 描述
|
||||
"description": torrentinfo.description,
|
||||
# 站点名称
|
||||
"site_name": torrentinfo.site_name,
|
||||
# 种子大小
|
||||
"size": size,
|
||||
}
|
||||
self._context.update(torrent_info)
|
||||
|
||||
def _add_transfer_info(self, transferinfo: Optional[TransferInfo]) -> Optional[Dict]:
|
||||
"""
|
||||
添加文件转移上下文
|
||||
"""
|
||||
if not transferinfo:
|
||||
return None
|
||||
ctx = {
|
||||
"transfer_type": transferinfo.transfer_type,
|
||||
"file_count": transferinfo.file_count,
|
||||
"total_size": StringUtils.str_filesize(transferinfo.total_size),
|
||||
"err_msg": transferinfo.message,
|
||||
}
|
||||
return self._context.update(ctx)
|
||||
|
||||
def _add_file_info(self, file_extension: Optional[str]):
|
||||
"""
|
||||
添加文件信息
|
||||
"""
|
||||
if not file_extension:
|
||||
return
|
||||
file_info = {
|
||||
# 文件后缀
|
||||
"fileExt": file_extension,
|
||||
}
|
||||
self._context.update(file_info)
|
||||
|
||||
def _add_raw_objects(
|
||||
self,
|
||||
meta: Optional[MetaBase],
|
||||
mediainfo: Optional[MediaInfo],
|
||||
torrentinfo: Optional[TorrentInfo],
|
||||
transferinfo: Optional[TransferInfo],
|
||||
episodes_info: Optional[List[TmdbEpisode]],
|
||||
):
|
||||
"""
|
||||
添加原始对象引用
|
||||
"""
|
||||
raw_objects = {
|
||||
# 文件元数据
|
||||
"__meta__": meta,
|
||||
# 识别的媒体信息
|
||||
"__mediainfo__": mediainfo,
|
||||
# 种子信息
|
||||
"__torrentinfo__": torrentinfo,
|
||||
# 文件转移信息
|
||||
"__transferinfo__": transferinfo,
|
||||
# 当前季的全部集信息
|
||||
"__episodes_info__": episodes_info,
|
||||
}
|
||||
self._context.update(raw_objects)
|
||||
|
||||
@staticmethod
|
||||
def __convert_invalid_characters(filename: str):
|
||||
"""
|
||||
将不支持的字符转换为全角字符
|
||||
"""
|
||||
if not filename:
|
||||
return filename
|
||||
invalid_characters = r'\/:*?"<>|'
|
||||
# 创建半角到全角字符的转换表
|
||||
halfwidth_chars = "".join([chr(i) for i in range(33, 127)])
|
||||
fullwidth_chars = "".join([chr(i + 0xFEE0) for i in range(33, 127)])
|
||||
translation_table = str.maketrans(halfwidth_chars, fullwidth_chars)
|
||||
# 将不支持的字符替换为对应的全角字符
|
||||
for char in invalid_characters:
|
||||
filename = filename.replace(char, char.translate(translation_table))
|
||||
return filename
|
||||
|
||||
|
||||
class TemplateHelper(metaclass=SingletonClass):
|
||||
"""
|
||||
模板格式渲染帮助类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.builder = TemplateContextBuilder()
|
||||
self.cache = TTLCache(maxsize=100, ttl=600)
|
||||
|
||||
@staticmethod
|
||||
def _generate_cache_key(cuntent: Union[str, dict]) -> str:
|
||||
"""
|
||||
生成缓存键
|
||||
"""
|
||||
if isinstance(cuntent, dict):
|
||||
base_str = cuntent.get("title", '') + cuntent.get("text", '')
|
||||
return StringUtils.md5_hash(json.dumps(base_str, sort_keys=True, ensure_ascii=False))
|
||||
|
||||
return StringUtils.md5_hash(cuntent)
|
||||
|
||||
def get_cache_context(self, cuntent: Union[str, dict]) -> Optional[dict]:
|
||||
"""
|
||||
获取缓存上下文
|
||||
"""
|
||||
cache_key = self._generate_cache_key(cuntent)
|
||||
return self.cache.get(cache_key)
|
||||
|
||||
def set_cache_context(self, cuntent: Union[str, dict], context: dict) -> None:
|
||||
"""
|
||||
设置缓存上下文
|
||||
"""
|
||||
cache_key = self._generate_cache_key(cuntent)
|
||||
self.cache[cache_key] = context
|
||||
|
||||
def render(self,
|
||||
template_content: str,
|
||||
template_type: Literal['string', 'dict', 'literal'] = "literal",
|
||||
**kwargs) -> Optional[Union[str, dict]]:
|
||||
"""
|
||||
根据模板格式渲染内容
|
||||
:param template_content: 模板字符串
|
||||
:param template_type: 模板字符串类型(消息通知`literal`, 路径`string`)
|
||||
:param kwargs: 补传业务对象
|
||||
:raises ValueError: 当模板处理过程中出现错误
|
||||
:return: 渲染后的结果
|
||||
"""
|
||||
try:
|
||||
# 解析模板字符
|
||||
parsed = self.parse_template_content(template_content, template_type)
|
||||
if not parsed:
|
||||
raise ValueError("模板解析失败")
|
||||
|
||||
context = self.builder.build(**kwargs)
|
||||
if not context:
|
||||
raise ValueError("上下文构建失败")
|
||||
|
||||
rendered = self.render_with_context(parsed, context)
|
||||
if not rendered:
|
||||
raise ValueError("模板渲染失败")
|
||||
|
||||
if rendered := rendered if template_type == 'string' else self.__process_formatted_string(rendered):
|
||||
# 缓存上下文
|
||||
self.set_cache_context(rendered, context)
|
||||
# 返回渲染结果
|
||||
return rendered
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"模板处理失败: {str(e)}")
|
||||
raise ValueError(f"模板处理失败: {str(e)}") from e
|
||||
|
||||
@staticmethod
|
||||
def render_with_context(template_content: str, context: dict) -> str:
|
||||
"""
|
||||
使用指定上下文渲染 Jinja2 模板字符串
|
||||
template_content: Jinja2 模板字符串
|
||||
context: 渲染用的上下文数据
|
||||
"""
|
||||
# 渲染模板
|
||||
template = Template(template_content)
|
||||
return template.render(context)
|
||||
|
||||
@staticmethod
|
||||
def parse_template_content(template_content: Union[str, dict],
|
||||
template_type: Literal['string', 'dict', 'literal'] = None) -> Optional[str]:
|
||||
"""
|
||||
解析模板字符
|
||||
:param template_content 模板格式字符
|
||||
:param template_type 模板字符类型
|
||||
"""
|
||||
|
||||
def parse_literal(_template_content: str) -> str:
|
||||
"""
|
||||
解析Python字面量
|
||||
"""
|
||||
try:
|
||||
template_dict = ast.literal_eval(_template_content) if isinstance(_template_content,
|
||||
str) else _template_content
|
||||
if not isinstance(template_dict, dict):
|
||||
raise ValueError("解析结果必须是一个字典")
|
||||
return json.dumps(template_dict, ensure_ascii=False)
|
||||
except (ValueError, SyntaxError) as err:
|
||||
raise ValueError(f"无效的Python字面量格式: {str(err)}")
|
||||
|
||||
try:
|
||||
if template_type:
|
||||
parse_map = {
|
||||
'string': lambda x: str(x),
|
||||
'dict': lambda x: json.dumps(x, ensure_ascii=False),
|
||||
'literal': parse_literal
|
||||
}
|
||||
return parse_map[template_type](template_content)
|
||||
|
||||
# 自动判断模板类型
|
||||
if isinstance(template_content, dict):
|
||||
return json.dumps(template_content, ensure_ascii=False)
|
||||
elif isinstance(template_content, str):
|
||||
try:
|
||||
json.loads(template_content)
|
||||
return template_content
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
return parse_literal(template_content)
|
||||
except (ValueError, SyntaxError):
|
||||
return template_content
|
||||
else:
|
||||
raise ValueError(f"不支持的模板类型: {type(template_content)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模板解析失败: {str(e)}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def __process_formatted_string(rendered: str) -> Optional[Union[dict, str]]:
|
||||
"""
|
||||
处理格式化字符串
|
||||
保留转义字符
|
||||
"""
|
||||
|
||||
def restore_chars(obj: Any) -> Any:
|
||||
"""恢复特殊字符"""
|
||||
if isinstance(obj, str):
|
||||
return obj.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\b', '\b').replace(
|
||||
'\\f', '\f')
|
||||
elif isinstance(obj, dict):
|
||||
return {k: restore_chars(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [restore_chars(item) for item in obj]
|
||||
return obj
|
||||
|
||||
# 定义特殊字符映射
|
||||
|
||||
special_chars = {
|
||||
'\n': '\\n', # 换行符
|
||||
'\r': '\\r', # 回车符
|
||||
'\t': '\\t', # 制表符
|
||||
'\b': '\\b', # 退格符
|
||||
'\f': '\\f', # 换页符
|
||||
}
|
||||
|
||||
# 处理特殊字符
|
||||
processed = rendered
|
||||
for char, escape in special_chars.items():
|
||||
processed = processed.replace(char, escape)
|
||||
|
||||
# 尝试解析为JSON
|
||||
try:
|
||||
rendered_dict = json.loads(processed)
|
||||
return restore_chars(rendered_dict)
|
||||
except json.JSONDecodeError:
|
||||
return rendered
|
||||
|
||||
|
||||
class MessageTemplateHelper:
|
||||
"""
|
||||
消息模板渲染器
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def render(message: Notification, *args, **kwargs) -> Optional[Notification]:
|
||||
"""
|
||||
渲染消息模板
|
||||
"""
|
||||
if not MessageTemplateHelper.is_instance_valid(message):
|
||||
if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs):
|
||||
logger.info("将使用模板渲染消息内容")
|
||||
return MessageTemplateHelper._apply_template_data(message, *args, **kwargs)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def is_instance_valid(message: Notification) -> bool:
|
||||
"""
|
||||
检查消息是否有效
|
||||
"""
|
||||
if isinstance(message, Notification):
|
||||
return bool(message.title or message.text)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def meets_update_conditions(message: Notification, *args, **kwargs) -> bool:
|
||||
"""
|
||||
判断是否满足消息实例更新条件
|
||||
|
||||
满足条件需同时具备:
|
||||
1. 消息为有效Notification实例
|
||||
2. 消息指定了模板类型(ctype)
|
||||
3. 存在待渲染的模板变量数据
|
||||
"""
|
||||
if isinstance(message, Notification):
|
||||
return True if message.ctype and (args or kwargs) else False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _apply_template_data(message: Notification, *args, **kwargs) -> Optional[Notification]:
|
||||
"""
|
||||
更新消息实例
|
||||
"""
|
||||
try:
|
||||
if template := MessageTemplateHelper._get_template(message):
|
||||
rendered = TemplateHelper().render(template_content=template, *args, **kwargs)
|
||||
for key, value in rendered.items():
|
||||
if hasattr(message, key):
|
||||
setattr(message, key, value)
|
||||
return message
|
||||
except Exception as e:
|
||||
logger.error(f"更新Notification时出现错误:{str(e)}")
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _get_template(message: Notification) -> Optional[str]:
|
||||
"""
|
||||
获取消息模板
|
||||
"""
|
||||
template_dict: dict[str, str] = SystemConfigOper().get(SystemConfigKey.NotificationTemplates)
|
||||
return template_dict.get(f"{message.ctype.value}")
|
||||
|
||||
|
||||
class MessageQueueManager(metaclass=SingletonClass):
|
||||
@@ -55,6 +574,7 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
def _parse_schedule(periods: Union[list, dict]) -> List[tuple[int, int, int, int]]:
|
||||
"""
|
||||
将字符串时间格式转换为分钟数元组
|
||||
支持格式为 'HH:MM' 或 'HH:MM:SS' 的时间字符串
|
||||
"""
|
||||
parsed = []
|
||||
if not periods:
|
||||
@@ -66,9 +586,31 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
continue
|
||||
if not period.get('start') or not period.get('end'):
|
||||
continue
|
||||
start_h, start_m = map(int, period['start'].split(':'))
|
||||
end_h, end_m = map(int, period['end'].split(':'))
|
||||
parsed.append((start_h, start_m, end_h, end_m))
|
||||
try:
|
||||
# 处理 start 时间
|
||||
start_parts = period['start'].split(':')
|
||||
if len(start_parts) == 2:
|
||||
start_h, start_m = map(int, start_parts)
|
||||
elif len(start_parts) >= 3:
|
||||
start_h, start_m = map(int, start_parts[:2]) # 只取前两个部分 (HH:MM)
|
||||
else:
|
||||
continue
|
||||
# 处理 end 时间
|
||||
end_parts = period['end'].split(':')
|
||||
if len(end_parts) == 2:
|
||||
end_h, end_m = map(int, end_parts)
|
||||
elif len(end_parts) >= 3:
|
||||
end_h, end_m = map(int, end_parts[:2]) # 只取前两个部分 (HH:MM)
|
||||
else:
|
||||
continue
|
||||
|
||||
parsed.append((start_h, start_m, end_h, end_m))
|
||||
except ValueError as e:
|
||||
logger.error(f"解析时间周期时出现错误:{period}. 错误:{str(e)}. 跳过此周期。")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"解析时间周期时出现意外错误:{period}. 错误:{str(e)}. 跳过此周期。")
|
||||
continue
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
@@ -103,7 +645,8 @@ class MessageQueueManager(metaclass=SingletonClass):
|
||||
"""
|
||||
发送消息(立即发送或加入队列)
|
||||
"""
|
||||
if self._is_in_scheduled_time(datetime.now()):
|
||||
immediately = kwargs.pop("immediately", False)
|
||||
if immediately or self._is_in_scheduled_time(datetime.now()):
|
||||
self._send(*args, **kwargs)
|
||||
else:
|
||||
self.queue.put({
|
||||
|
||||
@@ -7,14 +7,15 @@ from typing import List, Any, Callable
|
||||
|
||||
from app.log import logger
|
||||
|
||||
|
||||
FilterFuncType = Callable[[str, Any], bool]
|
||||
|
||||
|
||||
def _default_filter(name: str, obj: Any) -> bool:
|
||||
"""
|
||||
默认过滤器
|
||||
"""
|
||||
return True
|
||||
return True if name and obj else False
|
||||
|
||||
|
||||
class ModuleHelper:
|
||||
"""
|
||||
@@ -76,7 +77,8 @@ class ModuleHelper:
|
||||
|
||||
def reload_sub_modules(parent_module, parent_module_name):
|
||||
"""重新加载一级子模块"""
|
||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__, parent_module_name+'.'):
|
||||
for sub_importer, sub_module_name, sub_is_pkg in pkgutil.walk_packages(parent_module.__path__,
|
||||
parent_module_name + '.'):
|
||||
try:
|
||||
full_sub_module = importlib.import_module(sub_module_name)
|
||||
importlib.reload(full_sub_module)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
import traceback
|
||||
import site
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Set
|
||||
|
||||
@@ -38,12 +41,35 @@ class PluginHelper(metaclass=Singleton):
|
||||
if self.install_report():
|
||||
self.systemconfig.set(SystemConfigKey.PluginInstallReport, "1")
|
||||
|
||||
@cached(maxsize=1000, ttl=1800)
|
||||
def get_plugins(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
def get_plugins(self, repo_url: str, package_version: Optional[str] = None,
|
||||
force: bool = False) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
获取Github所有最新插件列表
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
:param force: 是否强制刷新,忽略缓存
|
||||
"""
|
||||
# 如果强制刷新,直接调用不带缓存的版本
|
||||
if force:
|
||||
return self._get_plugins_uncached(repo_url, package_version)
|
||||
|
||||
# 正常情况下调用带缓存的版本
|
||||
return self._get_plugins_cached(repo_url, package_version)
|
||||
|
||||
@cached(maxsize=64, ttl=1800)
|
||||
def _get_plugins_cached(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
获取Github所有最新插件列表(使用缓存)
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
return self._get_plugins_uncached(repo_url, package_version)
|
||||
|
||||
def _get_plugins_uncached(self, repo_url: str, package_version: Optional[str] = None) -> Optional[Dict[str, dict]]:
|
||||
"""
|
||||
获取Github所有最新插件列表(不使用缓存)
|
||||
:param repo_url: Github仓库地址
|
||||
:param package_version: 首选插件版本 (如 "v2", "v3"),如果不指定则获取 v1 版本
|
||||
"""
|
||||
if not repo_url:
|
||||
return None
|
||||
@@ -451,19 +477,22 @@ class PluginHelper(metaclass=Singleton):
|
||||
@staticmethod
|
||||
def __pip_install_with_fallback(requirements_file: Path) -> Tuple[bool, str]:
|
||||
"""
|
||||
使用自动降级策略,PIP 安装依赖,优先级依次为镜像站、代理、直连
|
||||
使用自动降级策略安装依赖,并确保新安装的包可被动态导入
|
||||
:param requirements_file: 依赖的 requirements.txt 文件路径
|
||||
:return: (是否成功, 错误信息)
|
||||
"""
|
||||
base_cmd = [sys.executable, "-m", "pip", "install", "-r", str(requirements_file)]
|
||||
strategies = []
|
||||
|
||||
# 添加策略到列表中
|
||||
if settings.PIP_PROXY:
|
||||
strategies.append(("镜像站", ["pip", "install", "-r", str(requirements_file), "-i", settings.PIP_PROXY]))
|
||||
strategies.append(("镜像站", base_cmd + ["-i", settings.PIP_PROXY]))
|
||||
if settings.PROXY_HOST:
|
||||
strategies.append(
|
||||
("代理", ["pip", "install", "-r", str(requirements_file), "--proxy", settings.PROXY_HOST]))
|
||||
strategies.append(("直连", ["pip", "install", "-r", str(requirements_file)]))
|
||||
strategies.append(("代理", base_cmd + ["--proxy", settings.PROXY_HOST]))
|
||||
strategies.append(("直连", base_cmd))
|
||||
|
||||
# 记录当前已安装的包,以便后续刷新
|
||||
before_installation = set(sys.modules.keys())
|
||||
|
||||
# 遍历策略进行安装
|
||||
for strategy_name, pip_command in strategies:
|
||||
@@ -471,6 +500,16 @@ class PluginHelper(metaclass=Singleton):
|
||||
success, message = SystemUtils.execute_with_subprocess(pip_command)
|
||||
if success:
|
||||
logger.debug(f"[PIP] 策略:{strategy_name} 安装依赖成功,输出:{message}")
|
||||
# 安装成功后刷新Python的模块系统
|
||||
importlib.reload(site)
|
||||
# 获取新安装的模块
|
||||
current_modules = set(sys.modules.keys())
|
||||
new_modules = current_modules - before_installation
|
||||
# 重新加载新安装的模块
|
||||
for module in new_modules:
|
||||
if module in sys.modules:
|
||||
del sys.modules[module]
|
||||
logger.debug(f"[PIP] 已刷新导入系统,新加载的模块: {new_modules}")
|
||||
return True, message
|
||||
else:
|
||||
logger.error(f"[PIP] 策略:{strategy_name} 安装依赖失败,错误信息:{message}")
|
||||
|
||||
@@ -3,14 +3,14 @@ from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.sites import SitesHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
from app.utils.string import StringUtils
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class ResourceHelper(metaclass=Singleton):
|
||||
class ResourceHelper:
|
||||
"""
|
||||
检测和更新资源包
|
||||
"""
|
||||
@@ -20,7 +20,6 @@ class ResourceHelper(metaclass=Singleton):
|
||||
_base_dir: Path = settings.ROOT_PATH
|
||||
|
||||
def __init__(self):
|
||||
self.siteshelper = SitesHelper()
|
||||
self.check()
|
||||
|
||||
@property
|
||||
@@ -32,80 +31,80 @@ class ResourceHelper(metaclass=Singleton):
|
||||
检测是否有更新,如有则下载安装
|
||||
"""
|
||||
if not settings.AUTO_UPDATE_RESOURCE:
|
||||
return
|
||||
return None
|
||||
if SystemUtils.is_frozen():
|
||||
return
|
||||
return None
|
||||
logger.info("开始检测资源包版本...")
|
||||
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10).get_res(self._repo)
|
||||
if res:
|
||||
try:
|
||||
resource_info = json.loads(res.text)
|
||||
online_version = resource_info.get("version")
|
||||
if online_version:
|
||||
logger.info(f"最新资源包版本:v{online_version}")
|
||||
# 需要更新的资源包
|
||||
need_updates = {}
|
||||
# 资源明细
|
||||
resources: dict = resource_info.get("resources") or {}
|
||||
for rname, resource in resources.items():
|
||||
rtype = resource.get("type")
|
||||
platform = resource.get("platform")
|
||||
target = resource.get("target")
|
||||
version = resource.get("version")
|
||||
# 判断平台
|
||||
if platform and platform != SystemUtils.platform():
|
||||
continue
|
||||
# 判断版本号
|
||||
if rtype == "auth":
|
||||
# 站点认证资源
|
||||
local_version = SitesHelper().auth_version
|
||||
elif rtype == "sites":
|
||||
# 站点索引资源
|
||||
local_version = SitesHelper().indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, ">", local_version):
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
# 需要安装
|
||||
need_updates[rname] = target
|
||||
if need_updates:
|
||||
# 下载文件信息列表
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=30).get_res(self._files_api)
|
||||
if r and not r.ok:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
elif not r:
|
||||
return None, "连接仓库失败"
|
||||
files_info = r.json()
|
||||
for item in files_info:
|
||||
save_path = need_updates.get(item.get("name"))
|
||||
if not save_path:
|
||||
continue
|
||||
if item.get("download_url"):
|
||||
logger.info(f"开始更新资源文件:{item.get('name')} ...")
|
||||
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
|
||||
# 下载资源文件
|
||||
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
|
||||
timeout=180).get_res(download_url)
|
||||
if not res:
|
||||
logger.error(f"文件 {item.get('name')} 下载失败!")
|
||||
elif res.status_code != 200:
|
||||
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
|
||||
# 创建插件文件夹
|
||||
file_path = self._base_dir / save_path / item.get("name")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 写入文件
|
||||
file_path.write_bytes(res.content)
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemHelper.restart()
|
||||
else:
|
||||
logger.info("所有资源已最新,无需更新")
|
||||
except json.JSONDecodeError:
|
||||
logger.error("资源包仓库数据解析失败!")
|
||||
return
|
||||
return None
|
||||
else:
|
||||
logger.warn("无法连接资源包仓库!")
|
||||
return
|
||||
online_version = resource_info.get("version")
|
||||
if online_version:
|
||||
logger.info(f"最新资源包版本:v{online_version}")
|
||||
# 需要更新的资源包
|
||||
need_updates = {}
|
||||
# 资源明细
|
||||
resources: dict = resource_info.get("resources") or {}
|
||||
for rname, resource in resources.items():
|
||||
rtype = resource.get("type")
|
||||
platform = resource.get("platform")
|
||||
target = resource.get("target")
|
||||
version = resource.get("version")
|
||||
# 判断平台
|
||||
if platform and platform != SystemUtils.platform():
|
||||
continue
|
||||
# 判断版本号
|
||||
if rtype == "auth":
|
||||
# 站点认证资源
|
||||
local_version = self.siteshelper.auth_version
|
||||
elif rtype == "sites":
|
||||
# 站点索引资源
|
||||
local_version = self.siteshelper.indexer_version
|
||||
else:
|
||||
continue
|
||||
if StringUtils.compare_version(version, ">", local_version):
|
||||
logger.info(f"{rname} 资源包有更新,最新版本:v{version}")
|
||||
else:
|
||||
continue
|
||||
# 需要安装
|
||||
need_updates[rname] = target
|
||||
if need_updates:
|
||||
# 下载文件信息列表
|
||||
r = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS,
|
||||
timeout=30).get_res(self._files_api)
|
||||
if r and not r.ok:
|
||||
return None, f"连接仓库失败:{r.status_code} - {r.reason}"
|
||||
elif not r:
|
||||
return None, "连接仓库失败"
|
||||
files_info = r.json()
|
||||
for item in files_info:
|
||||
save_path = need_updates.get(item.get("name"))
|
||||
if not save_path:
|
||||
continue
|
||||
if item.get("download_url"):
|
||||
logger.info(f"开始更新资源文件:{item.get('name')} ...")
|
||||
download_url = f"{settings.GITHUB_PROXY}{item.get('download_url')}"
|
||||
# 下载资源文件
|
||||
res = RequestUtils(proxies=self.proxies, headers=settings.GITHUB_HEADERS,
|
||||
timeout=180).get_res(download_url)
|
||||
if not res:
|
||||
logger.error(f"文件 {item.get('name')} 下载失败!")
|
||||
elif res.status_code != 200:
|
||||
logger.error(f"下载文件 {item.get('name')} 失败:{res.status_code} - {res.reason}")
|
||||
# 创建插件文件夹
|
||||
file_path = self._base_dir / save_path / item.get("name")
|
||||
if not file_path.parent.exists():
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# 写入文件
|
||||
file_path.write_bytes(res.content)
|
||||
logger.info("资源包更新完成,开始重启服务...")
|
||||
SystemUtils.restart()
|
||||
else:
|
||||
logger.info("所有资源已最新,无需更新")
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import re
|
||||
import traceback
|
||||
import xml.dom.minidom
|
||||
from typing import List, Tuple, Union, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -10,7 +9,6 @@ from lxml import etree
|
||||
from app.core.config import settings
|
||||
from app.helper.browser import PlaywrightHelper
|
||||
from app.log import logger
|
||||
from app.utils.dom import DomUtils
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -19,6 +17,11 @@ class RssHelper:
|
||||
"""
|
||||
RSS帮助类,解析RSS报文、获取RSS地址等
|
||||
"""
|
||||
|
||||
# RSS解析限制配置
|
||||
MAX_RSS_SIZE = 50 * 1024 * 1024 # 50MB最大RSS文件大小
|
||||
MAX_RSS_ITEMS = 1000 # 最大解析条目数
|
||||
|
||||
# 各站点RSS链接获取配置
|
||||
rss_link_conf = {
|
||||
"default": {
|
||||
@@ -224,8 +227,8 @@ class RssHelper:
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse(url, proxy: bool = False, timeout: Optional[int] = 15, headers: dict = None) -> Union[List[dict], None, bool]:
|
||||
def parse(self, url, proxy: bool = False,
|
||||
timeout: Optional[int] = 15, headers: dict = None) -> Union[List[dict], None, bool]:
|
||||
"""
|
||||
解析RSS订阅URL,获取RSS中的种子信息
|
||||
:param url: RSS地址
|
||||
@@ -238,6 +241,7 @@ class RssHelper:
|
||||
ret_array: list = []
|
||||
if not url:
|
||||
return False
|
||||
|
||||
try:
|
||||
ret = RequestUtils(proxies=settings.PROXY if proxy else None,
|
||||
timeout=timeout, headers=headers).get_res(url)
|
||||
@@ -246,11 +250,17 @@ class RssHelper:
|
||||
except Exception as err:
|
||||
logger.error(f"获取RSS失败:{str(err)} - {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
if ret:
|
||||
ret_xml = ""
|
||||
ret_xml = None
|
||||
root = None
|
||||
try:
|
||||
# 使用chardet检测字符编码
|
||||
# 检查响应大小,避免处理过大的RSS文件
|
||||
raw_data = ret.content
|
||||
if raw_data and len(raw_data) > self.MAX_RSS_SIZE:
|
||||
logger.warning(f"RSS文件过大: {len(raw_data) / 1024 / 1024:.1f}MB,跳过解析")
|
||||
return False
|
||||
|
||||
if raw_data:
|
||||
try:
|
||||
result = chardet.detect(raw_data)
|
||||
@@ -269,57 +279,114 @@ class RssHelper:
|
||||
ret.encoding = ret.apparent_encoding
|
||||
if not ret_xml:
|
||||
ret_xml = ret.text
|
||||
# 解析XML
|
||||
dom_tree = xml.dom.minidom.parseString(ret_xml)
|
||||
rootNode = dom_tree.documentElement
|
||||
items = rootNode.getElementsByTagName("item")
|
||||
for item in items:
|
||||
|
||||
# 使用lxml.etree解析XML
|
||||
parser = None
|
||||
try:
|
||||
# 创建解析器,禁用网络访问以提高安全性和性能
|
||||
parser = etree.XMLParser(
|
||||
recover=True, # 容错模式
|
||||
strip_cdata=False, # 保留CDATA
|
||||
resolve_entities=False, # 禁用外部实体解析
|
||||
no_network=True, # 禁用网络访问
|
||||
huge_tree=False # 禁用大文档解析,避免内存问题
|
||||
)
|
||||
root = etree.fromstring(ret_xml.encode('utf-8'), parser=parser)
|
||||
except etree.XMLSyntaxError:
|
||||
# 如果XML解析失败,尝试作为HTML解析
|
||||
try:
|
||||
# 标题
|
||||
title = DomUtils.tag_value(item, "title", default="")
|
||||
root = etree.HTML(ret_xml)
|
||||
if root is not None:
|
||||
# 查找RSS根节点
|
||||
rss_root = root.xpath('//rss | //feed')
|
||||
if rss_root:
|
||||
root = rss_root[0]
|
||||
except Exception as e:
|
||||
logger.error(f"HTML解析也失败:{str(e)}")
|
||||
return False
|
||||
finally:
|
||||
if parser is not None:
|
||||
del parser
|
||||
|
||||
if root is None:
|
||||
logger.error("无法解析RSS内容")
|
||||
return False
|
||||
|
||||
# 查找所有item或entry节点
|
||||
items = root.xpath('.//item | .//entry')
|
||||
|
||||
# 限制处理的条目数量
|
||||
items_count = min(len(items), self.MAX_RSS_ITEMS)
|
||||
if len(items) > self.MAX_RSS_ITEMS:
|
||||
logger.warning(f"RSS条目过多: {len(items)},仅处理前{self.MAX_RSS_ITEMS}个")
|
||||
|
||||
for item in items[:items_count]:
|
||||
try:
|
||||
# 使用xpath提取信息,更高效
|
||||
title_nodes = item.xpath('.//title')
|
||||
title = title_nodes[0].text if title_nodes and title_nodes[0].text else ""
|
||||
if not title:
|
||||
continue
|
||||
|
||||
# 描述
|
||||
description = DomUtils.tag_value(item, "description", default="")
|
||||
desc_nodes = item.xpath('.//description | .//summary')
|
||||
description = desc_nodes[0].text if desc_nodes and desc_nodes[0].text else ""
|
||||
|
||||
# 种子页面
|
||||
link = DomUtils.tag_value(item, "link", default="")
|
||||
link_nodes = item.xpath('.//link')
|
||||
if link_nodes:
|
||||
link = link_nodes[0].text if hasattr(link_nodes[0], 'text') and link_nodes[0].text else \
|
||||
link_nodes[0].get('href', '')
|
||||
else:
|
||||
link = ""
|
||||
|
||||
# 种子链接
|
||||
enclosure = DomUtils.tag_value(item, "enclosure", "url", default="")
|
||||
enclosure_nodes = item.xpath('.//enclosure')
|
||||
enclosure = enclosure_nodes[0].get('url', '') if enclosure_nodes else ""
|
||||
if not enclosure and not link:
|
||||
continue
|
||||
# 部分RSS只有link没有enclosure
|
||||
if not enclosure and link:
|
||||
enclosure = link
|
||||
|
||||
# 大小
|
||||
size = DomUtils.tag_value(item, "enclosure", "length", default=0)
|
||||
if size and str(size).isdigit():
|
||||
size = int(size)
|
||||
else:
|
||||
size = 0
|
||||
size = 0
|
||||
if enclosure_nodes:
|
||||
size_attr = enclosure_nodes[0].get('length', '0')
|
||||
if size_attr and str(size_attr).isdigit():
|
||||
size = int(size_attr)
|
||||
|
||||
# 发布日期
|
||||
pubdate = DomUtils.tag_value(item, "pubDate", default="")
|
||||
if pubdate:
|
||||
# 转换为时间
|
||||
pubdate = StringUtils.get_time(pubdate)
|
||||
pubdate_nodes = item.xpath('.//pubDate | .//published | .//updated')
|
||||
pubdate = ""
|
||||
if pubdate_nodes and pubdate_nodes[0].text:
|
||||
pubdate = StringUtils.get_time(pubdate_nodes[0].text)
|
||||
|
||||
# 获取豆瓣昵称
|
||||
nickname = DomUtils.tag_value(item, "dc:createor", default="")
|
||||
nickname_nodes = item.xpath('.//*[local-name()="creator"]')
|
||||
nickname = nickname_nodes[0].text if nickname_nodes and nickname_nodes[0].text else ""
|
||||
|
||||
# 返回对象
|
||||
tmp_dict = {'title': title,
|
||||
'enclosure': enclosure,
|
||||
'size': size,
|
||||
'description': description,
|
||||
'link': link,
|
||||
'pubdate': pubdate}
|
||||
tmp_dict = {
|
||||
'title': title,
|
||||
'enclosure': enclosure,
|
||||
'size': size,
|
||||
'description': description,
|
||||
'link': link,
|
||||
'pubdate': pubdate
|
||||
}
|
||||
# 如果豆瓣昵称不为空,返回数据增加豆瓣昵称,供doubansync插件获取
|
||||
if nickname:
|
||||
tmp_dict['nickname'] = nickname
|
||||
ret_array.append(tmp_dict)
|
||||
|
||||
except Exception as e1:
|
||||
logger.debug(f"解析RSS失败:{str(e1)} - {traceback.format_exc()}")
|
||||
logger.debug(f"解析RSS条目失败:{str(e1)} - {traceback.format_exc()}")
|
||||
continue
|
||||
|
||||
except Exception as e2:
|
||||
logger.error(f"解析RSS失败:{str(e2)} - {traceback.format_exc()}")
|
||||
# RSS过期 观众RSS 链接已过期,您需要获得一个新的! pthome RSS Link has expired, You need to get a new one!
|
||||
# RSS过期检查
|
||||
_rss_expired_msg = [
|
||||
"RSS 链接已过期, 您需要获得一个新的!",
|
||||
"RSS Link has expired, You need to get a new one!",
|
||||
@@ -328,6 +395,12 @@ class RssHelper:
|
||||
if ret_xml in _rss_expired_msg:
|
||||
return None
|
||||
return False
|
||||
finally:
|
||||
if root is not None:
|
||||
del root
|
||||
if ret_xml is not None:
|
||||
del ret_xml
|
||||
|
||||
return ret_array
|
||||
|
||||
def get_rss_link(self, url: str, cookie: str, ua: str, proxy: bool = False) -> Tuple[str, str]:
|
||||
@@ -369,12 +442,20 @@ class RssHelper:
|
||||
return "", f"获取 {url} RSS链接失败,错误码:{res.status_code},错误原因:{res.reason}"
|
||||
else:
|
||||
return "", f"获取RSS链接失败:无法连接 {url} "
|
||||
|
||||
# 解析HTML
|
||||
html = etree.HTML(html_text)
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
rss_link = html.xpath(site_conf.get("xpath"))
|
||||
if rss_link:
|
||||
return str(rss_link[-1]), ""
|
||||
if html_text:
|
||||
html = None
|
||||
try:
|
||||
html = etree.HTML(html_text)
|
||||
if StringUtils.is_valid_html_element(html):
|
||||
rss_link = html.xpath(site_conf.get("xpath"))
|
||||
if rss_link:
|
||||
return str(rss_link[-1]), ""
|
||||
finally:
|
||||
if html is not None:
|
||||
del html
|
||||
|
||||
return "", f"获取RSS链接失败:{url}"
|
||||
except Exception as e:
|
||||
return "", f"获取 {url} RSS链接失败:{str(e)}"
|
||||
|
||||
@@ -11,14 +11,12 @@ class RuleHelper:
|
||||
规划帮助类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def get_rule_groups(self) -> List[FilterRuleGroup]:
|
||||
@staticmethod
|
||||
def get_rule_groups() -> List[FilterRuleGroup]:
|
||||
"""
|
||||
获取用户所有规则组
|
||||
"""
|
||||
rule_groups: List[dict] = self.systemconfig.get(SystemConfigKey.UserFilterRuleGroups)
|
||||
rule_groups: List[dict] = SystemConfigOper().get(SystemConfigKey.UserFilterRuleGroups)
|
||||
if not rule_groups:
|
||||
return []
|
||||
return [FilterRuleGroup(**group) for group in rule_groups]
|
||||
@@ -50,11 +48,12 @@ class RuleHelper:
|
||||
ret_groups.append(group)
|
||||
return ret_groups
|
||||
|
||||
def get_custom_rules(self) -> List[CustomRule]:
|
||||
@staticmethod
|
||||
def get_custom_rules() -> List[CustomRule]:
|
||||
"""
|
||||
获取用户所有自定义规则
|
||||
"""
|
||||
rules: List[dict] = self.systemconfig.get(SystemConfigKey.CustomFilterRules)
|
||||
rules: List[dict] = SystemConfigOper().get(SystemConfigKey.CustomFilterRules)
|
||||
if not rules:
|
||||
return []
|
||||
return [CustomRule(**rule) for rule in rules]
|
||||
|
||||
@@ -10,14 +10,12 @@ class StorageHelper:
|
||||
存储帮助类
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
|
||||
def get_storagies(self) -> List[schemas.StorageConf]:
|
||||
@staticmethod
|
||||
def get_storagies() -> List[schemas.StorageConf]:
|
||||
"""
|
||||
获取所有存储设置
|
||||
"""
|
||||
storage_confs: List[dict] = self.systemconfig.get(SystemConfigKey.Storages)
|
||||
storage_confs: List[dict] = SystemConfigOper().get(SystemConfigKey.Storages)
|
||||
if not storage_confs:
|
||||
return []
|
||||
return [schemas.StorageConf(**s) for s in storage_confs]
|
||||
@@ -49,4 +47,36 @@ class StorageHelper:
|
||||
if s.type == storage:
|
||||
s.config = conf
|
||||
break
|
||||
self.systemconfig.set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||
SystemConfigOper().set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||
|
||||
def add_storage(self, storage: str, name: str, conf: dict):
|
||||
"""
|
||||
添加存储配置
|
||||
"""
|
||||
storagies = self.get_storagies()
|
||||
if not storagies:
|
||||
storagies = [
|
||||
schemas.StorageConf(
|
||||
type=storage,
|
||||
name=name,
|
||||
config=conf
|
||||
)
|
||||
]
|
||||
else:
|
||||
storagies.append(schemas.StorageConf(
|
||||
type=storage,
|
||||
name=name,
|
||||
config=conf
|
||||
))
|
||||
SystemConfigOper().set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||
|
||||
def reset_storage(self, storage: str):
|
||||
"""
|
||||
重置存储配置
|
||||
"""
|
||||
storagies = self.get_storagies()
|
||||
for s in storagies:
|
||||
if s.type == storage:
|
||||
s.config = {}
|
||||
break
|
||||
SystemConfigOper().set(SystemConfigKey.Storages, [s.dict() for s in storagies])
|
||||
|
||||
@@ -50,11 +50,11 @@ class SubscribeHelper(metaclass=Singleton):
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
self.systemconfig = SystemConfigOper()
|
||||
systemconfig = SystemConfigOper()
|
||||
if settings.SUBSCRIBE_STATISTIC_SHARE:
|
||||
if not self.systemconfig.get(SystemConfigKey.SubscribeReport):
|
||||
if not systemconfig.get(SystemConfigKey.SubscribeReport):
|
||||
if self.sub_report():
|
||||
self.systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
||||
systemconfig.set(SystemConfigKey.SubscribeReport, "1")
|
||||
self.get_user_uuid()
|
||||
self.get_github_user()
|
||||
|
||||
|
||||
162
app/helper/system.py
Normal file
162
app/helper/system.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import os
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
import docker
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
from app.utils.system import SystemUtils
|
||||
|
||||
|
||||
class SystemHelper:
|
||||
"""
|
||||
系统工具类,提供系统相关的操作和判断
|
||||
"""
|
||||
|
||||
__system_flag_file = "/var/log/nginx/__moviepilot__"
|
||||
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
"""
|
||||
处理配置变更事件,更新日志设置
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data: ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in ['DEBUG', 'LOG_LEVEL', 'LOG_MAX_FILE_SIZE', 'LOG_BACKUP_COUNT',
|
||||
'LOG_FILE_FORMAT', 'LOG_CONSOLE_FORMAT']:
|
||||
return
|
||||
logger.update_loggers()
|
||||
|
||||
@staticmethod
|
||||
def can_restart() -> bool:
|
||||
"""
|
||||
判断是否可以内部重启
|
||||
"""
|
||||
return (
|
||||
Path("/var/run/docker.sock").exists()
|
||||
or settings.DOCKER_CLIENT_API != "tcp://127.0.0.1:38379"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_container_id() -> str:
|
||||
"""
|
||||
获取当前容器ID
|
||||
"""
|
||||
container_id = None
|
||||
try:
|
||||
with open("/proc/self/mountinfo", "r") as f:
|
||||
data = f.read()
|
||||
index_resolv_conf = data.find("resolv.conf")
|
||||
if index_resolv_conf != -1:
|
||||
index_second_slash = data.rfind("/", 0, index_resolv_conf)
|
||||
index_first_slash = data.rfind("/", 0, index_second_slash) + 1
|
||||
container_id = data[index_first_slash:index_second_slash]
|
||||
if len(container_id) < 20:
|
||||
index_resolv_conf = data.find("/sys/fs/cgroup/devices")
|
||||
if index_resolv_conf != -1:
|
||||
index_second_slash = data.rfind(" ", 0, index_resolv_conf)
|
||||
index_first_slash = (
|
||||
data.rfind("/", 0, index_second_slash) + 1
|
||||
)
|
||||
container_id = data[index_first_slash:index_second_slash]
|
||||
except Exception as e:
|
||||
logger.debug(f"获取容器ID失败: {str(e)}")
|
||||
return container_id.strip() if container_id else None
|
||||
|
||||
@staticmethod
|
||||
def _check_restart_policy() -> bool:
|
||||
"""
|
||||
检查当前容器是否配置了自动重启策略
|
||||
"""
|
||||
try:
|
||||
# 获取当前容器ID
|
||||
container_id = SystemHelper._get_container_id()
|
||||
if not container_id:
|
||||
return False
|
||||
|
||||
# 创建 Docker 客户端
|
||||
client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API)
|
||||
# 获取容器信息
|
||||
container = client.containers.get(container_id)
|
||||
restart_policy = container.attrs.get('HostConfig', {}).get('RestartPolicy', {})
|
||||
policy_name = restart_policy.get('Name', 'no')
|
||||
# 检查是否有有效的重启策略
|
||||
auto_restart_policies = ['always', 'unless-stopped', 'on-failure']
|
||||
has_restart_policy = policy_name in auto_restart_policies
|
||||
|
||||
logger.info(f"容器重启策略: {policy_name}, 支持自动重启: {has_restart_policy}")
|
||||
return has_restart_policy
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"检查重启策略失败: {str(e)}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def restart() -> Tuple[bool, str]:
|
||||
"""
|
||||
执行Docker重启操作
|
||||
"""
|
||||
if not SystemUtils.is_docker():
|
||||
return False, "非Docker环境,无法重启!"
|
||||
|
||||
try:
|
||||
# 检查容器是否配置了自动重启策略
|
||||
has_restart_policy = SystemHelper._check_restart_policy()
|
||||
if has_restart_policy:
|
||||
# 有重启策略,使用优雅退出方式
|
||||
logger.info("检测到容器配置了自动重启策略,使用优雅重启方式...")
|
||||
# 发送SIGTERM信号给当前进程,触发优雅停止
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
return True, ""
|
||||
else:
|
||||
# 没有重启策略,使用Docker API强制重启
|
||||
logger.info("容器未配置自动重启策略,使用Docker API重启...")
|
||||
return SystemHelper._docker_api_restart()
|
||||
except Exception as err:
|
||||
logger.error(f"重启失败: {str(err)}")
|
||||
# 降级为Docker API重启
|
||||
logger.warning("降级为Docker API重启...")
|
||||
return SystemHelper._docker_api_restart()
|
||||
|
||||
@staticmethod
|
||||
def _docker_api_restart() -> Tuple[bool, str]:
|
||||
"""
|
||||
使用Docker API重启容器,并尝试优雅停止
|
||||
"""
|
||||
try:
|
||||
# 创建 Docker 客户端
|
||||
client = docker.DockerClient(base_url=settings.DOCKER_CLIENT_API)
|
||||
container_id = SystemHelper._get_container_id()
|
||||
if not container_id:
|
||||
return False, "获取容器ID失败!"
|
||||
# 重启容器
|
||||
client.containers.get(container_id).restart()
|
||||
return True, ""
|
||||
except Exception as docker_err:
|
||||
return False, f"重启时发生错误:{str(docker_err)}"
|
||||
|
||||
def set_system_modified(self):
|
||||
"""
|
||||
设置系统已修改标志
|
||||
"""
|
||||
try:
|
||||
if SystemUtils.is_docker():
|
||||
Path(self.__system_flag_file).touch(exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"设置系统修改标志失败: {str(e)}")
|
||||
|
||||
def is_system_reset(self) -> bool:
|
||||
"""
|
||||
检查系统是否已被重置
|
||||
:return: 如果系统已重置,返回 True;否则返回 False
|
||||
"""
|
||||
if SystemUtils.is_docker():
|
||||
return not Path(self.__system_flag_file).exists()
|
||||
return False
|
||||
@@ -2,14 +2,15 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Optional
|
||||
|
||||
from app.utils.singleton import Singleton
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class ThreadHelper(metaclass=Singleton):
|
||||
"""
|
||||
线程池管理
|
||||
"""
|
||||
def __init__(self, max_workers: Optional[int] = 50):
|
||||
self.pool = ThreadPoolExecutor(max_workers=max_workers)
|
||||
def __init__(self):
|
||||
self.pool = ThreadPoolExecutor(max_workers=settings.CONF['threadpool'])
|
||||
|
||||
def submit(self, func, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -28,10 +28,6 @@ class TorrentHelper(metaclass=Singleton):
|
||||
# 失败的种子:站点链接
|
||||
_invalid_torrents = []
|
||||
|
||||
def __init__(self):
|
||||
self.system_config = SystemConfigOper()
|
||||
self.site_oper = SiteOper()
|
||||
|
||||
def download_torrent(self, url: str,
|
||||
cookie: Optional[str] = None,
|
||||
ua: Optional[str] = None,
|
||||
@@ -192,7 +188,8 @@ class TorrentHelper(metaclass=Singleton):
|
||||
file_name = str(datetime.datetime.now())
|
||||
return file_name
|
||||
|
||||
def sort_torrents(self, torrent_list: List[Context]) -> List[Context]:
|
||||
@staticmethod
|
||||
def sort_torrents(torrent_list: List[Context]) -> List[Context]:
|
||||
"""
|
||||
对种子对行排序:torrent、site、upload、seeder
|
||||
"""
|
||||
@@ -200,11 +197,11 @@ class TorrentHelper(metaclass=Singleton):
|
||||
return []
|
||||
|
||||
# 下载规则
|
||||
priority_rule: List[str] = self.system_config.get(
|
||||
priority_rule: List[str] = SystemConfigOper().get(
|
||||
SystemConfigKey.TorrentsPriority) or ["torrent", "upload", "seeder"]
|
||||
# 站点上传量
|
||||
site_uploads = {
|
||||
site.name: site.upload for site in self.site_oper.get_userdata_latest()
|
||||
site.name: site.upload for site in SiteOper().get_userdata_latest()
|
||||
}
|
||||
|
||||
def get_sort_str(_context):
|
||||
|
||||
162
app/helper/wallpaper.py
Normal file
162
app/helper/wallpaper.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from typing import Optional, List
|
||||
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.cache import cached
|
||||
from app.core.config import settings
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
|
||||
class WallpaperHelper(metaclass=Singleton):
|
||||
"""
|
||||
壁纸帮助类
|
||||
"""
|
||||
|
||||
def get_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取登录页面壁纸
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
url = self.get_bing_wallpaper()
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
url = self.get_mediaserver_wallpaper()
|
||||
elif settings.WALLPAPER == "customize":
|
||||
url = self.get_customize_wallpaper()
|
||||
else:
|
||||
url = self.get_tmdb_wallpaper()
|
||||
return url
|
||||
|
||||
def get_wallpapers(self, num: int = 10) -> List[str]:
|
||||
"""
|
||||
获取登录页面壁纸列表
|
||||
"""
|
||||
if settings.WALLPAPER == "bing":
|
||||
return self.get_bing_wallpapers(num)
|
||||
elif settings.WALLPAPER == "mediaserver":
|
||||
return self.get_mediaserver_wallpapers(num)
|
||||
elif settings.WALLPAPER == "customize":
|
||||
return self.get_customize_wallpapers()
|
||||
else:
|
||||
return self.get_tmdb_wallpapers(num)
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_tmdb_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取TMDB每日壁纸
|
||||
"""
|
||||
return TmdbChain().get_random_wallpager()
|
||||
|
||||
@cached(maxsize=1, ttl=3600, skip_empty=True)
|
||||
def get_tmdb_wallpapers(self, num: int = 10) -> List[str]:
|
||||
"""
|
||||
获取7天的TMDB每日壁纸
|
||||
"""
|
||||
return TmdbChain().get_trending_wallpapers(num)
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_bing_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取Bing每日壁纸
|
||||
"""
|
||||
url = "https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1"
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
for image in result.get('images') or []:
|
||||
return f"https://cn.bing.com{image.get('url')}" if 'url' in image else ''
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return None
|
||||
|
||||
@cached(maxsize=1, ttl=3600, skip_empty=True)
|
||||
def get_bing_wallpapers(self, num: int = 7) -> List[str]:
|
||||
"""
|
||||
获取7天的Bing每日壁纸
|
||||
"""
|
||||
url = f"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n={num}"
|
||||
resp = RequestUtils(timeout=5).get_res(url)
|
||||
if resp and resp.status_code == 200:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, dict):
|
||||
return [f"https://cn.bing.com{image.get('url')}" for image in result.get('images') or []]
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return []
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_mediaserver_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取媒体服务器壁纸
|
||||
"""
|
||||
return MediaServerChain().get_latest_wallpaper()
|
||||
|
||||
@cached(maxsize=1, ttl=3600, skip_empty=True)
|
||||
def get_mediaserver_wallpapers(self, num: int = 10) -> List[str]:
|
||||
"""
|
||||
获取媒体服务器壁纸列表
|
||||
"""
|
||||
return MediaServerChain().get_latest_wallpapers(count=num)
|
||||
|
||||
@cached(maxsize=1, ttl=3600)
|
||||
def get_customize_wallpaper(self) -> Optional[str]:
|
||||
"""
|
||||
获取自定义壁纸api壁纸
|
||||
"""
|
||||
wallpaper_list = self.get_customize_wallpapers()
|
||||
if wallpaper_list:
|
||||
return wallpaper_list[0]
|
||||
return None
|
||||
|
||||
@cached(maxsize=1, ttl=3600, skip_empty=True)
|
||||
def get_customize_wallpapers(self) -> List[str]:
|
||||
"""
|
||||
获取自定义壁纸api壁纸
|
||||
"""
|
||||
|
||||
def find_files_with_suffixes(obj, suffixes: List[str]) -> List[str]:
|
||||
"""
|
||||
递归查找对象中所有包含特定后缀的文件,返回匹配的字符串列表
|
||||
支持输入:字典、列表、字符串
|
||||
"""
|
||||
_result = []
|
||||
|
||||
# 处理字符串
|
||||
if isinstance(obj, str):
|
||||
if obj.endswith(tuple(suffixes)):
|
||||
_result.append(obj)
|
||||
|
||||
# 处理字典
|
||||
elif isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
_result.extend(find_files_with_suffixes(value, suffixes))
|
||||
|
||||
# 处理列表
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_result.extend(find_files_with_suffixes(item, suffixes))
|
||||
|
||||
return _result
|
||||
|
||||
# 判断是否存在自定义壁纸api
|
||||
if settings.CUSTOMIZE_WALLPAPER_API_URL:
|
||||
wallpaper_list = []
|
||||
resp = RequestUtils(timeout=15).get_res(settings.CUSTOMIZE_WALLPAPER_API_URL)
|
||||
if resp and resp.status_code == 200:
|
||||
# 如果返回的是图片格式
|
||||
content_type = resp.headers.get('Content-Type')
|
||||
if content_type and content_type.lower().startswith('image/'):
|
||||
wallpaper_list.append(settings.CUSTOMIZE_WALLPAPER_API_URL)
|
||||
else:
|
||||
try:
|
||||
result = resp.json()
|
||||
if isinstance(result, list) or isinstance(result, dict) or isinstance(result, str):
|
||||
wallpaper_list = find_files_with_suffixes(result, settings.SECURITY_IMAGE_SUFFIXES)
|
||||
except Exception as err:
|
||||
print(str(err))
|
||||
return wallpaper_list
|
||||
else:
|
||||
return []
|
||||
25
app/log.py
25
app/log.py
@@ -28,9 +28,9 @@ class LogConfigModel(BaseModel):
|
||||
# 日志文件最大大小(单位:MB)
|
||||
LOG_MAX_FILE_SIZE: int = 5
|
||||
# 备份的日志文件数量
|
||||
LOG_BACKUP_COUNT: int = 3
|
||||
LOG_BACKUP_COUNT: int = 10
|
||||
# 控制台日志格式
|
||||
LOG_CONSOLE_FORMAT: str = "%(leveltext)s%(message)s"
|
||||
LOG_CONSOLE_FORMAT: str = "%(leveltext)s[%(name)s] %(asctime)s %(message)s"
|
||||
# 文件日志格式
|
||||
LOG_FILE_FORMAT: str = "【%(levelname)s】%(asctime)s - %(message)s"
|
||||
|
||||
@@ -99,6 +99,24 @@ class LoggerManager:
|
||||
# 线程锁
|
||||
_lock = threading.Lock()
|
||||
|
||||
def get_logger(self, name: str) -> logging.Logger:
|
||||
"""
|
||||
获取一个指定名称的、独立的日志记录器。
|
||||
创建一个独立的日志文件,例如 'diag_memory.log'。
|
||||
:param name: 日志记录器的名称,也将用作文件名。
|
||||
:return: 一个配置好的 logging.Logger 实例。
|
||||
"""
|
||||
# 使用名称作为日志文件名
|
||||
logfile = f"{name}.log"
|
||||
with LoggerManager._lock:
|
||||
# 检查是否已经创建过这个 logger
|
||||
_logger = self._loggers.get(logfile)
|
||||
if not _logger:
|
||||
# 如果没有,就使用现有的 __setup_logger 来创建一个新的
|
||||
_logger = self.__setup_logger(log_file=logfile)
|
||||
self._loggers[logfile] = _logger
|
||||
return _logger
|
||||
|
||||
@staticmethod
|
||||
def __get_caller():
|
||||
"""
|
||||
@@ -189,6 +207,9 @@ class LoggerManager:
|
||||
file_handler.setFormatter(file_formatter)
|
||||
_logger.addHandler(file_handler)
|
||||
|
||||
# 禁止向父级log传递
|
||||
_logger.propagate = False
|
||||
|
||||
return _logger
|
||||
|
||||
def update_loggers(self):
|
||||
|
||||
15
app/main.py
15
app/main.py
@@ -1,5 +1,6 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
@@ -21,7 +22,7 @@ from app.db.init import init_db, update_db
|
||||
# uvicorn服务
|
||||
Server = uvicorn.Server(Config(app, host=settings.HOST, port=settings.PORT,
|
||||
reload=settings.DEV, workers=multiprocessing.cpu_count(),
|
||||
timeout_graceful_shutdown=5))
|
||||
timeout_graceful_shutdown=60))
|
||||
|
||||
|
||||
def start_tray():
|
||||
@@ -70,7 +71,19 @@ def start_tray():
|
||||
threading.Thread(target=TrayIcon.run, daemon=True).start()
|
||||
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
"""
|
||||
信号处理函数,用于优雅停止服务
|
||||
"""
|
||||
print(f"收到信号 {signum},开始优雅停止服务...")
|
||||
Server.should_exit = True
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 注册信号处理器
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# 启动托盘
|
||||
start_tray()
|
||||
# 初始化数据库
|
||||
|
||||
@@ -112,7 +112,7 @@ class ServiceBase(Generic[TService, TConf], metaclass=ABCMeta):
|
||||
# 通过服务类型或工厂函数来创建实例
|
||||
if isinstance(service_type, type):
|
||||
# 如果传入的是类类型,调用构造函数实例化
|
||||
self._instances[conf.name] = service_type(**conf.config)
|
||||
self._instances[conf.name] = service_type(name=conf.name, **conf.config)
|
||||
else:
|
||||
# 如果传入的是工厂函数,直接调用工厂函数
|
||||
self._instances[conf.name] = service_type(conf)
|
||||
@@ -191,8 +191,6 @@ class _MessageBase(ServiceBase[TService, NotificationConf]):
|
||||
|
||||
:return: 返回消息通知的配置字典
|
||||
"""
|
||||
if self._configs is not None:
|
||||
return self._configs
|
||||
configs = ServiceConfigHelper.get_notification_configs()
|
||||
if not self._service_name:
|
||||
return {}
|
||||
@@ -212,8 +210,8 @@ class _MessageBase(ServiceBase[TService, NotificationConf]):
|
||||
# 检查消息来源
|
||||
if message.source and message.source != source:
|
||||
return False
|
||||
# 检查消息类型开关
|
||||
if message.mtype:
|
||||
# 不是定向发送时,检查消息类型开关
|
||||
if not message.userid and message.mtype:
|
||||
conf = self.get_config(source)
|
||||
if conf:
|
||||
switchs = conf.switchs or []
|
||||
@@ -260,8 +258,6 @@ class _DownloaderBase(ServiceBase[TService, DownloaderConf]):
|
||||
|
||||
:return: 返回下载器配置字典
|
||||
"""
|
||||
if self._configs is not None:
|
||||
return self._configs
|
||||
configs = ServiceConfigHelper.get_downloader_configs()
|
||||
if not self._service_name:
|
||||
return {}
|
||||
@@ -279,8 +275,6 @@ class _MediaServerBase(ServiceBase[TService, MediaServerConf]):
|
||||
|
||||
:return: 返回媒体服务器配置字典
|
||||
"""
|
||||
if self._configs is not None:
|
||||
return self._configs
|
||||
configs = ServiceConfigHelper.get_mediaserver_configs()
|
||||
if not self._service_name:
|
||||
return {}
|
||||
|
||||
@@ -18,7 +18,7 @@ class BangumiModule(_ModuleBase):
|
||||
self.bangumiapi = BangumiApi()
|
||||
|
||||
def stop(self):
|
||||
pass
|
||||
self.bangumiapi.close()
|
||||
|
||||
def test(self) -> Tuple[bool, str]:
|
||||
"""
|
||||
|
||||
@@ -25,19 +25,18 @@ class BangumiApi(object):
|
||||
"person_credits": "v0/persons/%s/subjects",
|
||||
}
|
||||
_base_url = "https://api.bgm.tv/"
|
||||
_req = RequestUtils(session=requests.Session())
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
self._session = requests.Session()
|
||||
self._req = RequestUtils(session=self._session)
|
||||
|
||||
@classmethod
|
||||
@cached(maxsize=settings.CACHE_CONF["bangumi"], ttl=settings.CACHE_CONF["meta"])
|
||||
def __invoke(cls, url, key: Optional[str] = None, **kwargs):
|
||||
req_url = cls._base_url + url
|
||||
@cached(maxsize=settings.CONF["bangumi"], ttl=settings.CONF["meta"])
|
||||
def __invoke(self, url, key: Optional[str] = None, **kwargs):
|
||||
req_url = self._base_url + url
|
||||
params = {}
|
||||
if kwargs:
|
||||
params.update(kwargs)
|
||||
resp = cls._req.get_res(url=req_url, params=params)
|
||||
resp = self._req.get_res(url=req_url, params=params)
|
||||
try:
|
||||
if not resp:
|
||||
return None
|
||||
@@ -207,3 +206,7 @@ class BangumiApi(object):
|
||||
return self.__invoke(self._urls["discover"],
|
||||
key="data",
|
||||
_ts=datetime.strftime(datetime.now(), '%Y%m%d'), **kwargs)
|
||||
|
||||
def close(self):
|
||||
if self._session:
|
||||
self._session.close()
|
||||
|
||||
@@ -39,11 +39,9 @@ class DoubanModule(_ModuleBase):
|
||||
测试模块连接性
|
||||
"""
|
||||
ret = RequestUtils().get_res("https://movie.douban.com/")
|
||||
if ret and ret.status_code == 200:
|
||||
return True, ""
|
||||
elif ret:
|
||||
return False, f"无法连接豆瓣,错误码:{ret.status_code}"
|
||||
return False, "豆瓣网络连接失败"
|
||||
if ret is None:
|
||||
return False, "豆瓣网络连接失败"
|
||||
return True, ""
|
||||
|
||||
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||
pass
|
||||
|
||||
@@ -171,14 +171,14 @@ class DoubanApi(metaclass=Singleton):
|
||||
).digest()
|
||||
).decode()
|
||||
|
||||
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||
@cached(maxsize=settings.CONF["douban"], ttl=settings.CONF["meta"])
|
||||
def __invoke_recommend(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
推荐/发现类API
|
||||
"""
|
||||
return self.__invoke(url, **kwargs)
|
||||
|
||||
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||
@cached(maxsize=settings.CONF["douban"], ttl=settings.CONF["meta"])
|
||||
def __invoke_search(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
搜索类API
|
||||
@@ -213,7 +213,7 @@ class DoubanApi(metaclass=Singleton):
|
||||
return resp.json()
|
||||
return resp.json() if resp else {}
|
||||
|
||||
@cached(maxsize=settings.CACHE_CONF["douban"], ttl=settings.CACHE_CONF["meta"])
|
||||
@cached(maxsize=settings.CONF["douban"], ttl=settings.CONF["meta"])
|
||||
def __post(self, url: str, **kwargs) -> dict:
|
||||
"""
|
||||
POST请求
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.schemas.types import MediaType
|
||||
lock = RLock()
|
||||
|
||||
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
|
||||
EXPIRE_TIMESTAMP = settings.CACHE_CONF["meta"]
|
||||
EXPIRE_TIMESTAMP = settings.CONF["meta"]
|
||||
|
||||
|
||||
class DoubanCache(metaclass=Singleton):
|
||||
|
||||
@@ -2,11 +2,11 @@ from typing import Any, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app import schemas
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.log import logger
|
||||
from app.modules import _MediaServerBase, _ModuleBase
|
||||
from app.modules.emby.emby import Emby
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType
|
||||
from app.schemas.types import MediaType, ModuleType, ChainEventType, MediaServerType, SystemConfigKey, EventType
|
||||
|
||||
|
||||
class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
@@ -18,6 +18,19 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
super().init_service(service_name=Emby.__name__.lower(),
|
||||
service_type=lambda conf: Emby(**conf.config, sync_libraries=conf.sync_libraries))
|
||||
|
||||
@eventmanager.register(EventType.ConfigChanged)
|
||||
def handle_config_changed(self, event: Event):
|
||||
"""
|
||||
处理配置变更事件
|
||||
:param event: 事件对象
|
||||
"""
|
||||
if not event:
|
||||
return
|
||||
event_data: schemas.ConfigChangeEventData = event.event_data
|
||||
if event_data.key not in [SystemConfigKey.MediaServers.value]:
|
||||
return
|
||||
self.init_module()
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
return "Emby"
|
||||
@@ -269,7 +282,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
) for season, episodes in seasoninfo.items()]
|
||||
|
||||
def mediaserver_playing(self, server: str,
|
||||
count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
|
||||
count: Optional[int] = 20, username: Optional[str] = None) -> List[
|
||||
schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器正在播放信息
|
||||
"""
|
||||
@@ -288,7 +302,8 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
return server_obj.get_play_url(item_id)
|
||||
|
||||
def mediaserver_latest(self, server: Optional[str] = None,
|
||||
count: Optional[int] = 20, username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
|
||||
count: Optional[int] = 20, username: Optional[str] = None) -> List[
|
||||
schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
获取媒体服务器最新入库条目
|
||||
"""
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.log import logger
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.http import RequestUtils
|
||||
from app.utils.url import UrlUtils
|
||||
from schemas import MediaServerItem
|
||||
from app.schemas import MediaServerItem
|
||||
|
||||
|
||||
class Emby:
|
||||
@@ -1031,6 +1031,8 @@ class Emby:
|
||||
eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id,
|
||||
image_type="Backdrop")
|
||||
|
||||
eventItem.json_object = message
|
||||
|
||||
return eventItem
|
||||
|
||||
def get_data(self, url: str) -> Optional[Response]:
|
||||
|
||||
@@ -399,10 +399,28 @@ class FanartModule(_ModuleBase):
|
||||
if not mediainfo.get_image(season_image):
|
||||
mediainfo.set_image(season_image, image_obj.get('url'))
|
||||
else:
|
||||
# 其他图片,按欢迎程度倒排
|
||||
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
# 取第一张图片
|
||||
image_obj = images[0]
|
||||
# 其他图片,优先环境变量指定语言,再like最多
|
||||
def pick_best_image(images):
|
||||
lang_env = settings.FANART_LANG
|
||||
if lang_env:
|
||||
langs = [lang.strip() for lang in lang_env.split(",") if lang.strip()]
|
||||
for lang in langs:
|
||||
lang_images = [img for img in images if img.get('lang') == lang]
|
||||
if lang_images:
|
||||
lang_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return lang_images[0]
|
||||
# 没设置或没找到,按原逻辑 zh、en、like最多
|
||||
zh_images = [img for img in images if img.get('lang') == 'zh']
|
||||
if zh_images:
|
||||
zh_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return zh_images[0]
|
||||
en_images = [img for img in images if img.get('lang') == 'en']
|
||||
if en_images:
|
||||
en_images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return en_images[0]
|
||||
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
|
||||
return images[0]
|
||||
image_obj = pick_best_image(images)
|
||||
# 设置图片,没有图片才设置
|
||||
if not mediainfo.get_image(image_name):
|
||||
mediainfo.set_image(image_name, image_obj.get('url'))
|
||||
@@ -420,7 +438,7 @@ class FanartModule(_ModuleBase):
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
@cached(maxsize=settings.CACHE_CONF["fanart"], ttl=settings.CACHE_CONF["meta"])
|
||||
@cached(maxsize=settings.CONF["fanart"], ttl=settings.CONF["meta"])
|
||||
def __request_fanart(cls, media_type: MediaType, queryid: Union[str, int]) -> Optional[dict]:
|
||||
if media_type == MediaType.MOVIE:
|
||||
image_url = cls._movie_url % queryid
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user