Compare commits

..

71 Commits
v2.7.8 ... v1

Author SHA1 Message Date
jxxghp
1336b2136d Merge pull request #4340 from jtcymc/main 2025-05-25 07:59:41 +08:00
shaw
b20e21e700 fix(SearchChain): with 关闭线程池
- 使用 with 语句管理 ThreadPoolExecutor,确保线程池正确关闭
2025-05-25 00:50:34 +08:00
jxxghp
c27ab4a4c7 v1.9.19
- 默认关闭自动升级
2025-05-12 16:41:57 +08:00
jxxghp
d9e6532325 更新 version.py 2025-04-10 14:55:24 +08:00
jxxghp
049f16ba01 Merge pull request #4130 from cddjr/fix_v1_mteam 2025-04-10 14:36:21 +08:00
景大侠
6541458326 backport: 适配馒头API变动 2025-04-10 14:18:04 +08:00
jxxghp
9f2912426b Merge pull request #2833 from wikrin/main 2024-10-10 22:49:12 +08:00
Attente
fde33d267a fix: 修正重复的特殊字符
将重复的特殊字符 `—`[U+2014](https://symbl.cc/cn/2014/) 修改为 `―`[U+2015](https://symbl.cc/cn/2015/)
2024-10-10 22:23:43 +08:00
jxxghp
ef7f0afa37 v1.9.17
- 修复115扫码登录问题
- 索引站点新增支持 `PTLGS`
2024-09-18 17:52:40 +08:00
jxxghp
bea77a8243 fix 115 2024-09-18 13:39:39 +08:00
jxxghp
b984b83870 v1.9.16
- 修复了有些情况下新增目录类型为全部时不生效的问题
2024-09-08 13:11:59 +08:00
jxxghp
2153ad48db v1.9.15
- 修复部分通知消息查看详细链接错误的问题
- 修复了麒麟无法索引综艺的问题
- 修复了插件无升级提示图标的问题
2024-08-28 15:01:46 +08:00
jxxghp
c9c43fde74 Merge pull request #2638 from Linvery/main 2024-08-13 12:41:51 +08:00
Linvery
e2c9742f64 fix: 解决推送消息错误的url路径 2024-08-13 12:40:33 +08:00
jxxghp
3d459a40f7 - 仅调整了插件页面的UI 2024-08-13 11:46:01 +08:00
jxxghp
5675cd5b11 v1.9.13
- 修复已知问题
2024-07-30 06:36:30 +08:00
jxxghp
74a4d0bd66 Merge pull request #2614 from InfinityPacer/main 2024-07-30 06:33:00 +08:00
InfinityPacer
2b8c313019 refactor(event): 事件处理调整为深复制,避免多线程环境下数据异常 2024-07-29 23:18:58 +08:00
jxxghp
62fb6b80a3 Merge pull request #2612 from audichuang/main 2024-07-28 18:48:06 +08:00
audichuang
eea86528d8 Modifying the Bilingual Subtitle Matching Specification 2024-07-28 18:07:28 +08:00
jxxghp
84e6abb659 Merge pull request #2600 from InfinityPacer/main 2024-07-23 19:45:18 +08:00
InfinityPacer
da2c755b6d fix(Plugin): 重置插件时初始化调整为reload,以保留默认配置 2024-07-23 19:41:29 +08:00
jxxghp
51f39be9bc Merge pull request #2590 from Akimio521/main 2024-07-20 20:15:04 +08:00
Akimio521
21b762e75c perfect(releasegroup):完善anime字幕组 2024-07-20 20:05:05 +08:00
jxxghp
54095074b6 v1.9.12
- 新增认证站点:`ROUSI`
- 修复Telegram部分消息格式异常问题
2024-07-16 08:07:24 +08:00
jxxghp
33525730b5 fix #2515 2024-07-16 08:04:48 +08:00
jxxghp
71260f04b5 Merge pull request #2560 from InfinityPacer/main 2024-07-12 17:03:55 +08:00
InfinityPacer
e2acec321d fix tips 2024-07-12 16:48:44 +08:00
InfinityPacer
74a462a09f fix SitesHelper import tips 2024-07-12 16:30:06 +08:00
jxxghp
ad9e1a5da6 Merge pull request #2552 from BrettDean/main 2024-07-11 21:26:04 +08:00
Dean
d90e3c29a5 优化微信文本消息发送:支持长文本分块发送 2024-07-11 20:30:09 +08:00
jxxghp
19165eff75 Merge pull request #2537 from InfinityPacer/main 2024-07-09 11:01:53 +08:00
jxxghp
52d0703812 v1.9.11
- 支持环境变量配置DOH域名和DNS服务器
- 问题修复
2024-07-09 08:09:58 +08:00
InfinityPacer
1431a5e82a fix #2518 移除不必要的debug日志 2024-07-09 01:40:37 +08:00
jxxghp
23fe643526 Merge pull request #2534 from InfinityPacer/main 2024-07-08 12:23:16 +08:00
jxxghp
545b3c0482 Merge pull request #2527 from s0urcelab/main 2024-07-08 12:22:50 +08:00
InfinityPacer
f102119eef fix #2526 Backdrop优先调整为取art 2024-07-07 23:03:34 +08:00
s0urce
9bb3d707c9 feat: history query add title field 2024-07-07 18:27:06 +08:00
jxxghp
b892ef50dc Merge pull request #2526 from InfinityPacer/main 2024-07-07 16:49:11 +08:00
InfinityPacer
41e2907168 fix jxxghp/MoviePilot-Plugins#38 2024-07-07 16:32:37 +08:00
jxxghp
14e28ed693 Merge pull request #2518 from InfinityPacer/main 2024-07-07 08:58:39 +08:00
InfinityPacer
79393c21ff feat: 支持插件进行私钥认证 2024-07-06 20:03:49 +08:00
InfinityPacer
cafa4d217c feat: 增加指定的仓库Github token 2024-07-06 16:07:46 +08:00
jxxghp
2b9e69b112 Merge pull request #2515 from BrettDean/main 2024-07-06 07:50:59 +08:00
Dean
3ffcea70a7 Fixed parsing of Telegram entities 2024-07-06 01:44:51 +08:00
jxxghp
ffc72ba6fe fix #2508 2024-07-05 17:03:00 +08:00
jxxghp
848becd946 Merge pull request #2506 from Akimio521/main 2024-07-05 14:44:50 +08:00
Akimio521
71fe96d7f9 feat: 添加 DOH 解析服务器列表至配置文件实现自定义 DOH 服务器 2024-07-05 13:55:48 +08:00
jxxghp
35c7238ede Merge pull request #2503 from Akimio521/main 2024-07-05 11:31:21 +08:00
Akimio521
3578204508 style 2024-07-05 10:52:39 +08:00
Akimio521
c11cf17f62 style:app.core.config.settings 2024-07-05 10:34:57 +08:00
Akimio521
5a59652684 feat:将使用 DOH 域名解析的域名添加至 app.core.config.settings 2024-07-05 09:31:41 +08:00
jxxghp
7f5f31f143 Merge pull request #2484 from InfinityPacer/main 2024-07-02 06:12:13 +08:00
InfinityPacer
dc1cee80b1 fix plugin install and reg 2024-07-02 01:11:42 +08:00
jxxghp
92cb066748 更新 version.py 2024-07-01 21:46:07 +08:00
jxxghp
6c8ef4122b fix e5ec02e043 2024-07-01 12:23:02 +08:00
jxxghp
971b02ac8c - 重新兼容了v1.9.1之前的版本直接升级
- 索引站点新增支持`HDVBits`
- 自定义重命名新增季年份`season_year`占位符
- 修复了普通用户搜索越权问题
2024-07-01 10:46:29 +08:00
jxxghp
d4a9643f47 Merge pull request #2463 from InfinityPacer/main
处理链run_module支持raise_exception
2024-07-01 10:33:55 +08:00
InfinityPacer
e56d31fedc fix exception 2024-06-30 11:50:26 +08:00
InfinityPacer
b9d91c5cd7 feat: DoubanModule触发限流时支持立即抛出限流异常 2024-06-30 11:48:29 +08:00
InfinityPacer
57cdb57331 feat: retry支持立即抛出异常 2024-06-30 11:47:30 +08:00
InfinityPacer
0f7a7ef44f feat: 添加ImmediateException 2024-06-30 11:47:00 +08:00
InfinityPacer
6267b3f670 feat: run_module支持raise_exception 2024-06-30 11:41:00 +08:00
jxxghp
82f77b4729 Merge pull request #2456 from AisukaYuki/main 2024-06-30 09:09:36 +08:00
jxxghp
58da0ebb4f Merge pull request #2460 from thsrite/main 2024-06-30 09:08:35 +08:00
thsrite
7a43e43478 fix 删除文件未删除thumb.jpg 2024-06-29 20:12:26 +08:00
AisukaYuki
e5ec02e043 add 自定义重命名新增季年份season_year 2024-06-29 13:50:40 +08:00
jxxghp
2944c343a8 Merge pull request #2432 from InfinityPacer/main 2024-06-26 18:21:00 +08:00
InfinityPacer
940cc566c8 fix douban rate_limit tips 2024-06-26 18:17:31 +08:00
jxxghp
db7b2cdcac fix error 2024-06-26 17:42:08 +08:00
jxxghp
8111cf5dc8 - 站点索引及用户认证新增支持海胆之家 2024-06-26 16:18:14 +08:00
393 changed files with 17290 additions and 59898 deletions

View File

@@ -1,84 +1,3 @@
# Git
# Ignore git
.github
.git
.gitignore
# Documentation
docs/
README.md
LICENSE
# Development files
.pylintrc
*.pyc
__pycache__/
*.pyo
*.pyd
.Python
*.so
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
.hypothesis/
.mypy_cache/
.dmypy.json
dmypy.json
# Virtual environments
venv/
env/
ENV/
env.bak/
venv.bak/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
logs/
# Temporary files
*.tmp
*.temp
tmp/
temp/
# Database
*.db
*.sqlite
*.sqlite3
# Test files
tests/
test_*
*_test.py
# Build artifacts
build/
dist/
*.egg-info/
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Other
app.ico
frozen.spec
.git

View File

@@ -1,45 +0,0 @@
name: 功能提案
description: Request for Comments
title: "[RFC]"
labels: ["RFC"]
body:
- type: markdown
attributes:
value: |
一份提案(RFC)定位为 **「在某功能/重构的具体开发前,用于开发者间 review 技术设计/方案的文档」**
目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」,以及所有的开发者都能公开透明的参与讨论;
以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突)
因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。
如果仅希望讨论是否添加或改进某功能本身,请使用 -> [Issue: 功能改进](https://github.com/jxxghp/MoviePilot/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.yml&title=%5BFeature+Request%5D%3A+)
- type: textarea
id: background
attributes:
label: 背景 or 问题
description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。
validations:
required: true
- type: textarea
id: goal
attributes:
label: "目标 & 方案简述"
description: 简单描述提案此提案实现后,**预期的目标效果**,以及简单大致描述会采取的方案/步骤,可能会/不会产生什么影响。
validations:
required: true
- type: textarea
id: design
attributes:
label: "方案设计 & 实现步骤"
description: |
详细描述你设计的具体方案,可以考虑拆分列表或要点,一步步描述具体打算如何实现的步骤和相关细节。
这部份不需要一次性写完整,即使在创建完此提案 issue 后,依旧可以再次编辑修改。
validations:
required: false
- type: textarea
id: alternative
attributes:
label: "替代方案 & 对比"
description: |
[可选] 为来实现目标效果,还考虑过什么其他方案,有什么对比?
validations:
required: false

View File

@@ -1,60 +0,0 @@
name: MoviePilot Builder Beta
on:
workflow_dispatch:
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
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=beta
- 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: 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: docker/Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
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

View File

@@ -1,11 +1,11 @@
name: MoviePilot Builder v2
name: MoviePilot Builder
on:
workflow_dispatch:
push:
branches:
- v2
- main
paths:
- 'version.py'
- version.py
jobs:
Docker-build:
@@ -25,10 +25,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ secrets.DOCKER_USERNAME }}/moviepilot-v2
${{ secrets.DOCKER_USERNAME }}/moviepilot
ghcr.io/${{ github.repository }}
images: ${{ secrets.DOCKER_USERNAME }}/moviepilot
tags: |
type=raw,value=${{ env.app_version }}
type=raw,value=latest
@@ -45,54 +42,190 @@ 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: docker/Dockerfile
file: Dockerfile
platforms: |
linux/amd64
linux/arm64/v8
push: true
build-args: |
MOVIEPILOT_VERSION=${{ env.app_version }}
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
- name: Get existing release body
id: get_release_body
continue-on-error: true
Windows-build:
runs-on: windows-latest
name: Build Windows Binary
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Init Python 3.11.4
uses: actions/setup-python@v4
with:
python-version: '3.11.4'
cache: 'pip'
- name: Install Dependent Packages
run: |
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
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
shell: pwsh
- name: Delete Release
uses: dev-drprasad/delete-tag-and-release@v1.1
continue-on-error: true
with:
tag_name: v${{ env.app_version }}
delete_release: true
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Prepare Frontend
run: |
# 下载nginx
Invoke-WebRequest -Uri "http://nginx.org/download/nginx-1.25.2.zip" -OutFile "nginx.zip"
Expand-Archive -Path "nginx.zip" -DestinationPath "nginx-1.25.2"
Move-Item -Path "nginx-1.25.2/nginx-1.25.2" -Destination "nginx"
Remove-Item -Path "nginx.zip"
Remove-Item -Path "nginx-1.25.2" -Recurse -Force
# 下载前端
$FRONTEND_VERSION = (Invoke-WebRequest -Uri "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | ConvertFrom-Json).tag_name
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/$FRONTEND_VERSION/dist.zip" -OutFile "dist.zip"
Expand-Archive -Path "dist.zip" -DestinationPath "dist"
Move-Item -Path "dist/dist/*" -Destination "nginx/html" -Force
Remove-Item -Path "dist.zip"
Remove-Item -Path "dist" -Recurse -Force
Move-Item -Path "nginx/html/nginx.conf" -Destination "nginx/conf/nginx.conf" -Force
New-Item -Path "nginx/temp" -ItemType Directory -Force
New-Item -Path "nginx/temp/__keep__.txt" -ItemType File -Force
New-Item -Path "nginx/logs" -ItemType Directory -Force
New-Item -Path "nginx/logs/__keep__.txt" -ItemType File -Force
# 下载插件 jxxghp
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 thsrite
Invoke-WebRequest -Uri "https://github.com/thsrite/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 honue
Invoke-WebRequest -Uri "https://github.com/honue/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载插件 InfinityPacer
Invoke-WebRequest -Uri "https://github.com/InfinityPacer/MoviePilot-Plugins/archive/refs/heads/main.zip" -OutFile "MoviePilot-Plugins-main.zip"
Expand-Archive -Path "MoviePilot-Plugins-main.zip" -DestinationPath "MoviePilot-Plugins-main"
Move-Item -Path "MoviePilot-Plugins-main/MoviePilot-Plugins-main/plugins/*" -Destination "app/plugins/" -Force -ErrorAction SilentlyContinue
Remove-Item -Path "MoviePilot-Plugins-main.zip"
Remove-Item -Path "MoviePilot-Plugins-main" -Recurse -Force
# 下载资源
Invoke-WebRequest -Uri "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" -OutFile "MoviePilot-Resources-main.zip"
Expand-Archive -Path "MoviePilot-Resources-main.zip" -DestinationPath "MoviePilot-Resources-main"
Move-Item -Path "MoviePilot-Resources-main/MoviePilot-Resources-main/resources/*" -Destination "app/helper/" -Force
Remove-Item -Path "MoviePilot-Resources-main.zip"
Remove-Item -Path "MoviePilot-Resources-main" -Recurse -Force
shell: pwsh
- name: Generate Release
uses: softprops/action-gh-release@v2
- name: Pyinstaller
run: |
pyinstaller frozen.spec
shell: pwsh
- name: Upload Windows File
uses: actions/upload-artifact@v3
with:
tag_name: v${{ env.app_version }}
name: v${{ env.app_version }}
body: ${{ env.RELEASE_BODY }}
draft: false
prerelease: false
make_latest: true
name: windows
path: dist/MoviePilot.exe
Linux-build-amd64:
runs-on: ubuntu-latest
name: Build Linux Amd64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Init Python 3.11.4
uses: actions/setup-python@v4
with:
python-version: '3.11.4'
cache: 'pip'
- name: Install Dependent Packages
run: |
python -m pip install --upgrade pip
pip install wheel pyinstaller
pip install -r requirements.txt
find app/plugins -name requirements.txt -exec pip install -r {} \;
- name: Prepare Frontend
run: |
wget https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip
unzip main.zip
mv MoviePilot-Plugins-main/plugins/* app/plugins/
rm main.zip
rm -rf MoviePilot-Plugins-main
wget https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip
unzip main.zip
mv MoviePilot-Resources-main/resources/* app/helper/
rm main.zip
rm -rf MoviePilot-Resources-main
- name: Pyinstaller
run: |
pyinstaller frozen.spec
mv dist/MoviePilot dist/MoviePilot_Amd64
- name: Upload Linux File
uses: actions/upload-artifact@v3
with:
name: linux-amd64
path: dist/MoviePilot_Amd64
Create-release:
permissions: write-all
runs-on: ubuntu-latest
needs: [ Windows-build, Docker-build, Linux-build-amd64]
steps:
- uses: actions/checkout@v2
- name: Release Version
id: release_version
run: |
app_version=$(cat version.py |sed -ne "s/APP_VERSION\s=\s'v\(.*\)'/\1/gp")
echo "app_version=$app_version" >> $GITHUB_ENV
- name: Download Artifact
uses: actions/download-artifact@v3
- name: get release_informations
shell: bash
run: |
mkdir releases
mv ./windows/MoviePilot.exe ./releases/MoviePilot_Win_v${{ env.app_version }}.exe
mv ./linux-amd64/MoviePilot_Amd64 ./releases/MoviePilot_Amd64_v${{ env.app_version }}
- name: Create Release
id: create_release
uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: v${{ env.app_version }}
release_name: v${{ env.app_version }}
body: ${{ github.event.commits[0].message }}
draft: false
prerelease: false
- name: Upload Release Asset
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}
assets_path: |
./releases/

View File

@@ -1,32 +0,0 @@
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
# 排除带有RFC标签的issue
exempt-issue-labels: "RFC"
repo-token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,91 +0,0 @@
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 "📊 详细报告已保存为构建工件"

14
.gitignore vendored
View File

@@ -1,13 +1,9 @@
.idea/
*.c
*.so
*.pyd
build/
cython_cache/
dist/
nginx/
test.py
safety_report.txt
app/helper/sites.py
app/helper/*.so
app/helper/*.pyd
@@ -15,16 +11,10 @@ app/helper/*.bin
app/plugins/**
!app/plugins/__init__.py
config/cookies/**
config/user.db*
config/user.db
config/sites/**
config/logs/
config/temp/
config/cache/
*.pyc
*.log
.vscode
venv
# Pylint
pylint-report.json
.pylint.d/
.DS_Store

View File

@@ -1,83 +0,0 @@
[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

90
Dockerfile Normal file
View File

@@ -0,0 +1,90 @@
FROM python:3.11.4-slim-bookworm
ARG MOVIEPILOT_VERSION
ENV LANG="C.UTF-8" \
TZ="Asia/Shanghai" \
HOME="/moviepilot" \
CONFIG_DIR="/config" \
TERM="xterm" \
PUID=0 \
PGID=0 \
UMASK=000 \
PORT=3001 \
NGINX_PORT=3000 \
PROXY_HOST="" \
MOVIEPILOT_AUTO_UPDATE=false \
AUTH_SITE="iyuu" \
IYUU_SIGN=""
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 \
haproxy \
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.txt requirements.txt
RUN apt-get update -y \
&& apt-get install -y build-essential \
&& pip install --upgrade pip \
&& pip install Cython \
&& pip install -r requirements.txt \
&& playwright install-deps chromium \
&& 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 \
&& chmod +x /entrypoint /usr/local/bin/mp_update \
&& mkdir -p ${HOME} /var/lib/haproxy/server-state \
&& groupadd -r moviepilot -g 911 \
&& useradd -r moviepilot -g moviepilot -d ${HOME} -s /bin/bash -u 911 \
&& python_ver=$(python3 -V | awk '{print $2}') \
&& echo "/app/" > /usr/local/lib/python${python_ver%.*}/site-packages/app.pth \
&& echo 'fs.inotify.max_user_watches=5242880' >> /etc/sysctl.conf \
&& echo 'fs.inotify.max_user_instances=5242880' >> /etc/sysctl.conf \
&& locale-gen zh_CN.UTF-8 \
&& FRONTEND_VERSION=$(curl -sL "https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest" | jq -r .tag_name) \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Frontend/releases/download/${FRONTEND_VERSION}/dist.zip" | busybox unzip -d / - \
&& mv /dist /public \
&& curl -sL "https://github.com/jxxghp/MoviePilot-Plugins/archive/refs/heads/main.zip" | busybox unzip -d /tmp - \
&& mv -f /tmp/MoviePilot-Plugins-main/plugins/* /app/app/plugins/ \
&& 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/*
EXPOSE 3000
VOLUME [ "/config" ]
ENTRYPOINT [ "/entrypoint" ]

View File

@@ -6,7 +6,6 @@
![GitHub repo size](https://img.shields.io/github/repo-size/jxxghp/MoviePilot?style=for-the-badge)
![GitHub issues](https://img.shields.io/github/issues/jxxghp/MoviePilot?style=for-the-badge)
![Docker Pulls](https://img.shields.io/docker/pulls/jxxghp/moviepilot?style=for-the-badge)
![Docker Pulls V2](https://img.shields.io/docker/pulls/jxxghp/moviepilot-v2?style=for-the-badge)
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20Synology-blue?style=for-the-badge)
@@ -18,57 +17,13 @@
## 主要特性
- 前后端分离基于FastApi + Vue3
- 前后端分离基于FastApi + Vue3,前端项目地址:[MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)APIhttp://localhost:3001/docs
- 聚焦核心需求,简化功能和设置,部分设置项可直接使用默认值。
- 重新设计了用户界面,更加美观易用。
## 安装使用
官方Wikihttps://wiki.movie-pilot.org
## 参与开发
API文档https://api.movie-pilot.org
本地运行需要 `Python 3.12``Node JS v20.12.1`
- 克隆主项目 [MoviePilot](https://github.com/jxxghp/MoviePilot)
```shell
git clone https://github.com/jxxghp/MoviePilot
```
- 克隆资源项目 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) ,将 `resources` 目录下对应平台及版本的库 `.so`/`.pyd`/`.bin` 文件复制到 `app/helper` 目录
```shell
git clone https://github.com/jxxghp/MoviePilot-Resources
```
- 安装后端依赖,设置`app`为源代码根目录,运行 `main.py` 启动后端服务,默认监听端口:`3001`API文档地址`http://localhost:3001/docs`
```shell
pip install -r requirements.txt
python3 main.py
```
- 克隆前端项目 [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
```shell
git clone https://github.com/jxxghp/MoviePilot-Frontend
```
- 安装前端依赖,运行前端项目,访问:`http://localhost:5173`
```shell
yarn
yarn dev
```
- 参考 [插件开发指引](https://wiki.movie-pilot.org/zh/plugindev) 在 `app/plugins` 目录下开发插件代码
## 相关项目
- [MoviePilot-Frontend](https://github.com/jxxghp/MoviePilot-Frontend)
- [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources)
- [MoviePilot-Plugins](https://github.com/jxxghp/MoviePilot-Plugins)
- [MoviePilot-Server](https://github.com/jxxghp/MoviePilot-Server)
- [MoviePilot-Wiki](https://github.com/jxxghp/MoviePilot-Wiki)
## 免责申明
- 本软件仅供学习交流使用,任何人不得将本软件用于商业用途,任何人不得将本软件用于违法犯罪活动,软件对用户行为不知情,一切责任由使用者承担。
- 本软件代码开源,基于开源代码进行修改,人为去除相关限制导致软件被分发、传播并造成责任事件的,需由代码修改发布者承担全部责任,不建议对用户认证机制进行规避或修改并公开发布。
- 本项目不接受捐赠,没有在任何地方发布捐赠信息页面,软件本身不收费也不提供任何收费相关服务,请仔细辨别避免误导。
访问官方Wikihttps://wiki.movie-pilot.org
## 贡献者

View File

@@ -1,106 +0,0 @@
from abc import ABC, abstractmethod
from typing import Union
from app.chain import ChainBase
from app.db.systemconfig_oper import SystemConfigOper
from app.schemas import ActionContext, ActionParams
class ActionChain(ChainBase):
pass
class BaseAction(ABC):
"""
工作流动作基类
"""
# 动作ID
_action_id = None
# 完成标志
_done_flag = False
# 执行信息
_message = ""
# 缓存键值
_cache_key = "WorkflowCache-%s"
def __init__(self, action_id: str):
self._action_id = action_id
self.systemconfigoper = SystemConfigOper()
@classmethod
@property
@abstractmethod
def name(cls) -> str: # noqa
pass
@classmethod
@property
@abstractmethod
def description(cls) -> str: # noqa
pass
@classmethod
@property
@abstractmethod
def data(cls) -> dict: # noqa
pass
@property
def done(self) -> bool:
"""
判断动作是否完成
"""
return self._done_flag
@property
@abstractmethod
def success(self) -> bool:
"""
判断动作是否成功
"""
pass
@property
def message(self) -> str:
"""
执行信息
"""
return self._message
def job_done(self, message: str = None):
"""
标记动作完成
"""
self._message = message
self._done_flag = True
def check_cache(self, workflow_id: int, key: str) -> bool:
"""
检查是否处理过
"""
workflow_key = self._cache_key % workflow_id
workflow_cache = self.systemconfigoper.get(workflow_key) or {}
action_cache = workflow_cache.get(self._action_id) or []
return key in action_cache
def save_cache(self, workflow_id: int, data: Union[list, str]):
"""
保存缓存
"""
workflow_key = self._cache_key % workflow_id
workflow_cache = self.systemconfigoper.get(workflow_key) or {}
action_cache = workflow_cache.get(self._action_id) or []
if isinstance(data, list):
action_cache.extend(data)
else:
action_cache.append(data)
workflow_cache[self._action_id] = action_cache
self.systemconfigoper.set(workflow_key, workflow_cache)
@abstractmethod
def execute(self, workflow_id: int, params: ActionParams, context: ActionContext) -> ActionContext:
"""
执行动作
"""
raise NotImplementedError

View File

@@ -1,116 +0,0 @@
from typing import Optional
from pydantic import Field
from app.actions import BaseAction
from app.chain.download import DownloadChain
from app.chain.media import MediaChain
from app.core.config import global_vars
from app.core.metainfo import MetaInfo
from app.log import logger
from app.schemas import ActionParams, ActionContext, DownloadTask, MediaType
class AddDownloadParams(ActionParams):
"""
添加下载资源参数
"""
downloader: Optional[str] = Field(default=None, description="下载器")
save_path: Optional[str] = Field(default=None, description="保存路径")
labels: Optional[str] = Field(default=None, description="标签(,分隔)")
only_lack: Optional[bool] = Field(default=False, description="仅下载缺失的资源")
class AddDownloadAction(BaseAction):
"""
添加下载资源
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._added_downloads = []
self._has_error = 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 AddDownloadParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
将上下文中的torrents添加到下载任务中
"""
params = AddDownloadParams(**params)
_started = False
for t in context.torrents:
if global_vars.is_workflow_stopped(workflow_id):
break
# 检查缓存
cache_key = f"{t.torrent_info.site}-{t.torrent_info.title}"
if self.check_cache(workflow_id, cache_key):
logger.info(f"{t.torrent_info.title} 已添加过下载,跳过")
continue
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 = 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 = DownloadChain().media_exists(t.media_info)
if exists_info:
if t.media_info.type == MediaType.MOVIE:
# 电影
logger.warning(f"{t.torrent_info.title} 媒体库中已存在,跳过")
continue
else:
# 电视剧
exists_seasons = exists_info.seasons or {}
if len(t.meta_info.season_list) > 1:
# 多季不下载
logger.warning(f"{t.meta_info.title} 有多季,跳过")
continue
else:
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} 集已存在,跳过")
continue
_started = True
did = DownloadChain().download_single(context=t,
downloader=params.downloader,
save_path=params.save_path,
label=params.labels)
if did:
self._added_downloads.append(did)
# 保存缓存
self.save_cache(workflow_id, cache_key)
if self._added_downloads:
logger.info(f"已添加 {len(self._added_downloads)} 个下载任务")
context.downloads.extend(
[DownloadTask(download_id=did, downloader=params.downloader) for did in self._added_downloads]
)
elif _started:
self._has_error = True
self.job_done(f"已添加 {len(self._added_downloads)} 个下载任务")
return context

View File

@@ -1,88 +0,0 @@
from app.actions import BaseAction
from app.chain.subscribe import SubscribeChain
from app.core.config import settings, global_vars
from app.core.context import MediaInfo
from app.db.subscribe_oper import SubscribeOper
from app.log import logger
from app.schemas import ActionParams, ActionContext
class AddSubscribeParams(ActionParams):
"""
添加订阅参数
"""
pass
class AddSubscribeAction(BaseAction):
"""
添加订阅
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._added_subscribes = []
self._has_error = 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 AddSubscribeParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
将medias中的信息添加订阅如果订阅不存在的话
"""
_started = False
for media in context.medias:
if global_vars.is_workflow_stopped(workflow_id):
break
# 检查缓存
cache_key = f"{media.type}-{media.title}-{media.year}-{media.season}"
if self.check_cache(workflow_id, cache_key):
logger.info(f"{media.title} {media.year} 已添加过订阅,跳过")
continue
mediainfo = MediaInfo()
mediainfo.from_dict(media.dict())
subscribechain = SubscribeChain()
if subscribechain.exists(mediainfo):
logger.info(f"{media.title} 已存在订阅")
continue
# 添加订阅
_started = True
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)
# 保存缓存
self.save_cache(workflow_id, cache_key)
if self._added_subscribes:
logger.info(f"已添加 {len(self._added_subscribes)} 个订阅")
for sid in self._added_subscribes:
context.subscribes.append(SubscribeOper().get(sid))
elif _started:
self._has_error = True
self.job_done(f"已添加 {len(self._added_subscribes)} 个订阅")
return context

View File

@@ -1,65 +0,0 @@
from app.actions import BaseAction, ActionChain
from app.core.config import global_vars
from app.schemas import ActionParams, ActionContext
from app.log import logger
class FetchDownloadsParams(ActionParams):
"""
获取下载任务参数
"""
pass
class FetchDownloadsAction(BaseAction):
"""
获取下载任务
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._downloads = []
@classmethod
@property
def name(cls) -> str: # noqa
return "获取下载任务"
@classmethod
@property
def description(cls) -> str: # noqa
return "获取下载队列中的任务状态"
@classmethod
@property
def data(cls) -> dict: # noqa
return FetchDownloadsParams().dict()
@property
def success(self) -> bool:
return self.done
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
更新downloads中的下载任务状态
"""
__all_complete = False
for download in self._downloads:
if global_vars.is_workflow_stopped(workflow_id):
break
logger.info(f"获取下载任务 {download.download_id} 状态 ...")
torrents = ActionChain().list_torrents(hashs=[download.download_id])
if not torrents:
download.completed = True
continue
for t in torrents:
download.path = t.path
if t.progress >= 100:
logger.info(f"下载任务 {download.download_id} 已完成")
download.completed = True
else:
logger.info(f"下载任务 {download.download_id} 未完成")
download.completed = False
if all([d.completed for d in self._downloads]):
self.job_done()
return context

View File

@@ -1,186 +0,0 @@
from typing import List, Optional
from pydantic import Field
from app.actions import BaseAction
from app.chain.recommend import RecommendChain
from app.schemas import ActionParams, ActionContext
from app.core.config import settings, global_vars
from app.core.event import eventmanager
from app.log import logger
from app.schemas import RecommendSourceEventData, MediaInfo
from app.schemas.types import ChainEventType
from app.utils.http import RequestUtils
class FetchMediasParams(ActionParams):
"""
获取媒体数据参数
"""
source_type: Optional[str] = Field(default="ranking", description="来源")
sources: Optional[List[str]] = Field(default=[], description="榜单")
api_path: Optional[str] = Field(default=None, description="API路径")
class FetchMediasAction(BaseAction):
"""
获取媒体数据
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._medias = []
self._has_error = False
self.__inner_sources = [
{
"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"
}
]
# 广播事件,请示额外的推荐数据源支持
event_data = RecommendSourceEventData()
event = eventmanager.send_event(ChainEventType.RecommendSource, event_data)
# 使用事件返回的上下文数据
if event and event.event_data:
event_data: RecommendSourceEventData = event.event_data
if event_data.extra_sources:
self.__inner_sources.extend([s.dict() for s in event_data.extra_sources])
@classmethod
@property
def name(cls) -> str: # noqa
return "获取媒体数据"
@classmethod
@property
def description(cls) -> str: # noqa
return "获取榜单等媒体数据列表"
@classmethod
@property
def data(cls) -> dict: # noqa
return FetchMediasParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def __get_source(self, source: str):
"""
获取数据源
"""
for s in self.__inner_sources:
if s['api_path'] == source:
return s
return None
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
获取媒体数据填充到medias
"""
params = FetchMediasParams(**params)
try:
if params.source_type == "ranking":
for api_path in params.sources:
if global_vars.is_workflow_stopped(workflow_id):
break
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']()
else:
# 调用内部API获取数据
api_url = f"http://127.0.0.1:{settings.PORT}/api/v1/{source['api_path']}?token={settings.API_TOKEN}"
res = RequestUtils(timeout=15).post_res(api_url)
if res:
results = res.json()
if results:
logger.info(f"{name} 获取到 {len(results)} 条数据")
self._medias.extend([MediaInfo(**r) for r in results])
else:
logger.error(f"{name} 获取数据失败")
else:
# 调用内部API获取数据
api_url = f"http://127.0.0.1:{settings.PORT}{params.api_path}?token={settings.API_TOKEN}"
res = RequestUtils(timeout=15).post_res(api_url)
if res:
results = res.json()
if results:
logger.info(f"{params.api_path} 获取到 {len(results)} 条数据")
self._medias.extend([MediaInfo(**r) for r in results])
except Exception as e:
logger.error(f"获取媒体数据失败: {e}")
self._has_error = True
if self._medias:
context.medias.extend(self._medias)
self.job_done(f"获取到 {len(self._medias)} 条媒数据")
return context

View File

@@ -1,112 +0,0 @@
from typing import Optional
from pydantic import Field
from app.actions import BaseAction, ActionChain
from app.core.config import settings, global_vars
from app.core.context import Context
from app.core.metainfo import MetaInfo
from app.helper.rss import RssHelper
from app.log import logger
from app.schemas import ActionParams, ActionContext, TorrentInfo
class FetchRssParams(ActionParams):
"""
获取RSS资源列表参数
"""
url: str = Field(default=None, description="RSS地址")
proxy: Optional[bool] = Field(default=False, description="是否使用代理")
timeout: Optional[int] = Field(default=15, description="超时时间")
content_type: Optional[str] = Field(default=None, description="Content-Type")
referer: Optional[str] = Field(default=None, description="Referer")
ua: Optional[str] = Field(default=None, description="User-Agent")
match_media: Optional[str] = Field(default=None, description="匹配媒体信息")
class FetchRssAction(BaseAction):
"""
获取RSS资源列表
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._rss_torrents = []
self._has_error = False
@classmethod
@property
def name(cls) -> str: # noqa
return "获取RSS资源"
@classmethod
@property
def description(cls) -> str: # noqa
return "订阅RSS地址获取资源"
@classmethod
@property
def data(cls) -> dict: # noqa
return FetchRssParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
请求RSS地址获取数据并解析为资源列表
"""
params = FetchRssParams(**params)
if not params.url:
return context
headers = {}
if params.content_type:
headers["Content-Type"] = params.content_type
if params.referer:
headers["Referer"] = params.referer
if params.ua:
headers["User-Agent"] = params.ua
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
return context
if not rss_items:
logger.error(f'RSS地址 {params.url} 未获取到RSS数据')
return context
# 组装种子
for item in rss_items:
if global_vars.is_workflow_stopped(workflow_id):
break
if not item.get("title"):
continue
torrentinfo = TorrentInfo(
title=item.get("title"),
enclosure=item.get("enclosure"),
page_url=item.get("link"),
size=item.get("size"),
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
)
meta = MetaInfo(title=torrentinfo.title, subtitle=torrentinfo.description)
mediainfo = None
if params.match_media:
mediainfo = ActionChain().recognize_media(meta)
if not mediainfo:
logger.warning(f"{torrentinfo.title} 未识别到媒体信息")
continue
self._rss_torrents.append(Context(meta_info=meta, media_info=mediainfo, torrent_info=torrentinfo))
if self._rss_torrents:
logger.info(f"获取到 {len(self._rss_torrents)} 个RSS资源")
context.torrents.extend(self._rss_torrents)
self.job_done(f"获取到 {len(self._rss_torrents)} 个资源")
return context

View File

@@ -1,102 +0,0 @@
import random
import time
from typing import Optional, List
from pydantic import Field
from app.actions import BaseAction
from app.chain.search import SearchChain
from app.core.config import global_vars
from app.log import logger
from app.schemas import ActionParams, ActionContext, MediaType
class FetchTorrentsParams(ActionParams):
"""
获取站点资源参数
"""
search_type: Optional[str] = Field(default="keyword", description="搜索类型")
name: Optional[str] = Field(default=None, description="资源名称")
year: Optional[str] = Field(default=None, description="年份")
type: Optional[str] = Field(default=None, description="资源类型 (电影/电视剧)")
season: Optional[int] = Field(default=None, description="季度")
sites: Optional[List[int]] = Field(default=[], description="站点列表")
match_media: Optional[bool] = Field(default=False, description="匹配媒体信息")
class FetchTorrentsAction(BaseAction):
"""
搜索站点资源
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._torrents = []
@classmethod
@property
def name(cls) -> str: # noqa
return "搜索站点资源"
@classmethod
@property
def description(cls) -> str: # noqa
return "搜索站点种子资源列表"
@classmethod
@property
def data(cls) -> dict: # noqa
return FetchTorrentsParams().dict()
@property
def success(self) -> bool:
return self.done
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
搜索站点,获取资源列表
"""
params = FetchTorrentsParams(**params)
searchchain = SearchChain()
if params.search_type == "keyword":
# 按关键字搜索
torrents = searchchain.search_by_title(title=params.name, sites=params.sites)
for torrent in torrents:
if global_vars.is_workflow_stopped(workflow_id):
break
if params.year and torrent.meta_info.year != params.year:
continue
if params.type and torrent.media_info and torrent.media_info.type != MediaType(params.type):
continue
if params.season and torrent.meta_info.begin_season != params.season:
continue
# 识别媒体信息
if params.match_media:
torrent.media_info = searchchain.recognize_media(torrent.meta_info)
if not torrent.media_info:
logger.warning(f"{torrent.torrent_info.title} 未识别到媒体信息")
continue
self._torrents.append(torrent)
else:
# 搜索媒体列表
for media in context.medias:
if global_vars.is_workflow_stopped(workflow_id):
break
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)
# 随机休眠 5-30秒
sleep_time = random.randint(5, 30)
logger.info(f"随机休眠 {sleep_time} 秒 ...")
time.sleep(sleep_time)
if self._torrents:
context.torrents.extend(self._torrents)
logger.info(f"共搜索到 {len(self._torrents)} 条资源")
self.job_done(f"搜索到 {len(self._torrents)} 个资源")
return context

View File

@@ -1,69 +0,0 @@
from typing import Optional
from pydantic import Field
from app.actions import BaseAction
from app.core.config import global_vars
from app.log import logger
from app.schemas import ActionParams, ActionContext
class FilterMediasParams(ActionParams):
"""
过滤媒体数据参数
"""
type: Optional[str] = Field(default=None, description="媒体类型 (电影/电视剧)")
vote: Optional[int] = Field(default=0, description="评分")
year: Optional[str] = Field(default=None, description="年份")
class FilterMediasAction(BaseAction):
"""
过滤媒体数据
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._medias = []
@classmethod
@property
def name(cls) -> str: # noqa
return "过滤媒体数据"
@classmethod
@property
def description(cls) -> str: # noqa
return "对媒体数据列表进行过滤"
@classmethod
@property
def data(cls) -> dict: # noqa
return FilterMediasParams().dict()
@property
def success(self) -> bool:
return self.done
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
过滤medias中媒体数据
"""
params = FilterMediasParams(**params)
for media in context.medias:
if global_vars.is_workflow_stopped(workflow_id):
break
if params.type and media.type != params.type:
continue
if params.vote and media.vote_average < params.vote:
continue
if params.year and media.year != params.year:
continue
self._medias.append(media)
logger.info(f"过滤后剩余 {len(self._medias)} 条媒体数据")
context.medias = self._medias
self.job_done(f"过滤后剩余 {len(self._medias)} 条媒体数据")
return context

View File

@@ -1,84 +0,0 @@
from typing import Optional, List
from pydantic import Field
from app.actions import BaseAction, ActionChain
from app.core.config import global_vars
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import ActionParams, ActionContext
class FilterTorrentsParams(ActionParams):
"""
过滤资源数据参数
"""
rule_groups: Optional[List[str]] = Field(default=[], description="规则组")
quality: Optional[str] = Field(default=None, description="资源质量")
resolution: Optional[str] = Field(default=None, description="资源分辨率")
effect: Optional[str] = Field(default=None, description="特效")
include: Optional[str] = Field(default=None, description="包含规则")
exclude: Optional[str] = Field(default=None, description="排除规则")
size: Optional[str] = Field(default=None, description="资源大小范围MB")
class FilterTorrentsAction(BaseAction):
"""
过滤资源数据
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._torrents = []
@classmethod
@property
def name(cls) -> str: # noqa
return "过滤资源"
@classmethod
@property
def description(cls) -> str: # noqa
return "对资源列表数据进行过滤"
@classmethod
@property
def data(cls) -> dict: # noqa
return FilterTorrentsParams().dict()
@property
def success(self) -> bool:
return self.done
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
过滤torrents中的资源
"""
params = FilterTorrentsParams(**params)
for torrent in context.torrents:
if global_vars.is_workflow_stopped(workflow_id):
break
if TorrentHelper().filter_torrent(
torrent_info=torrent.torrent_info,
filter_params={
"quality": params.quality,
"resolution": params.resolution,
"effect": params.effect,
"include": params.include,
"exclude": params.exclude,
"size": params.size
}
):
if ActionChain().filter_torrents(
rule_groups=params.rule_groups,
torrent_list=[torrent.torrent_info],
mediainfo=torrent.media_info
):
self._torrents.append(torrent)
logger.info(f"过滤后剩余 {len(self._torrents)} 个资源")
context.torrents = self._torrents
self.job_done(f"过滤后剩余 {len(self._torrents)} 个资源")
return context

View File

@@ -1,70 +0,0 @@
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

View File

@@ -1,30 +0,0 @@
from app.actions import BaseAction
from app.schemas import ActionContext
class NoteAction(BaseAction):
"""
备注
"""
@classmethod
@property
def name(cls) -> str: # noqa
return "备注"
@classmethod
@property
def description(cls) -> str: # noqa
return "给工作流添加备注"
@classmethod
@property
def data(cls) -> dict: # noqa
return {}
@property
def success(self) -> bool:
return True
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
return context

View File

@@ -1,83 +0,0 @@
from pathlib import Path
from typing import Optional
from pydantic import Field
from app.actions import BaseAction
from app.chain.storage import StorageChain
from app.core.config import global_vars, settings
from app.log import logger
from app.schemas import ActionParams, ActionContext
class ScanFileParams(ActionParams):
"""
整理文件参数
"""
# 存储
storage: Optional[str] = Field(default="local", description="存储")
directory: Optional[str] = Field(default=None, description="目录")
class ScanFileAction(BaseAction):
"""
整理文件
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._fileitems = []
self._has_error = 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 ScanFileParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
扫描目录中的所有文件记录到fileitems
"""
params = ScanFileParams(**params)
if not params.storage or not params.directory:
return context
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 = storagechain.list_files(fileitem, recursion=True)
for file in files:
if global_vars.is_workflow_stopped(workflow_id):
break
if not file.extension or f".{file.extension.lower()}" not in settings.RMT_MEDIAEXT:
continue
# 检查缓存
cache_key = f"{file.path}"
if self.check_cache(workflow_id, cache_key):
logger.info(f"{file.path} 已处理过,跳过")
continue
self._fileitems.append(fileitem)
# 保存缓存
self.save_cache(workflow_id, cache_key)
if self._fileitems:
context.fileitems.extend(self._fileitems)
self.job_done(f"扫描到 {len(self._fileitems)} 个文件")
return context

View File

@@ -1,82 +0,0 @@
from pathlib import Path
from app.actions import BaseAction
from app.core.config import global_vars
from app.schemas import ActionParams, ActionContext
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.core.metainfo import MetaInfoPath
from app.log import logger
class ScrapeFileParams(ActionParams):
"""
刮削文件参数
"""
pass
class ScrapeFileAction(BaseAction):
"""
刮削文件
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._scraped_files = []
self._has_error = 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 ScrapeFileParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
刮削fileitems中的所有文件
"""
# 失败次数
_failed_count = 0
for fileitem in context.fileitems:
if global_vars.is_workflow_stopped(workflow_id):
break
if fileitem in self._scraped_files:
continue
if not StorageChain().exists(fileitem):
continue
# 检查缓存
cache_key = f"{fileitem.path}"
if self.check_cache(workflow_id, cache_key):
logger.info(f"{fileitem.path} 已刮削过,跳过")
continue
meta = MetaInfoPath(Path(fileitem.path))
mediachain = MediaChain()
mediainfo = mediachain.recognize_media(meta)
if not mediainfo:
_failed_count += 1
logger.info(f"{fileitem.path} 未识别到媒体信息,无法刮削")
continue
mediachain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo)
self._scraped_files.append(fileitem)
# 保存缓存
self.save_cache(workflow_id, cache_key)
if not self._scraped_files and _failed_count:
self._has_error = True
self.job_done(f"成功刮削 {len(self._scraped_files)} 个文件,失败 {_failed_count}")
return context

View File

@@ -1,48 +0,0 @@
from app.actions import BaseAction
from app.core.event import eventmanager
from app.schemas import ActionParams, ActionContext
from app.schemas.types import ChainEventType
class SendEventParams(ActionParams):
"""
发送事件参数
"""
pass
class SendEventAction(BaseAction):
"""
发送事件
"""
@classmethod
@property
def name(cls) -> str: # noqa
return "发送事件"
@classmethod
@property
def description(cls) -> str: # noqa
return "发送任务执行事件"
@classmethod
@property
def data(cls) -> dict: # noqa
return SendEventParams().dict()
@property
def success(self) -> bool:
return self.done
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
发送工作流事件,以更插件干预工作流执行
"""
# 触发资源下载事件,更新执行上下文
event = eventmanager.send_event(ChainEventType.WorkflowExecution, context)
if event and event.event_data:
context = event.event_data
self.job_done()
return context

View File

@@ -1,73 +0,0 @@
from typing import List, Optional, Union
from pydantic import Field
from app.actions import BaseAction, ActionChain
from app.schemas import ActionParams, ActionContext, Notification
from app.core.config import settings
class SendMessageParams(ActionParams):
"""
发送消息参数
"""
client: Optional[List[str]] = Field(default=[], description="消息渠道")
userid: Optional[Union[str, int]] = Field(default=None, description="用户ID")
class SendMessageAction(BaseAction):
"""
发送消息
"""
def __init__(self, action_id: str):
super().__init__(action_id)
@classmethod
@property
def name(cls) -> str: # noqa
return "发送消息"
@classmethod
@property
def description(cls) -> str: # noqa
return "发送任务执行消息"
@classmethod
@property
def data(cls) -> dict: # noqa
return SendMessageParams().dict()
@property
def success(self) -> bool:
return self.done
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
发送messages中的消息
"""
params = SendMessageParams(**params)
msg_text = f"当前进度:{context.progress}%"
index = 1
if context.execute_history:
for history in context.execute_history:
if not history.message:
continue
msg_text += f"\n{index}. {history.action}{history.message}"
index += 1
# 发送消息
if not params.client:
params.client = [""]
for client in params.client:
ActionChain().post_message(
Notification(
source=client,
userid=params.userid,
title="【工作流执行结果】",
text=msg_text,
link=settings.MP_DOMAIN("#/workflow")
)
)
self.job_done()
return context

View File

@@ -1,136 +0,0 @@
import copy
from pathlib import Path
from typing import Optional
from pydantic import Field
from app.actions import BaseAction
from app.core.config import global_vars
from app.db.transferhistory_oper import TransferHistoryOper
from app.schemas import ActionParams, ActionContext
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.log import logger
class TransferFileParams(ActionParams):
"""
整理文件参数
"""
# 来源
source: Optional[str] = Field(default="downloads", description="来源")
class TransferFileAction(BaseAction):
"""
整理文件
"""
def __init__(self, action_id: str):
super().__init__(action_id)
self._fileitems = []
self._has_error = 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 TransferFileParams().dict()
@property
def success(self) -> bool:
return not self._has_error
def execute(self, workflow_id: int, params: dict, context: ActionContext) -> ActionContext:
"""
从 downloads / fileitems 中整理文件记录到fileitems
"""
def check_continue():
"""
检查是否继续整理文件
"""
if global_vars.is_workflow_stopped(workflow_id):
return False
return True
params = TransferFileParams(**params)
# 失败次数
_failed_count = 0
storagechain = StorageChain()
transferchain = TransferChain()
transferhis = TransferHistoryOper()
if params.source == "downloads":
# 从下载任务中整理文件
for download in context.downloads:
if global_vars.is_workflow_stopped(workflow_id):
break
if not download.completed:
logger.info(f"下载任务 {download.download_id} 未完成")
continue
# 检查缓存
cache_key = f"{download.download_id}"
if self.check_cache(workflow_id, cache_key):
logger.info(f"{download.path} 已整理过,跳过")
continue
fileitem = storagechain.get_file_item(storage="local", path=Path(download.path))
if not fileitem:
logger.info(f"文件 {download.path} 不存在")
continue
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
if transferd:
# 已经整理过的文件不再整理
continue
logger.info(f"开始整理文件 {download.path} ...")
state, errmsg = transferchain.do_transfer(fileitem, background=False)
if not state:
_failed_count += 1
logger.error(f"整理文件 {download.path} 失败: {errmsg}")
continue
logger.info(f"整理文件 {download.path} 完成")
self._fileitems.append(fileitem)
self.save_cache(workflow_id, cache_key)
else:
# 从 fileitems 中整理文件
for fileitem in copy.deepcopy(context.fileitems):
if not check_continue():
break
# 检查缓存
cache_key = f"{fileitem.path}"
if self.check_cache(workflow_id, cache_key):
logger.info(f"{fileitem.path} 已整理过,跳过")
continue
transferd = transferhis.get_by_src(fileitem.path, storage=fileitem.storage)
if transferd:
# 已经整理过的文件不再整理
continue
logger.info(f"开始整理文件 {fileitem.path} ...")
state, errmsg = transferchain.do_transfer(fileitem, background=False,
continue_callback=check_continue)
if not state:
_failed_count += 1
logger.error(f"整理文件 {fileitem.path} 失败: {errmsg}")
continue
logger.info(f"整理文件 {fileitem.path} 完成")
# 从 fileitems 中移除已整理的文件
context.fileitems.remove(fileitem)
self._fileitems.append(fileitem)
# 记录已整理的文件
self.save_cache(workflow_id, cache_key)
if self._fileitems:
context.fileitems.extend(self._fileitems)
elif _failed_count:
self._has_error = True
self.job_done(f"整理成功 {len(self._fileitems)} 个文件,失败 {_failed_count}")
return context

View File

@@ -1,8 +1,8 @@
from fastapi import APIRouter
from app.api.endpoints import login, user, webhook, message, site, subscribe, \
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, torrent, monitoring
local, transfer, mediaserver, bangumi, aliyun, u115
api_router = APIRouter()
api_router.include_router(login.router, prefix="/login", tags=["login"])
@@ -20,12 +20,9 @@ api_router.include_router(system.router, prefix="/system", tags=["system"])
api_router.include_router(plugin.router, prefix="/plugin", tags=["plugin"])
api_router.include_router(download.router, prefix="/download", tags=["download"])
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
api_router.include_router(storage.router, prefix="/storage", tags=["storage"])
api_router.include_router(local.router, prefix="/local", tags=["local"])
api_router.include_router(transfer.router, prefix="/transfer", tags=["transfer"])
api_router.include_router(mediaserver.router, prefix="/mediaserver", tags=["mediaserver"])
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"])
api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"])
api_router.include_router(aliyun.router, prefix="/aliyun", tags=["aliyun"])
api_router.include_router(u115.router, prefix="/u115", tags=["115"])

198
app/api/endpoints/aliyun.py Normal file
View File

@@ -0,0 +1,198 @@
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import Response
from app import schemas
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.helper.aliyun import AliyunHelper
from app.helper.progress import ProgressHelper
from app.schemas.types import ProgressKey
router = APIRouter()
@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response)
def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
"""
qrcode_data, errmsg = AliyunHelper().generate_qrcode()
if qrcode_data:
return schemas.Response(success=True, data=qrcode_data)
return schemas.Response(success=False, message=errmsg)
@router.get("/check", summary="二维码登录确认", response_model=schemas.Response)
def check(ck: str, t: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
二维码登录确认
"""
if not ck or not t:
return schemas.Response(success=False, message="参数错误")
data, errmsg = AliyunHelper().check_login(ck, t)
if data:
return schemas.Response(success=True, data=data)
return schemas.Response(success=False, message=errmsg)
@router.get("/userinfo", summary="查询用户信息", response_model=schemas.Response)
def userinfo(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询用户信息
"""
aliyunhelper = AliyunHelper()
# 查询用户信息返回
info = aliyunhelper.user_info()
if info:
return schemas.Response(success=True, data=info)
return schemas.Response(success=False)
@router.post("/list", summary="所有目录和文件(阿里云盘)", response_model=List[schemas.FileItem])
def list_aliyun(fileitem: schemas.FileItem,
sort: str = 'updated_at',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件夹信息
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
if not fileitem.fileid:
return []
if not fileitem.path:
path = "/"
else:
path = fileitem.path
if sort == "time":
sort = "updated_at"
if fileitem.type == "file":
fileitem = AliyunHelper().detail(drive_id=fileitem.drive_id, file_id=fileitem.fileid, path=path)
if fileitem:
return [fileitem]
return []
return AliyunHelper().list(drive_id=fileitem.drive_id,
parent_file_id=fileitem.fileid,
path=path,
order_by=sort)
@router.post("/mkdir", summary="创建目录(阿里云盘)", response_model=schemas.Response)
def mkdir_aliyun(fileitem: schemas.FileItem,
name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not fileitem.fileid or not name:
return schemas.Response(success=False)
result = AliyunHelper().create_folder(drive_id=fileitem.drive_id, parent_file_id=fileitem.fileid,
name=name, path=fileitem.path)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录(阿里云盘)", response_model=schemas.Response)
def delete_aliyun(fileitem: schemas.FileItem,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not fileitem.fileid:
return schemas.Response(success=False)
result = AliyunHelper().delete(drive_id=fileitem.drive_id, file_id=fileitem.fileid)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/download", summary="下载文件(阿里云盘)")
def download_aliyun(fileid: str,
drive_id: str = None,
_: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
下载文件或目录
"""
if not fileid:
return schemas.Response(success=False)
url = AliyunHelper().download(drive_id=drive_id, file_id=fileid)
if url:
# 重定向
return Response(status_code=302, headers={"Location": url})
raise HTTPException(status_code=500, detail="下载文件出错")
@router.post("/rename", summary="重命名文件或目录(阿里云盘)", response_model=schemas.Response)
def rename_aliyun(fileitem: schemas.FileItem,
new_name: str,
recursive: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not fileitem.fileid or not new_name:
return schemas.Response(success=False)
result = AliyunHelper().rename(drive_id=fileitem.drive_id, file_id=fileitem.fileid, name=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] = list_aliyun(fileitem=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_aliyun(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)
@router.get("/image", summary="读取图片(阿里云盘)", response_model=schemas.Response)
def image_aliyun(fileid: str, drive_id: str = None, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
读取图片
"""
if not fileid:
return schemas.Response(success=False)
url = AliyunHelper().download(drive_id=drive_id, file_id=fileid)
if url:
# 重定向
return Response(status_code=302, headers={"Location": url})
raise HTTPException(status_code=500, detail="下载图片出错")

View File

@@ -1,4 +1,4 @@
from typing import List, Any, Optional
from typing import List, Any
from fastapi import APIRouter, Depends
@@ -10,64 +10,76 @@ from app.core.security import verify_token
router = APIRouter()
@router.get("/calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo])
def calendar(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览Bangumi每日放送
"""
medias = BangumiChain().calendar()
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return []
@router.get("/credits/{bangumiid}", summary="查询Bangumi演职员表", response_model=List[schemas.MediaPerson])
async def bangumi_credits(bangumiid: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def bangumi_credits(bangumiid: int,
page: int = 1,
count: int = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询Bangumi演职员表
"""
persons = await BangumiChain().async_bangumi_credits(bangumiid)
persons = BangumiChain().bangumi_credits(bangumiid)
if persons:
return persons[(page - 1) * count: page * count]
return []
@router.get("/recommend/{bangumiid}", summary="查询Bangumi推荐", response_model=List[schemas.MediaInfo])
async def bangumi_recommend(bangumiid: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def bangumi_recommend(bangumiid: int,
page: int = 1,
count: int = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询Bangumi推荐
"""
medias = await BangumiChain().async_bangumi_recommend(bangumiid)
medias = BangumiChain().bangumi_recommend(bangumiid)
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return []
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
async def bangumi_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def bangumi_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
return await BangumiChain().async_person_detail(person_id=person_id)
return BangumiChain().person_detail(person_id=person_id)
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
async def bangumi_person_credits(person_id: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def bangumi_person_credits(person_id: int,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物参演作品
"""
medias = await BangumiChain().async_person_credits(person_id=person_id)
medias = BangumiChain().person_credits(person_id=person_id)
if medias:
return [media.to_dict() for media in medias[(page - 1) * count: page * count]]
return [media.to_dict() for media in medias[(page - 1) * 20: page * 20]]
return []
@router.get("/{bangumiid}", summary="查询Bangumi详情", response_model=schemas.MediaInfo)
async def bangumi_info(bangumiid: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def bangumi_info(bangumiid: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询Bangumi详情
"""
info = await BangumiChain().async_bangumi_info(bangumiid)
info = BangumiChain().bangumi_info(bangumiid)
if info:
return MediaInfo(bangumi_info=info).to_dict()
else:

View File

@@ -1,12 +1,11 @@
from pathlib import Path
from typing import Any, List, Optional, Annotated
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.dashboard import DashboardChain
from app.chain.storage import StorageChain
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models.transferhistory import TransferHistory
@@ -18,11 +17,11 @@ router = APIRouter()
@router.get("/statistic", summary="媒体数量统计", response_model=schemas.Statistic)
def statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询媒体数量统计信息
"""
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic(name)
media_statistics: Optional[List[schemas.Statistic]] = DashboardChain().media_statistic()
if media_statistics:
# 汇总各媒体库统计信息
ret_statistic = schemas.Statistic()
@@ -37,38 +36,30 @@ def statistic(name: Optional[str] = None, _: schemas.TokenPayload = Depends(veri
@router.get("/statistic2", summary="媒体数量统计API_TOKEN", response_model=schemas.Statistic)
def statistic2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def statistic2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询媒体数量统计信息 API_TOKEN认证?token=xxx
"""
return statistic()
@router.get("/storage", summary="本地存储空间", response_model=schemas.Storage)
@router.get("/storage", summary="存储空间", response_model=schemas.Storage)
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询本地存储空间信息
查询存储空间信息
"""
total, available = 0, 0
dirs = DirectoryHelper().get_dirs()
if not dirs:
return schemas.Storage(total_storage=total, used_storage=total - available)
storages = set([d.library_storage for d in dirs if d.library_storage])
for _storage in storages:
_usage = StorageChain().storage_usage(_storage)
if _usage:
total += _usage.total
available += _usage.available
library_dirs = DirectoryHelper().get_library_dirs()
total_storage, free_storage = SystemUtils.space_usage([Path(d.path) for d in library_dirs if d.path])
return schemas.Storage(
total_storage=total,
used_storage=total - available
total_storage=total_storage,
used_storage=total_storage - free_storage
)
@router.get("/storage2", summary="本地存储空间API_TOKEN", response_model=schemas.Storage)
def storage2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.get("/storage2", summary="存储空间API_TOKEN", response_model=schemas.Storage)
def storage2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询本地存储空间信息 API_TOKEN认证?token=xxx
查询存储空间信息 API_TOKEN认证?token=xxx
"""
return storage()
@@ -82,16 +73,16 @@ def processes(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/downloader", summary="下载器信息", response_model=schemas.DownloaderInfo)
def downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
def downloader(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询下载器信息
"""
# 下载目录空间
download_dirs = DirectoryHelper().get_local_download_dirs()
_, free_space = SystemUtils.space_usage([Path(d.download_path) for d in download_dirs])
download_dirs = DirectoryHelper().get_download_dirs()
_, free_space = SystemUtils.space_usage([Path(d.path) for d in download_dirs if d.path])
# 下载器信息
downloader_info = schemas.DownloaderInfo()
transfer_infos = DashboardChain().downloader_info(name)
transfer_infos = DashboardChain().downloader_info()
if transfer_infos:
for transfer_info in transfer_infos:
downloader_info.download_speed += transfer_info.download_speed
@@ -103,7 +94,7 @@ def downloader(name: Optional[str] = None, _: schemas.TokenPayload = Depends(ver
@router.get("/downloader2", summary="下载器信息API_TOKEN", response_model=schemas.DownloaderInfo)
def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def downloader2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
@@ -111,7 +102,7 @@ def downloader2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.get("/schedule", summary="后台服务", response_model=List[schemas.ScheduleInfo])
async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询后台服务信息
"""
@@ -119,7 +110,7 @@ async def schedule(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/schedule2", summary="后台服务API_TOKEN", response_model=List[schemas.ScheduleInfo])
async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def schedule2(_: str = Depends(verify_apitoken)) -> Any:
"""
查询下载器信息 API_TOKEN认证?token=xxx
"""
@@ -127,13 +118,12 @@ async def schedule2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
@router.get("/transfer", summary="文件整理统计", response_model=List[int])
async def transfer(days: Optional[int] = 7,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def transfer(days: int = 7, db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询文件整理统计信息
"""
transfer_stat = await TransferHistory.async_statistic(db, days)
transfer_stat = TransferHistory.statistic(db, days)
return [stat[1] for stat in transfer_stat]
@@ -146,7 +136,7 @@ def cpu(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/cpu2", summary="获取当前CPU使用率API_TOKEN", response_model=int)
def cpu2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def cpu2(_: str = Depends(verify_apitoken)) -> Any:
"""
获取当前CPU使用率 API_TOKEN认证?token=xxx
"""
@@ -162,24 +152,8 @@ def memory(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/memory2", summary="获取当前内存使用量和使用率API_TOKEN", response_model=List[int])
def memory2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def memory2(_: str = Depends(verify_apitoken)) -> Any:
"""
获取当前内存使用率 API_TOKEN认证?token=xxx
"""
return memory()
@router.get("/network", summary="获取当前网络流量", response_model=List[int])
def network(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取当前网络流量上行和下行流量单位bytes/s
"""
return SystemUtils.network_usage()
@router.get("/network2", summary="获取当前网络流量API_TOKEN", response_model=List[int])
def network2(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取当前网络流量 API_TOKEN认证?token=xxx
"""
return network()

View File

@@ -1,130 +0,0 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
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
router = APIRouter()
@router.get("/source", summary="获取探索数据源", response_model=List[schemas.DiscoverMediaSource])
def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取探索数据源
"""
# 广播事件,请示额外的探索数据源支持
event_data = DiscoverSourceEventData()
event = eventmanager.send_event(ChainEventType.DiscoverSource, event_data)
# 使用事件返回的上下文数据
if event and event.event_data:
event_data: DiscoverSourceEventData = event.event_data
if event_data.extra_sources:
return event_data.extra_sources
return []
@router.get("/bangumi", summary="探索Bangumi", response_model=List[schemas.MediaInfo])
async def bangumi(type: Optional[int] = 2,
cat: Optional[int] = None,
sort: Optional[str] = 'rank',
year: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
探索Bangumi
"""
medias = await BangumiChain().async_discover(type=type, cat=cat, sort=sort, year=year,
limit=count, offset=(page - 1) * count)
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/douban_movies", summary="探索豆瓣电影", response_model=List[schemas.MediaInfo])
async def douban_movies(sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣电影信息
"""
movies = await DoubanChain().async_douban_discover(mtype=MediaType.MOVIE,
sort=sort, tags=tags, page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@router.get("/douban_tvs", summary="探索豆瓣剧集", response_model=List[schemas.MediaInfo])
async def douban_tvs(sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
tvs = await DoubanChain().async_douban_discover(mtype=MediaType.TV,
sort=sort, tags=tags, page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []
@router.get("/tmdb_movies", summary="探索TMDB电影", response_model=List[schemas.MediaInfo])
async def tmdb_movies(sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB电影信息
"""
movies = await TmdbChain().async_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 []
@router.get("/tmdb_tvs", summary="探索TMDB剧集", response_model=List[schemas.MediaInfo])
async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB剧集信息
"""
tvs = await TmdbChain().async_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 []

View File

@@ -1,65 +1,206 @@
from typing import Any, List, Optional
from typing import List, Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Response
from app import schemas
from app.chain.douban import DoubanChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.security import verify_token
from app.schemas import MediaType
from app.utils.http import RequestUtils
router = APIRouter()
@router.get("/img", summary="豆瓣图片代理")
def douban_img(imgurl: str) -> Any:
"""
豆瓣图片代理
"""
if not imgurl:
return None
response = RequestUtils(headers={
'Referer': "https://movie.douban.com/"
}, ua=settings.USER_AGENT).get_res(url=imgurl)
if response:
return Response(content=response.content, media_type="image/jpeg")
return None
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
async def douban_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def douban_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
return await DoubanChain().async_person_detail(person_id=person_id)
return DoubanChain().person_detail(person_id=person_id)
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
async def douban_person_credits(person_id: int,
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def douban_person_credits(person_id: int,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物参演作品
"""
medias = await DoubanChain().async_person_credits(person_id=person_id, page=page)
medias = DoubanChain().person_credits(person_id=person_id, page=page)
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
def movie_showing(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣正在热映
"""
movies = DoubanChain().movie_showing(page=page, count=count)
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
def douban_movies(sort: str = "R",
tags: str = "",
page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣电影信息
"""
movies = DoubanChain().douban_discover(mtype=MediaType.MOVIE,
sort=sort, tags=tags, page=page, count=count)
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
def douban_tvs(sort: str = "R",
tags: str = "",
page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
tvs = DoubanChain().douban_discover(mtype=MediaType.TV,
sort=sort, tags=tags, page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
def movie_top250(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
movies = DoubanChain().movie_top250(page=page, count=count)
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
def tv_weekly_chinese(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
中国每周剧集口碑榜
"""
tvs = DoubanChain().tv_weekly_chinese(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
def tv_weekly_global(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
全球每周剧集口碑榜
"""
tvs = DoubanChain().tv_weekly_global(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
def tv_animation(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门动画剧集
"""
tvs = DoubanChain().tv_animation(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
def movie_hot(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门电影
"""
movies = DoubanChain().movie_hot(page=page, count=count)
if movies:
return [media.to_dict() for media in movies]
return []
@router.get("/tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
def tv_hot(page: int = 1,
count: int = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门电视剧
"""
tvs = DoubanChain().tv_hot(page=page, count=count)
if tvs:
return [media.to_dict() for media in tvs]
return []
@router.get("/credits/{doubanid}/{type_name}", summary="豆瓣演员阵容", response_model=List[schemas.MediaPerson])
async def douban_credits(doubanid: str,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def douban_credits(doubanid: str,
type_name: str,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据豆瓣ID查询演员阵容type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
return await DoubanChain().async_movie_credits(doubanid=doubanid)
return DoubanChain().movie_credits(doubanid=doubanid)
elif mediatype == MediaType.TV:
return await DoubanChain().async_tv_credits(doubanid=doubanid)
return DoubanChain().tv_credits(doubanid=doubanid)
return []
@router.get("/recommend/{doubanid}/{type_name}", summary="豆瓣推荐电影/电视剧", response_model=List[schemas.MediaInfo])
async def douban_recommend(doubanid: str,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def douban_recommend(doubanid: str,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据豆瓣ID查询推荐电影/电视剧type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
medias = await DoubanChain().async_movie_recommend(doubanid=doubanid)
medias = DoubanChain().movie_recommend(doubanid=doubanid)
elif mediatype == MediaType.TV:
medias = await DoubanChain().async_tv_recommend(doubanid=doubanid)
medias = DoubanChain().tv_recommend(doubanid=doubanid)
else:
return []
if medias:
@@ -68,12 +209,12 @@ async def douban_recommend(doubanid: str,
@router.get("/{doubanid}", summary="查询豆瓣详情", response_model=schemas.MediaInfo)
async def douban_info(doubanid: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def douban_info(doubanid: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据豆瓣ID查询豆瓣媒体信息
"""
doubaninfo = await DoubanChain().async_douban_info(doubanid=doubanid)
doubaninfo = DoubanChain().douban_info(doubanid=doubanid)
if doubaninfo:
return MediaInfo(douban_info=doubaninfo).to_dict()
else:

View File

@@ -1,6 +1,6 @@
from typing import Any, List, Annotated, Optional
from typing import Any, List
from fastapi import APIRouter, Depends, Body
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.download import DownloadChain
@@ -9,29 +9,24 @@ from app.core.context import MediaInfo, Context, TorrentInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.schemas.types import SystemConfigKey
from app.db.userauth import get_current_active_user
router = APIRouter()
@router.get("/", summary="正在下载", response_model=List[schemas.DownloadingTorrent])
def current(
name: Optional[str] = None,
def read(
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询正在下载的任务
"""
return DownloadChain().downloading(name)
return DownloadChain().downloading()
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
def download(
media_in: schemas.MediaInfo,
torrent_in: schemas.TorrentInfo,
downloader: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
current_user: User = Depends(get_current_active_user)) -> Any:
"""
添加下载任务(含媒体信息)
@@ -44,16 +39,13 @@ def download(
# 种子信息
torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.dict())
# 手动下载始终使用选择的下载器
torrentinfo.site_downloader = downloader
# 上下文
context = Context(
meta_info=metainfo,
media_info=mediainfo,
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name,
save_path=save_path, source="Manual")
did = DownloadChain().download_single(context=context, username=current_user.name)
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
@@ -64,8 +56,6 @@ def download(
@router.post("/add", summary="添加下载(不含媒体信息)", response_model=schemas.Response)
def add(
torrent_in: schemas.TorrentInfo,
downloader: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None,
current_user: User = Depends(get_current_active_user)) -> Any:
"""
添加下载任务(不含媒体信息)
@@ -85,8 +75,7 @@ def add(
media_info=mediainfo,
torrent_info=torrentinfo
)
did = DownloadChain().download_single(context=context, username=current_user.name,
downloader=downloader, save_path=save_path, source="Manual")
did = DownloadChain().download_single(context=context, username=current_user.name)
if not did:
return schemas.Response(success=False, message="任务添加失败")
return schemas.Response(success=True, data={
@@ -96,41 +85,32 @@ def add(
@router.get("/start/{hashString}", summary="开始任务", response_model=schemas.Response)
def start(
hashString: str, name: Optional[str] = None,
hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
开如下载任务
"""
ret = DownloadChain().set_downloading(hashString, "start", name=name)
ret = DownloadChain().set_downloading(hashString, "start")
return schemas.Response(success=True if ret else False)
@router.get("/stop/{hashString}", summary="暂停任务", response_model=schemas.Response)
def stop(hashString: str, name: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def stop(
hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
暂停下载任务
"""
ret = DownloadChain().set_downloading(hashString, "stop", name=name)
ret = DownloadChain().set_downloading(hashString, "stop")
return schemas.Response(success=True if ret else False)
@router.get("/clients", summary="查询可用下载器", response_model=List[dict])
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可用下载器
"""
downloaders: List[dict] = SystemConfigOper().get(SystemConfigKey.Downloaders)
if downloaders:
return [{"name": d.get("name"), "type": d.get("type")} for d in downloaders if d.get("enabled")]
return []
@router.delete("/{hashString}", summary="删除下载任务", response_model=schemas.Response)
def delete(hashString: str, name: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def info(
hashString: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除下载任务
"""
ret = DownloadChain().remove_downloading(hashString, name=name)
ret = DownloadChain().remove_downloading(hashString)
return schemas.Response(success=True if ret else False)

View File

@@ -1,55 +1,54 @@
from typing import List, Any, Optional
from pathlib import Path
from typing import List, Any
import jieba
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.event import eventmanager
from app.core.security import verify_token
from app.db import get_async_db, get_db
from app.db import get_db
from app.db.models import User
from app.db.models.downloadhistory import DownloadHistory
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser_async, get_current_active_superuser
from app.schemas.types import EventType, MediaType
from app.db.userauth import get_current_active_superuser
from app.schemas.types import EventType
router = APIRouter()
@router.get("/download", summary="查询下载历史记录", response_model=List[schemas.DownloadHistory])
async def download_history(page: Optional[int] = 1,
count: Optional[int] = 30,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def download_history(page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询下载历史记录
"""
return await DownloadHistory.async_list_by_page(db, page, count)
return DownloadHistory.list_by_page(db, page, count)
@router.delete("/download", summary="删除下载历史记录", response_model=schemas.Response)
async def delete_download_history(history_in: schemas.DownloadHistory,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def delete_download_history(history_in: schemas.DownloadHistory,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除下载历史记录
"""
await DownloadHistory.async_delete(db, history_in.id)
DownloadHistory.delete(db, history_in.id)
return schemas.Response(success=True)
@router.get("/transfer", summary="查询整理记录", response_model=schemas.Response)
async def transfer_history(title: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
status: Optional[bool] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@router.get("/transfer", summary="查询转移历史记录", response_model=schemas.Response)
def transfer_history(title: str = None,
page: int = 1,
count: int = 30,
status: bool = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询整理记录
查询转移历史记录
"""
if title == "失败":
title = None
@@ -59,14 +58,12 @@ async def transfer_history(title: Optional[str] = None,
status = True
if title:
words = jieba.cut(title, HMM=False)
title = "%".join(words)
total = await TransferHistory.async_count_by_title(db, title=title, status=status)
result = await TransferHistory.async_list_by_title(db, title=title, page=page,
count=count, status=status)
total = TransferHistory.count_by_title(db, title=title, status=status)
result = TransferHistory.list_by_title(db, title=title, page=page,
count=count, status=status)
else:
result = await TransferHistory.async_list_by_page(db, page=page, count=count, status=status)
total = await TransferHistory.async_count(db, status=status)
result = TransferHistory.list_by_page(db, page=page, count=count, status=status)
total = TransferHistory.count(db, status=status)
return schemas.Response(success=True,
data={
@@ -75,29 +72,28 @@ async def transfer_history(title: Optional[str] = None,
})
@router.delete("/transfer", summary="删除整理记录", response_model=schemas.Response)
@router.delete("/transfer", summary="删除转移历史记录", response_model=schemas.Response)
def delete_transfer_history(history_in: schemas.TransferHistory,
deletesrc: Optional[bool] = False,
deletedest: Optional[bool] = False,
deletesrc: bool = False,
deletedest: bool = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除整理记录
删除转移历史记录
"""
history: TransferHistory = TransferHistory.get(db, history_in.id)
history = TransferHistory.get(db, history_in.id)
if not history:
return schemas.Response(success=False, message="记录不存在")
return schemas.Response(success=False, msg="记录不存在")
# 册除媒体库文件
if deletedest and history.dest_fileitem:
dest_fileitem = schemas.FileItem(**history.dest_fileitem)
StorageChain().delete_media_file(dest_fileitem)
# 删除源文件
if deletesrc and history.src_fileitem:
src_fileitem = schemas.FileItem(**history.src_fileitem)
state = StorageChain().delete_media_file(src_fileitem)
if deletedest and history.dest:
state, msg = TransferChain().delete_files(Path(history.dest))
if not state:
return schemas.Response(success=False, message=f"{src_fileitem.path} 删除失败")
return schemas.Response(success=False, msg=msg)
# 删除源文件
if deletesrc and history.src:
state, msg = TransferChain().delete_files(Path(history.src))
if not state:
return schemas.Response(success=False, msg=msg)
# 发送事件
eventmanager.send_event(
EventType.DownloadFileDeleted,
@@ -111,11 +107,11 @@ def delete_transfer_history(history_in: schemas.TransferHistory,
return schemas.Response(success=True)
@router.get("/empty/transfer", summary="清空整理记录", response_model=schemas.Response)
async def empty_transfer_history(db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)) -> Any:
@router.get("/empty/transfer", summary="清空转移历史记录", response_model=schemas.Response)
def delete_transfer_history(db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
"""
清空整理记录
清空转移历史记录
"""
await TransferHistory.async_truncate(db)
TransferHistory.truncate(db)
return schemas.Response(success=True)

273
app/api/endpoints/local.py Normal file
View File

@@ -0,0 +1,273 @@
import shutil
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import FileResponse, Response
from app import schemas
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.helper.progress import ProgressHelper
from app.log import logger
from app.schemas.types import ProgressKey
from app.utils.system import SystemUtils
router = APIRouter()
IMAGE_TYPES = [".jpg", ".png", ".gif", ".bmp", ".jpeg", ".webp"]
@router.post("/list", summary="所有目录和文件(本地)", response_model=List[schemas.FileItem])
def list_local(fileitem: schemas.FileItem,
sort: str = 'time',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件项
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
# 返回结果
ret_items = []
path = fileitem.path
if not fileitem.path or fileitem.path == "/":
if SystemUtils.is_windows():
partitions = SystemUtils.get_windows_drives() or ["C:/"]
for partition in partitions:
ret_items.append(schemas.FileItem(
type="dir",
path=partition + "/",
name=partition,
basename=partition
))
return ret_items
else:
path = "/"
else:
if SystemUtils.is_windows():
path = path.lstrip("/")
elif not path.startswith("/"):
path = "/" + path
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.warn(f"目录不存在:{path}")
return []
# 如果是文件
if path_obj.is_file():
ret_items.append(schemas.FileItem(
type="file",
path=str(path_obj).replace("\\", "/"),
name=path_obj.name,
basename=path_obj.stem,
extension=path_obj.suffix[1:],
size=path_obj.stat().st_size,
modify_time=path_obj.stat().st_mtime,
))
return ret_items
# 扁历所有目录
for item in SystemUtils.list_sub_directory(path_obj):
ret_items.append(schemas.FileItem(
type="dir",
path=str(item).replace("\\", "/") + "/",
name=item.name,
basename=item.stem,
modify_time=item.stat().st_mtime,
))
# 遍历所有文件,不含子目录
for item in SystemUtils.list_sub_files(path_obj,
settings.RMT_MEDIAEXT
+ settings.RMT_SUBEXT
+ IMAGE_TYPES
+ [".nfo"]):
ret_items.append(schemas.FileItem(
type="file",
path=str(item).replace("\\", "/"),
name=item.name,
basename=item.stem,
extension=item.suffix[1:],
size=item.stat().st_size,
modify_time=item.stat().st_mtime,
))
# 排序
if sort == 'time':
ret_items.sort(key=lambda x: x.modify_time, reverse=True)
else:
ret_items.sort(key=lambda x: x.name, reverse=False)
return ret_items
@router.get("/listdir", summary="所有目录(本地,不含文件)", response_model=List[schemas.FileItem])
def list_local_dir(path: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录
"""
# 返回结果
ret_items = []
if not path or path == "/":
if SystemUtils.is_windows():
partitions = SystemUtils.get_windows_drives() or ["C:/"]
for partition in partitions:
ret_items.append(schemas.FileItem(
type="dir",
path=partition + "/",
name=partition,
children=[]
))
return ret_items
else:
path = "/"
else:
if not SystemUtils.is_windows() and not path.startswith("/"):
path = "/" + path
# 遍历目录
path_obj = Path(path)
if not path_obj.exists():
logger.warn(f"目录不存在:{path}")
return []
# 扁历所有目录
for item in SystemUtils.list_sub_directory(path_obj):
ret_items.append(schemas.FileItem(
type="dir",
path=str(item).replace("\\", "/") + "/",
name=item.name,
children=[]
))
return ret_items
@router.post("/mkdir", summary="创建目录(本地)", response_model=schemas.Response)
def mkdir_local(fileitem: schemas.FileItem,
name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not fileitem.path:
return schemas.Response(success=False)
path_obj = Path(fileitem.path) / name
if path_obj.exists():
return schemas.Response(success=False)
path_obj.mkdir(parents=True, exist_ok=True)
return schemas.Response(success=True)
@router.post("/delete", summary="删除文件或目录(本地)", response_model=schemas.Response)
def delete_local(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not fileitem.path:
return schemas.Response(success=False)
path_obj = Path(fileitem.path)
if not path_obj.exists():
return schemas.Response(success=True)
if path_obj.is_file():
path_obj.unlink()
else:
shutil.rmtree(path_obj, ignore_errors=True)
return schemas.Response(success=True)
@router.get("/download", summary="下载文件(本地)")
def download_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
下载文件或目录
"""
if not path:
return schemas.Response(success=False)
path_obj = Path(path)
if not path_obj.exists():
raise HTTPException(status_code=404, detail="文件不存在")
if path_obj.is_file():
# 做为文件流式下载
return FileResponse(path_obj)
else:
# 做为压缩包下载
shutil.make_archive(base_name=path_obj.stem, format="zip", root_dir=path_obj)
reponse = Response(content=path_obj.read_bytes(), media_type="application/zip")
# 删除压缩包
Path(f"{path_obj.stem}.zip").unlink()
return reponse
@router.post("/rename", summary="重命名文件或目录(本地)", response_model=schemas.Response)
def rename_local(fileitem: schemas.FileItem,
new_name: str,
recursive: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not fileitem.path or not new_name:
return schemas.Response(success=False)
path_obj = Path(fileitem.path)
if not path_obj.exists():
return schemas.Response(success=False)
path_obj.rename(path_obj.parent / new_name)
if recursive:
transferchain = TransferChain()
media_exts = settings.RMT_MEDIAEXT + settings.RMT_SUBEXT + settings.RMT_AUDIO_TRACK_EXT
# 递归修改目录内文件(智能识别命名)
sub_files: List[schemas.FileItem] = list_local(fileitem=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(sub_file.path)
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_local(fileitem, 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)
@router.get("/image", summary="读取图片(本地)")
def image_local(path: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
读取图片
"""
if not path:
return None
path_obj = Path(path)
if not path_obj.exists():
return None
if not path_obj.is_file():
return None
# 判断是否图片文件
if path_obj.suffix.lower() not in IMAGE_TYPES:
raise HTTPException(status_code=500, detail="图片读取出错")
return Response(content=path_obj.read_bytes(), media_type="image/jpeg")

View File

@@ -1,50 +1,78 @@
from datetime import timedelta
from typing import Any, List, Annotated
from typing import Any, List
from fastapi import APIRouter, Depends, Form, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app import schemas
from app.chain.tmdb import TmdbChain
from app.chain.user import UserChain
from app.core import security
from app.core.config import settings
from app.helper.sites import SitesHelper # noqa
from app.helper.wallpaper import WallpaperHelper
from app.core.security import get_password_hash
from app.db import get_db
from app.db.models.user import User
from app.helper.sites import SitesHelper
from app.log import logger
from app.utils.web import WebUtils
router = APIRouter()
@router.post("/access-token", summary="获取token", response_model=schemas.Token)
def login_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
otp_password: Annotated[str | None, Form()] = None
async def login_access_token(
db: Session = Depends(get_db),
form_data: OAuth2PasswordRequestForm = Depends(),
otp_password: str = Form(None)
) -> Any:
"""
获取认证Token
"""
success, user_or_message = UserChain().user_authenticate(username=form_data.username,
password=form_data.password,
mfa_code=otp_password)
# 检查数据库
success, user = User.authenticate(
db=db,
name=form_data.username,
password=form_data.password,
otp_password=otp_password
)
if not success:
raise HTTPException(status_code=401, detail=user_or_message)
# 认证不成功
if not user:
# 未找到用户,请求协助认证
logger.warn(f"登录用户 {form_data.username} 本地不存在,尝试辅助认证 ...")
token = UserChain().user_authenticate(form_data.username, form_data.password)
if not token:
logger.warn(f"用户 {form_data.username} 登录失败!")
raise HTTPException(status_code=401, detail="用户名、密码、二次校验码不正确")
else:
logger.info(f"用户 {form_data.username} 辅助认证成功,用户信息: {token},以普通用户登录...")
# 加入用户信息表
logger.info(f"创建用户: {form_data.username}")
user = User(name=form_data.username, is_active=True,
is_superuser=False, hashed_password=get_password_hash(token))
user.create(db)
else:
# 用户存在,但认证失败
logger.warn(f"用户 {user.name} 登录失败!")
raise HTTPException(status_code=401, detail="用户名、密码或二次校验码不正确")
elif user and not user.is_active:
raise HTTPException(status_code=403, detail="用户未启用")
logger.info(f"用户 {user.name} 登录成功!")
level = SitesHelper().auth_level
return schemas.Token(
access_token=security.create_access_token(
userid=user_or_message.id,
username=user_or_message.name,
super_user=user_or_message.is_superuser,
userid=user.id,
username=user.name,
super_user=user.is_superuser,
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
level=level
),
token_type="bearer",
super_user=user_or_message.is_superuser,
user_id=user_or_message.id,
user_name=user_or_message.name,
avatar=user_or_message.avatar,
level=level,
permissions=user_or_message.permissions or {},
super_user=user.is_superuser,
user_name=user.name,
avatar=user.avatar,
level=level
)
@@ -53,7 +81,10 @@ def wallpaper() -> Any:
"""
获取登录页面电影海报
"""
url = WallpaperHelper().get_wallpaper()
if settings.WALLPAPER == "tmdb":
url = TmdbChain().get_random_wallpager()
else:
url = WebUtils.get_bing_wallpaper()
if url:
return schemas.Response(
success=True,
@@ -67,4 +98,7 @@ def wallpapers() -> Any:
"""
获取登录页面电影海报
"""
return WallpaperHelper().get_wallpapers()
if settings.WALLPAPER == "tmdb":
return TmdbChain().get_trending_wallpapers()
else:
return WebUtils.get_bing_wallpapers()

View File

@@ -1,83 +1,78 @@
from pathlib import Path
from typing import List, Any, Union, Annotated, Optional
from typing import List, Any, Union
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.media import MediaChain
from app.chain.tmdb import TmdbChain
from app.core.config import settings
from app.core.context import Context
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo, MetaInfoPath
from app.core.security import verify_token, verify_apitoken
from app.schemas import MediaType, MediaRecognizeConvertEventData
from app.schemas.types import ChainEventType
from app.schemas import MediaType
router = APIRouter()
@router.get("/recognize", summary="识别媒体信息(种子)", response_model=schemas.Context)
async def recognize(title: str,
subtitle: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def recognize(title: str,
subtitle: str = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据标题、副标题识别媒体信息
"""
# 识别媒体信息
metainfo = MetaInfo(title, subtitle)
mediainfo = await MediaChain().async_recognize_by_meta(metainfo)
mediainfo = MediaChain().recognize_by_meta(metainfo)
if mediainfo:
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
return schemas.Context()
@router.get("/recognize2", summary="识别种子媒体信息API_TOKEN", response_model=schemas.Context)
async def recognize2(_: Annotated[str, Depends(verify_apitoken)],
title: str,
subtitle: Optional[str] = None
) -> Any:
def recognize2(title: str,
subtitle: str = None,
_: str = Depends(verify_apitoken)) -> Any:
"""
根据标题、副标题识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return await recognize(title, subtitle)
return recognize(title, subtitle)
@router.get("/recognize_file", summary="识别媒体信息(文件)", response_model=schemas.Context)
async def recognize_file(path: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def recognize_file(path: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据文件路径识别媒体信息
"""
# 识别媒体信息
context = await MediaChain().async_recognize_by_path(path)
context = MediaChain().recognize_by_path(path)
if context:
return context.to_dict()
return schemas.Context()
@router.get("/recognize_file2", summary="识别文件媒体信息API_TOKEN", response_model=schemas.Context)
async def recognize_file2(path: str,
_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def recognize_file2(path: str,
_: str = Depends(verify_apitoken)) -> Any:
"""
根据文件路径识别媒体信息 API_TOKEN认证?token=xxx
"""
# 识别媒体信息
return await recognize_file(path)
return recognize_file(path)
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
async def search(title: str,
type: Optional[str] = "media",
page: int = 1,
count: int = 8,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search(title: str,
type: str = "media",
page: int = 1,
count: int = 8,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
模糊搜索媒体/人物信息列表 media媒体信息person人物信息
"""
def __get_source(obj: Union[schemas.MediaInfo, schemas.MediaPerson, dict]):
def __get_source(obj: Union[dict, schemas.MediaPerson]):
"""
获取对象属性
"""
@@ -86,15 +81,12 @@ async def search(title: str,
return obj.source
result = []
media_chain = MediaChain()
if type == "media":
_, medias = await media_chain.async_search(title=title)
_, medias = MediaChain().search(title=title)
if medias:
result = [media.to_dict() for media in medias]
elif type == "collection":
result = await media_chain.async_search_collections(name=title)
else:
result = await media_chain.async_search_persons(name=title)
result = MediaChain().search_persons(name=title)
if result:
# 按设置的顺序对结果进行排序
setting_order = settings.SEARCH_SOURCE.split(',') or []
@@ -102,13 +94,12 @@ async def search(title: str,
for index, source in enumerate(setting_order):
sort_order[source] = index
result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4))
return result[(page - 1) * count:page * count]
return []
return result[(page - 1) * count:page * count]
@router.post("/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response)
def scrape(fileitem: schemas.FileItem,
storage: Optional[str] = "local",
storage: str = "local",
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
刮削媒体信息
@@ -120,127 +111,46 @@ def scrape(fileitem: schemas.FileItem,
scrape_path = Path(fileitem.path)
meta = MetaInfoPath(scrape_path)
mediainfo = chain.recognize_by_meta(meta)
if not mediainfo:
if not media_info:
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
if storage == "local":
if not scrape_path.exists():
return schemas.Response(success=False, message="刮削路径不存在")
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
chain.scrape_metadata(fileitem=fileitem, meta=meta, mediainfo=mediainfo, overwrite=True)
else:
if not fileitem.fileid:
return schemas.Response(success=False, message="刮削文件ID无效")
# 手动刮削
chain.manual_scrape(storage=storage, fileitem=fileitem, meta=meta, mediainfo=mediainfo)
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
@router.get("/category", summary="查询自动分类配置", response_model=dict)
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询自动分类配置
"""
return MediaChain().media_category() or {}
@router.get("/group/seasons/{episode_group}", summary="查询剧集组季信息", response_model=List[schemas.MediaSeason])
async def group_seasons(episode_group: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询剧集组季信息themoviedb
"""
return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group)
@router.get("/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[dict])
async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询媒体剧集组列表themoviedb
"""
mediainfo = await MediaChain().async_recognize_media(tmdbid=tmdbid, mtype=MediaType.TV)
if not mediainfo:
return []
return mediainfo.episode_groups
@router.get("/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason])
async def seasons(mediaid: Optional[str] = None,
title: Optional[str] = None,
year: str = None,
season: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询媒体季信息
"""
if mediaid:
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid[5:])
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
if seasons_info:
if season:
return [sea for sea in seasons_info if sea.season_number == season]
return seasons_info
if title:
meta = MetaInfo(title)
if year:
meta.year = year
mediainfo = await MediaChain().async_recognize_media(meta, mtype=MediaType.TV)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=mediainfo.tmdb_id)
if seasons_info:
if season:
return [sea for sea in seasons_info if sea.season_number == season]
return seasons_info
else:
sea = season or 1
return [schemas.MediaSeason(
season_number=sea,
poster_path=mediainfo.poster_path,
name=f"{sea}",
air_date=mediainfo.release_date,
overview=mediainfo.overview,
vote_average=mediainfo.vote_average,
episode_count=mediainfo.number_of_episodes
)]
return []
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
async def detail(mediaid: str, type_name: str, title: Optional[str] = None, year: str = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def media_info(mediaid: str, type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据媒体ID查询themoviedb或豆瓣媒体信息type_name: 电影/电视剧
"""
mtype = MediaType(type_name)
mediainfo = None
mediachain = MediaChain()
tmdbid, doubanid, bangumiid = None, None, None
if mediaid.startswith("tmdb:"):
mediainfo = await mediachain.async_recognize_media(tmdbid=int(mediaid[5:]), mtype=mtype)
tmdbid = int(mediaid[5:])
elif mediaid.startswith("douban:"):
mediainfo = await mediachain.async_recognize_media(doubanid=mediaid[7:], mtype=mtype)
doubanid = mediaid[7:]
elif mediaid.startswith("bangumi:"):
mediainfo = await mediachain.async_recognize_media(bangumiid=int(mediaid[8:]), mtype=mtype)
else:
# 广播事件解析媒体信息
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid,
convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)
# 使用事件返回的上下文数据
if event and event.event_data and event.event_data.media_dict:
event_data: MediaRecognizeConvertEventData = event.event_data
new_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
mediainfo = await mediachain.async_recognize_media(tmdbid=new_id, mtype=mtype)
elif event_data.convert_type == "douban":
mediainfo = await mediachain.async_recognize_media(doubanid=new_id, mtype=mtype)
elif title:
# 使用名称识别兜底
meta = MetaInfo(title)
if year:
meta.year = year
if mtype:
meta.type = mtype
mediainfo = await mediachain.async_recognize_media(meta=meta)
bangumiid = int(mediaid[8:])
if not tmdbid and not doubanid and not bangumiid:
return schemas.MediaInfo()
# 识别
mediainfo = MediaChain().recognize_media(tmdbid=tmdbid, doubanid=doubanid, bangumiid=bangumiid, mtype=mtype)
if mediainfo:
await mediachain.async_obtain_images(mediainfo)
MediaChain().obtain_images(mediainfo)
return mediainfo.to_dict()
return schemas.MediaInfo()

View File

@@ -1,55 +1,53 @@
from typing import Any, List, Dict, Optional
from typing import Any, List, Dict
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.chain.download import DownloadChain
from app.chain.mediaserver import MediaServerChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.db import get_async_db
from app.db import get_db
from app.db.mediaserver_oper import MediaServerOper
from app.db.models import MediaServerItem
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.mediaserver import MediaServerHelper
from app.schemas import MediaType, NotExistMediaInfo
from app.schemas.types import SystemConfigKey
router = APIRouter()
@router.get("/play/{itemid:path}", summary="在线播放")
def play_item(itemid: str, _: schemas.TokenPayload = Depends(verify_token)) -> schemas.Response:
@router.get("/play/{itemid}", summary="在线播放")
def play_item(itemid: str) -> schemas.Response:
"""
获取媒体服务器播放页面地址
"""
if not itemid:
return schemas.Response(success=False, message="参数错误")
configs = MediaServerHelper().get_configs()
if not configs:
return schemas.Response(success=False, message="未配置媒体服务器")
media_chain = MediaServerChain()
for name in configs.keys():
item = media_chain.iteminfo(server=name, item_id=itemid)
if item:
play_url = media_chain.get_play_url(server=name, item_id=itemid)
if play_url:
return schemas.Response(success=True, data={
"url": play_url
})
return schemas.Response(success=False, message="未找到播放地址")
return schemas.Response(success=False, msg="参数错误")
if not settings.MEDIASERVER:
return schemas.Response(success=False, msg="未配置媒体服务器")
# 查找一个不为空的值
mediaserver = next((server for server in settings.MEDIASERVER.split(",") if server), None)
if not mediaserver:
return schemas.Response(success=False, msg="未配置媒体服务器")
play_url = MediaServerChain().get_play_url(server=mediaserver, item_id=itemid)
# 重定向到play_url
if not play_url:
return schemas.Response(success=False, msg="未找到播放地址")
return schemas.Response(success=True, data={
"url": play_url
})
@router.get("/exists", summary="查询本地是否存在(数据库)", response_model=schemas.Response)
async def exists_local(title: Optional[str] = None,
year: Optional[str] = None,
mtype: Optional[str] = None,
tmdbid: Optional[int] = None,
season: Optional[int] = None,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def exists_local(title: str = None,
year: int = None,
mtype: str = None,
tmdbid: int = None,
season: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
判断本地是否存在
"""
@@ -59,7 +57,7 @@ async def exists_local(title: Optional[str] = None,
# 返回对象
ret_info = {}
# 本地数据库是否存在
exist: MediaServerItem = await MediaServerOper(db).async_exists(
exist: MediaServerItem = MediaServerOper(db).exists(
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
)
if exist:
@@ -121,38 +119,26 @@ def not_exists(media_in: schemas.MediaInfo,
@router.get("/latest", summary="最新入库条目", response_model=List[schemas.MediaServerPlayItem])
def latest(server: str, count: Optional[int] = 20,
def latest(count: int = 18,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器最新入库条目
"""
return MediaServerChain().latest(server=server, count=count, username=userinfo.username) or []
return MediaServerChain().latest(count=count, username=userinfo.username) or []
@router.get("/playing", summary="正在播放条目", response_model=List[schemas.MediaServerPlayItem])
def playing(server: str, count: Optional[int] = 12,
def playing(count: int = 12,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器正在播放条目
"""
return MediaServerChain().playing(server=server, count=count, username=userinfo.username) or []
return MediaServerChain().playing(count=count, username=userinfo.username) or []
@router.get("/library", summary="媒体库列表", response_model=List[schemas.MediaServerLibrary])
def library(server: str, hidden: Optional[bool] = False,
userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
def library(userinfo: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取媒体服务器媒体库列表
"""
return MediaServerChain().librarys(server=server, username=userinfo.username, hidden=hidden) or []
@router.get("/clients", summary="查询可用媒体服务器", response_model=List[dict])
async def clients(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询可用媒体服务器
"""
mediaservers: List[dict] = SystemConfigOper().get(SystemConfigKey.MediaServers)
if mediaservers:
return [{"name": d.get("name"), "type": d.get("type")} for d in mediaservers if d.get("enabled")]
return []
return MediaServerChain().librarys(username=userinfo.username) or []

View File

@@ -1,23 +1,25 @@
import json
from typing import Union, Any, List, Optional
from typing import Union, Any, List
from fastapi import APIRouter, BackgroundTasks, Depends, Request
from fastapi import APIRouter, BackgroundTasks, Depends
from fastapi import Request
from pywebpush import WebPushException, webpush
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from starlette.responses import PlainTextResponse
from app import schemas
from app.chain.message import MessageChain
from app.core.config import settings, global_vars
from app.core.security import verify_token, verify_apitoken
from app.db import get_async_db
from app.core.security import verify_token
from app.db import get_db
from app.db.models import User
from app.db.models.message import Message
from app.db.user_oper import get_current_active_superuser
from app.helper.service import ServiceConfigHelper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.userauth import get_current_active_superuser
from app.log import logger
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
from app.schemas.types import MessageChannel
from app.schemas import NotificationSwitch
from app.schemas.types import SystemConfigKey, NotificationType, MessageChannel
router = APIRouter()
@@ -30,10 +32,9 @@ def start_message_chain(body: Any, form: Any, args: Any):
@router.post("/", summary="接收用户消息", response_model=schemas.Response)
async def user_message(background_tasks: BackgroundTasks, request: Request,
_: schemas.TokenPayload = Depends(verify_apitoken)):
async def user_message(background_tasks: BackgroundTasks, request: Request):
"""
用户消息响应配置请求中需要添加参数token=API_TOKEN&source=消息配置名
用户消息响应
"""
body = await request.body()
form = await request.form()
@@ -49,7 +50,6 @@ def web_message(text: str, current_user: User = Depends(get_current_active_super
"""
MessageChain().handle_message(
channel=MessageChannel.Web,
source=current_user.name,
userid=current_user.name,
username=current_user.name,
text=text
@@ -58,15 +58,15 @@ def web_message(text: str, current_user: User = Depends(get_current_active_super
@router.get("/web", summary="获取WEB消息", response_model=List[dict])
async def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
db: AsyncSession = Depends(get_async_db),
page: Optional[int] = 1,
count: Optional[int] = 20):
def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
db: Session = Depends(get_db),
page: int = 1,
count: int = 20):
"""
获取WEB消息列表
"""
ret_messages = []
messages = await Message.async_list_by_page(db, page=page, count=count)
messages = Message.list_by_page(db, page=page, count=count)
for message in messages:
try:
ret_messages.append(message.to_dict())
@@ -76,59 +76,91 @@ async def get_web_message(_: schemas.TokenPayload = Depends(verify_token),
return ret_messages
def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str,
source: Optional[str] = None) -> Any:
def wechat_verify(echostr: str, msg_signature: str,
timestamp: Union[str, int], nonce: str) -> Any:
"""
微信验证响应
"""
# 获取服务配置
client_configs = ServiceConfigHelper.get_notification_configs()
if not client_configs:
return "未找到对应的消息配置"
client_config = next((config for config in client_configs if
config.type == "wechat" and config.enabled and (not source or config.name == source)), None)
if not client_config:
return "未找到对应的消息配置"
try:
wxcpt = WXBizMsgCrypt(sToken=client_config.config.get('WECHAT_TOKEN'),
sEncodingAESKey=client_config.config.get('WECHAT_ENCODING_AESKEY'),
sReceiveId=client_config.config.get('WECHAT_CORPID'))
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
sTimeStamp=timestamp,
sNonce=nonce,
sEchoStr=echostr)
if ret == 0:
# 验证URL成功将sEchoStr返回给企业号
return PlainTextResponse(sEchoStr)
return "微信验证失败"
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
sReceiveId=settings.WECHAT_CORPID)
except Exception as err:
logger.error(f"微信请求验证失败: {str(err)}")
return str(err)
ret, sEchoStr = wxcpt.VerifyURL(sMsgSignature=msg_signature,
sTimeStamp=timestamp,
sNonce=nonce,
sEchoStr=echostr)
if ret != 0:
logger.error("微信请求验证失败 VerifyURL ret: %s" % str(ret))
# 验证URL成功将sEchoStr返回给企业号
return PlainTextResponse(sEchoStr)
def vocechat_verify() -> Any:
def vocechat_verify(token: str) -> Any:
"""
VoceChat验证响应
"""
return {"status": "OK"}
if token == settings.API_TOKEN:
return {"status": "OK"}
return {"status": "ERROR"}
@router.get("/", summary="回调请求验证")
def incoming_verify(token: Optional[str] = None, echostr: Optional[str] = None, msg_signature: Optional[str] = None,
timestamp: Union[str, int] = None, nonce: Optional[str] = None, source: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_apitoken)) -> Any:
def incoming_verify(token: str = None, echostr: str = None, msg_signature: str = None,
timestamp: Union[str, int] = None, nonce: str = None) -> Any:
"""
微信/VoceChat等验证响应
"""
logger.info(f"收到验证请求: token={token}, echostr={echostr}, "
f"msg_signature={msg_signature}, timestamp={timestamp}, nonce={nonce}")
if echostr and msg_signature and timestamp and nonce:
return wechat_verify(echostr, msg_signature, timestamp, nonce, source)
return vocechat_verify()
return wechat_verify(echostr, msg_signature, timestamp, nonce)
return vocechat_verify(token)
@router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch])
def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询通知消息渠道开关
"""
return_list = []
# 读取数据库
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
if not switchs:
for noti in NotificationType:
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True,
synologychat=True, vocechat=True))
else:
for switch in switchs:
return_list.append(NotificationSwitch(**switch))
for noti in NotificationType:
if not any([x.mtype == noti.value for x in return_list]):
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
telegram=True, slack=True,
synologychat=True, vocechat=True))
return return_list
@router.post("/switchs", summary="设置通知消息渠道开关", response_model=schemas.Response)
def set_switchs(switchs: List[NotificationSwitch],
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
设置通知消息渠道开关
"""
switch_list = []
for switch in switchs:
switch_list.append(switch.dict())
# 存入数据库
SystemConfigOper().set(SystemConfigKey.NotificationChannels, switch_list)
return schemas.Response(success=True)
@router.post("/webpush/subscribe", summary="客户端webpush通知订阅", response_model=schemas.Response)
async def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
def subscribe(subscription: schemas.Subscription, _: schemas.TokenPayload = Depends(verify_token)):
"""
客户端webpush通知订阅
"""

View File

@@ -1,409 +0,0 @@
from typing import Any, List
from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse
from app import schemas
from app.core.security import verify_apitoken
from app.monitoring import monitor, get_metrics_response
from app.schemas.monitoring import (
PerformanceSnapshot,
EndpointStats,
ErrorRequest,
MonitoringOverview
)
router = APIRouter()
@router.get("/overview", summary="获取监控概览", response_model=schemas.MonitoringOverview)
def get_overview(_: str = Depends(verify_apitoken)) -> Any:
"""
获取完整的监控概览信息
"""
# 获取性能快照
performance = monitor.get_performance_snapshot()
# 获取最活跃端点
top_endpoints = monitor.get_top_endpoints(limit=10)
# 获取最近错误
recent_errors = monitor.get_recent_errors(limit=20)
# 检查告警
alerts = monitor.check_alerts()
return MonitoringOverview(
performance=PerformanceSnapshot(
timestamp=performance.timestamp,
cpu_usage=performance.cpu_usage,
memory_usage=performance.memory_usage,
active_requests=performance.active_requests,
request_rate=performance.request_rate,
avg_response_time=performance.avg_response_time,
error_rate=performance.error_rate,
slow_requests=performance.slow_requests
),
top_endpoints=[EndpointStats(**endpoint) for endpoint in top_endpoints],
recent_errors=[ErrorRequest(**error) for error in recent_errors],
alerts=alerts
)
@router.get("/performance", summary="获取性能快照", response_model=schemas.PerformanceSnapshot)
def get_performance(_: str = Depends(verify_apitoken)) -> Any:
"""
获取当前性能快照
"""
snapshot = monitor.get_performance_snapshot()
return PerformanceSnapshot(
timestamp=snapshot.timestamp,
cpu_usage=snapshot.cpu_usage,
memory_usage=snapshot.memory_usage,
active_requests=snapshot.active_requests,
request_rate=snapshot.request_rate,
avg_response_time=snapshot.avg_response_time,
error_rate=snapshot.error_rate,
slow_requests=snapshot.slow_requests
)
@router.get("/endpoints", summary="获取端点统计", response_model=List[schemas.EndpointStats])
def get_endpoints(
limit: int = Query(10, ge=1, le=50, description="返回的端点数量"),
_: str = Depends(verify_apitoken)
) -> Any:
"""
获取最活跃的API端点统计
"""
endpoints = monitor.get_top_endpoints(limit=limit)
return [EndpointStats(**endpoint) for endpoint in endpoints]
@router.get("/errors", summary="获取错误请求", response_model=List[schemas.ErrorRequest])
def get_errors(
limit: int = Query(20, ge=1, le=100, description="返回的错误数量"),
_: str = Depends(verify_apitoken)
) -> Any:
"""
获取最近的错误请求记录
"""
errors = monitor.get_recent_errors(limit=limit)
return [ErrorRequest(**error) for error in errors]
@router.get("/alerts", summary="获取告警信息", response_model=List[str])
def get_alerts(_: str = Depends(verify_apitoken)) -> Any:
"""
获取当前告警信息
"""
return monitor.check_alerts()
@router.get("/metrics", summary="Prometheus指标")
def get_prometheus_metrics(_: str = Depends(verify_apitoken)) -> Any:
"""
获取Prometheus格式的监控指标
"""
return get_metrics_response()
@router.get("/dashboard", summary="监控仪表板", response_class=HTMLResponse)
def get_dashboard(_: str = Depends(verify_apitoken)) -> Any:
"""
获取实时监控仪表板HTML页面
"""
return HTMLResponse(content="""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MoviePilot 性能监控仪表板</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
margin-bottom: 30px;
color: #333;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.metric-card {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.metric-value {
font-size: 2em;
font-weight: bold;
color: #2196F3;
}
.metric-label {
color: #666;
margin-top: 5px;
}
.chart-container {
background: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-bottom: 20px;
}
.alerts {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
}
.alert-item {
color: #856404;
margin: 5px 0;
}
.refresh-btn {
background: #2196F3;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
margin-bottom: 20px;
}
.refresh-btn:hover {
background: #1976D2;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎬 MoviePilot 性能监控仪表板</h1>
<button class="refresh-btn" onclick="refreshData()">刷新数据</button>
</div>
<div id="alerts" class="alerts" style="display: none;">
<h3>⚠️ 告警信息</h3>
<div id="alerts-list"></div>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-value" id="cpu-usage">--</div>
<div class="metric-label">CPU使用率 (%)</div>
</div>
<div class="metric-card">
<div class="metric-value" id="memory-usage">--</div>
<div class="metric-label">内存使用率 (%)</div>
</div>
<div class="metric-card">
<div class="metric-value" id="active-requests">--</div>
<div class="metric-label">活跃请求数</div>
</div>
<div class="metric-card">
<div class="metric-value" id="request-rate">--</div>
<div class="metric-label">请求率 (req/min)</div>
</div>
<div class="metric-card">
<div class="metric-value" id="avg-response-time">--</div>
<div class="metric-label">平均响应时间 (s)</div>
</div>
<div class="metric-card">
<div class="metric-value" id="error-rate">--</div>
<div class="metric-label">错误率 (%)</div>
</div>
</div>
<div class="chart-container">
<h3>📊 性能趋势</h3>
<canvas id="performanceChart" width="400" height="200"></canvas>
</div>
<div class="chart-container">
<h3>🔥 最活跃端点</h3>
<canvas id="endpointsChart" width="400" height="200"></canvas>
</div>
</div>
<script>
let performanceChart, endpointsChart;
let performanceData = {
labels: [],
cpu: [],
memory: [],
requests: []
};
// 初始化图表
function initCharts() {
const ctx1 = document.getElementById('performanceChart').getContext('2d');
performanceChart = new Chart(ctx1, {
type: 'line',
data: {
labels: performanceData.labels,
datasets: [{
label: 'CPU使用率 (%)',
data: performanceData.cpu,
borderColor: '#2196F3',
backgroundColor: 'rgba(33, 150, 243, 0.1)',
tension: 0.4
}, {
label: '内存使用率 (%)',
data: performanceData.memory,
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
tension: 0.4
}, {
label: '活跃请求数',
data: performanceData.requests,
borderColor: '#FF9800',
backgroundColor: 'rgba(255, 152, 0, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
const ctx2 = document.getElementById('endpointsChart').getContext('2d');
endpointsChart = new Chart(ctx2, {
type: 'bar',
data: {
labels: [],
datasets: [{
label: '请求数',
data: [],
backgroundColor: 'rgba(33, 150, 243, 0.8)'
}]
},
options: {
responsive: true,
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// 更新性能数据
function updatePerformanceData(data) {
const now = new Date().toLocaleTimeString();
performanceData.labels.push(now);
performanceData.cpu.push(data.performance.cpu_usage);
performanceData.memory.push(data.performance.memory_usage);
performanceData.requests.push(data.performance.active_requests);
// 保持最近20个数据点
if (performanceData.labels.length > 20) {
performanceData.labels.shift();
performanceData.cpu.shift();
performanceData.memory.shift();
performanceData.requests.shift();
}
// 更新图表
performanceChart.data.labels = performanceData.labels;
performanceChart.data.datasets[0].data = performanceData.cpu;
performanceChart.data.datasets[1].data = performanceData.memory;
performanceChart.data.datasets[2].data = performanceData.requests;
performanceChart.update();
// 更新端点图表
const endpointLabels = data.top_endpoints.map(e => e.endpoint.substring(0, 20));
const endpointData = data.top_endpoints.map(e => e.count);
endpointsChart.data.labels = endpointLabels;
endpointsChart.data.datasets[0].data = endpointData;
endpointsChart.update();
}
// 更新指标显示
function updateMetrics(data) {
document.getElementById('cpu-usage').textContent = data.performance.cpu_usage.toFixed(1);
document.getElementById('memory-usage').textContent = data.performance.memory_usage.toFixed(1);
document.getElementById('active-requests').textContent = data.performance.active_requests;
document.getElementById('request-rate').textContent = data.performance.request_rate.toFixed(0);
document.getElementById('avg-response-time').textContent = data.performance.avg_response_time.toFixed(3);
document.getElementById('error-rate').textContent = (data.performance.error_rate * 100).toFixed(2);
}
// 更新告警
function updateAlerts(alerts) {
const alertsDiv = document.getElementById('alerts');
const alertsList = document.getElementById('alerts-list');
if (alerts.length > 0) {
alertsDiv.style.display = 'block';
alertsList.innerHTML = alerts.map(alert =>
`<div class="alert-item">⚠️ ${alert}</div>`
).join('');
} else {
alertsDiv.style.display = 'none';
}
}
// 获取URL中的token参数
function getTokenFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('token');
}
// 刷新数据
async function refreshData() {
try {
const token = getTokenFromUrl();
if (!token) {
console.error('未找到token参数');
return;
}
const response = await fetch(`/api/v1/monitoring/overview?token=${token}`);
if (response.ok) {
const data = await response.json();
updateMetrics(data);
updatePerformanceData(data);
updateAlerts(data.alerts);
}
} catch (error) {
console.error('获取监控数据失败:', error);
}
}
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
initCharts();
refreshData();
// 每5秒自动刷新
setInterval(refreshData, 5000);
});
</script>
</body>
</html>
""")

View File

@@ -1,163 +1,57 @@
import mimetypes
import shutil
from typing import Annotated, Any, List, Optional
from typing import Any, List, Annotated
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, Header, HTTPException
from fastapi.concurrency import run_in_threadpool
from starlette import status
from starlette.responses import StreamingResponse
from fastapi import APIRouter, Depends, Header
from app import schemas
from app.command import Command
from app.core.config import settings
from app.core.plugin import PluginManager
from app.core.security import verify_apikey, verify_token, verify_apitoken
from app.db.models import User
from app.core.security import verify_token
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
from app.factory import app
from app.helper.plugin import PluginHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas.plugin import PluginMemoryInfo
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()
def register_plugin_api(plugin_id: Optional[str] = None):
def register_plugin_api(plugin_id: str = None):
"""
动态注册插件 API
:param plugin_id: 插件 ID如果为 None则注册所有插件
注册插件API(先删除后新增)
"""
_update_plugin_api_routes(plugin_id, action="add")
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
router.add_api_route(**api)
def remove_plugin_api(plugin_id: str):
"""
动态移除单个插件的 API
:param plugin_id: 插件 ID
移除插件API
"""
_update_plugin_api_routes(plugin_id, action="remove")
def _update_plugin_api_routes(plugin_id: Optional[str], action: str):
"""
插件 API 路由注册和移除
:param plugin_id: 插件 ID如果 action 为 "add" 且 plugin_id 为 None则处理所有插件
如果 action 为 "remove"plugin_id 必须是有效的插件 ID
:param action: "add""remove",决定是添加还是移除路由
"""
if action not in {"add", "remove"}:
raise ValueError("Action must be 'add' or 'remove'")
is_modified = False
existing_paths = {route.path: route for route in app.routes}
plugin_ids = [plugin_id] if plugin_id else PluginManager().get_running_plugin_ids()
for plugin_id in plugin_ids:
routes_removed = _remove_routes(plugin_id)
if routes_removed:
is_modified = True
if action != "add":
continue
# 获取插件的 API 路由信息
plugin_apis = PluginManager().get_plugin_apis(plugin_id)
for api in plugin_apis:
api_path = f"{PLUGIN_PREFIX}{api.get('path', '')}"
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:
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}")
except Exception as e:
logger.error(f"Error adding plugin route {api_path}: {str(e)}")
if is_modified:
_clean_protected_routes(existing_paths)
app.openapi_schema = None
app.setup()
def _remove_routes(plugin_id: str) -> bool:
"""
移除与单个插件相关的路由
:param plugin_id: 插件 ID
:return: 是否有路由被移除
"""
if not plugin_id:
return False
prefix = f"{PLUGIN_PREFIX}/{plugin_id}/"
routes_to_remove = [route for route in app.routes if route.path.startswith(prefix)]
removed = False
for route in routes_to_remove:
try:
app.routes.remove(route)
removed = True
logger.debug(f"Removed plugin route: {route.path}")
except Exception as e:
logger.error(f"Error removing plugin route {route.path}: {str(e)}")
return removed
def _clean_protected_routes(existing_paths: dict):
"""
清理受保护的路由,防止在插件操作中被删除或重复添加
:param existing_paths: 当前应用的路由路径映射
"""
for protected_route in PROTECTED_ROUTES:
try:
existing_route = existing_paths.get(protected_route)
if existing_route:
app.routes.remove(existing_route)
except Exception as e:
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)
for api in PluginManager().get_plugin_apis(plugin_id):
for r in router.routes:
if r.path == api.get("path"):
router.routes.remove(r)
break
@router.get("/", summary="所有插件", response_model=List[schemas.Plugin])
async def all_plugins(_: User = Depends(get_current_active_superuser_async),
state: Optional[str] = "all", force: bool = False) -> List[schemas.Plugin]:
def all_plugins(_: schemas.TokenPayload = Depends(verify_token), state: str = "all") -> List[schemas.Plugin]:
"""
查询所有插件清单包括本地插件和在线插件插件状态installed, market, all
"""
# 本地插件
plugin_manager = PluginManager()
local_plugins = plugin_manager.get_local_plugins()
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 = await plugin_manager.async_get_online_plugins(force)
online_plugins = PluginManager().get_online_plugins()
if not online_plugins:
# 没有获取在线插件
if state == "market":
@@ -184,13 +78,12 @@ async def all_plugins(_: User = Depends(get_current_active_superuser_async),
if state == "market":
# 返回未安装的插件
return market_plugins
# 返回所有插件
return installed_plugins + market_plugins
@router.get("/installed", summary="已安装插件", response_model=List[str])
async def installed(_: User = Depends(get_current_active_superuser_async)) -> Any:
def installed(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询用户已安装插件清单
"""
@@ -198,43 +91,30 @@ async def installed(_: User = Depends(get_current_active_superuser_async)) -> An
@router.get("/statistic", summary="插件安装统计", response_model=dict)
async def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def statistic(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
插件安装统计
"""
return await PluginHelper().async_get_statistic()
@router.get("/reload/{plugin_id}", summary="重新加载插件", response_model=schemas.Response)
def reload_plugin(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> Any:
"""
重新加载插件
"""
# 重新加载插件
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
register_plugin(plugin_id)
return schemas.Response(success=True)
return PluginHelper().get_statistic()
@router.get("/install/{plugin_id}", summary="安装插件", response_model=schemas.Response)
async def install(plugin_id: str,
repo_url: Optional[str] = "",
force: Optional[bool] = False,
_: User = Depends(get_current_active_superuser_async)) -> Any:
def install(plugin_id: str,
repo_url: str = "",
force: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
安装插件
"""
# 已安装插件
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
# 首先检查插件是否已经存在,并且是否强制安装,否则只进行安装统计
plugin_helper = PluginHelper()
if not force and plugin_id in PluginManager().get_plugin_ids():
await plugin_helper.async_install_reg(pid=plugin_id)
PluginHelper().install_reg(pid=plugin_id)
else:
# 插件不存在或需要强制安装,下载安装并注册插件
if repo_url:
state, msg = await plugin_helper.async_install(pid=plugin_id, repo_url=repo_url)
state, msg = PluginHelper().install(pid=plugin_id, repo_url=repo_url)
# 安装失败则直接响应
if not state:
return schemas.Response(success=False, message=msg)
@@ -245,67 +125,35 @@ async def install(plugin_id: str,
if plugin_id not in install_plugins:
install_plugins.append(plugin_id)
# 保存设置
await SystemConfigOper().async_set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 重新加载插件
await run_in_threadpool(reload_plugin, plugin_id)
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 加载插件到内存
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@router.get("/remotes", summary="获取插件联邦组件列表", response_model=List[dict])
async 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,
_: User = Depends(get_current_active_superuser)) -> dict:
_: schemas.TokenPayload = Depends(verify_token)) -> dict:
"""
根据插件ID获取插件配置表单或Vue组件URL
根据插件ID获取插件配置表单
"""
plugin_manager = PluginManager()
plugin_instance = plugin_manager.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": plugin_manager.get_plugin_config(plugin_id) or model
}
except Exception as e:
logger.error(f"插件 {plugin_id} 调用方法 get_form 出错: {str(e)}")
return {}
conf, model = PluginManager().get_plugin_form(plugin_id)
return {
"conf": conf,
"model": model
}
@router.get("/page/{plugin_id}", summary="获取插件数据页面")
def plugin_page(plugin_id: str, _: User = Depends(get_current_active_superuser)) -> dict:
def plugin_page(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
"""
根据插件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 {}
return PluginManager().get_plugin_page(plugin_id)
@router.get("/dashboard/meta", summary="获取所有插件仪表板元信息")
@@ -316,238 +164,44 @@ 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 plugin_dashboard_by_key(plugin_id, "", user_agent)
return PluginManager().get_plugin_dashboard(plugin_id, key=None, user_agent=user_agent)
@router.get("/dashboard/{plugin_id}/{key}", summary="获取插件仪表板配置")
def plugin_dashboard(plugin_id: str, key: str, user_agent: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> schemas.PluginDashboard:
"""
根据插件ID获取插件仪表板
"""
return PluginManager().get_plugin_dashboard(plugin_id, key=key, user_agent=user_agent)
@router.get("/reset/{plugin_id}", summary="重置插件配置及数据", response_model=schemas.Response)
def reset_plugin(plugin_id: str,
_: User = Depends(get_current_active_superuser)) -> Any:
def reset_plugin(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据插件ID重置插件配置及数据
"""
plugin_manager = PluginManager()
# 删除配置
plugin_manager.delete_plugin_config(plugin_id)
PluginManager().delete_plugin_config(plugin_id)
# 删除插件所有数据
plugin_manager.delete_plugin_data(plugin_id)
# 重新加载插件
reload_plugin(plugin_id)
PluginManager().delete_plugin_data(plugin_id)
# 重新生效插件
PluginManager().reload_plugin(plugin_id)
# 注册插件服务
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@router.get("/file/{plugin_id}/{filepath:path}", summary="获取插件静态文件")
async 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 = AsyncPath(settings.ROOT_PATH) / "app" / "plugins" / plugin_id.lower()
plugin_file_path = plugin_base_dir / filepath
if not await plugin_file_path.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"{plugin_file_path} 不存在")
if not await 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:
# 异步生成器函数,用于流式读取文件
async def file_generator():
async with aiofiles.open(plugin_file_path, mode='rb') as file:
# 8KB 块大小
while chunk := await file.read(8192):
yield chunk
return StreamingResponse(
file_generator(),
media_type=response_type,
headers={"Content-Disposition": f"inline; filename={plugin_file_path.name}"}
)
except Exception as e:
logger.error(f"Error creating/sending StreamingResponse for {plugin_file_path}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Internal Server Error")
@router.get("/folders", summary="获取插件文件夹配置", response_model=dict)
async def get_plugin_folders(_: User = Depends(get_current_active_superuser_async)) -> 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)
async def save_plugin_folders(folders: dict, _: User = Depends(get_current_active_superuser_async)) -> 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)
async def create_plugin_folder(folder_name: str,
_: User = Depends(get_current_active_superuser_async)) -> 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)
async def delete_plugin_folder(folder_name: str,
_: User = Depends(get_current_active_superuser_async)) -> Any:
"""
删除插件文件夹
"""
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
if folder_name in folders:
del folders[folder_name]
await SystemConfigOper().async_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)
async def update_folder_plugins(folder_name: str, plugin_ids: List[str],
_: User = Depends(get_current_active_superuser_async)) -> Any:
"""
更新指定文件夹中的插件列表
"""
folders = SystemConfigOper().get(SystemConfigKey.PluginFolders) or {}
folders[folder_name] = plugin_ids
await SystemConfigOper().async_set(SystemConfigKey.PluginFolders, folders)
return schemas.Response(success=True, message=f"文件夹 '{folder_name}' 中的插件已更新")
@router.post("/clone/{plugin_id}", summary="创建插件分身", response_model=schemas.Response)
def clone_plugin(plugin_id: str,
clone_data: dict,
_: User = 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)}")
@router.get("/memory", summary="插件内存使用统计", response_model=List[PluginMemoryInfo])
def plugin_memory_stats(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取所有插件的内存使用统计信息
"""
try:
plugin_manager = PluginManager()
memory_stats = plugin_manager.get_plugin_memory_stats()
return memory_stats
except Exception as e:
logger.error(f"获取插件内存统计失败:{str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取插件内存统计失败:{str(e)}")
@router.get("/memory/{plugin_id}", summary="单个插件内存使用统计", response_model=PluginMemoryInfo)
def plugin_memory_stat(plugin_id: str, _: Annotated[str, Depends(verify_apitoken)]) -> Any:
"""
获取指定插件的内存使用统计信息
"""
try:
plugin_manager = PluginManager()
memory_stats = plugin_manager.get_plugin_memory_stats(plugin_id)
if not memory_stats:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail=f"插件 {plugin_id} 不存在或未运行")
return memory_stats[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"获取插件 {plugin_id} 内存统计失败:{str(e)}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"获取插件内存统计失败:{str(e)}")
@router.delete("/memory/cache", summary="清除插件内存统计缓存")
def clear_plugin_memory_cache(_: Annotated[str, Depends(verify_apitoken)],
plugin_id: Optional[str] = None) -> Any:
"""
清除插件内存统计缓存
"""
try:
plugin_manager = PluginManager()
plugin_manager.clear_plugin_memory_cache(plugin_id)
message = f"已清除插件 {plugin_id} 的内存统计缓存" if plugin_id else "已清除所有插件的内存统计缓存"
return schemas.Response(success=True, message=message)
except Exception as e:
logger.error(f"清除插件内存统计缓存失败:{str(e)}")
return schemas.Response(success=False, message=f"清除缓存失败:{str(e)}")
@router.get("/{plugin_id}", summary="获取插件配置")
async def plugin_config(plugin_id: str,
_: User = Depends(get_current_active_superuser_async)) -> dict:
def plugin_config(plugin_id: str, _: schemas.TokenPayload = Depends(verify_token)) -> dict:
"""
根据插件ID获取插件配置信息
"""
@@ -556,143 +210,43 @@ async def plugin_config(plugin_id: str,
@router.put("/{plugin_id}", summary="更新插件配置", response_model=schemas.Response)
def set_plugin_config(plugin_id: str, conf: dict,
_: User = Depends(get_current_active_superuser)) -> Any:
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
更新插件配置
"""
plugin_manager = PluginManager()
# 保存配置
plugin_manager.save_plugin_config(plugin_id, conf)
PluginManager().save_plugin_config(plugin_id, conf)
# 重新生效插件
plugin_manager.init_plugin(plugin_id, conf)
PluginManager().init_plugin(plugin_id, conf)
# 注册插件服务
register_plugin(plugin_id)
Scheduler().update_plugin_job(plugin_id)
# 注册插件API
register_plugin_api(plugin_id)
return schemas.Response(success=True)
@router.delete("/{plugin_id}", summary="卸载插件", response_model=schemas.Response)
def uninstall_plugin(plugin_id: str,
_: User = Depends(get_current_active_superuser)) -> Any:
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
卸载插件
"""
config_oper = SystemConfigOper()
# 删除已安装信息
install_plugins = config_oper.get(SystemConfigKey.UserInstalledPlugins) or []
install_plugins = SystemConfigOper().get(SystemConfigKey.UserInstalledPlugins) or []
for plugin in install_plugins:
if plugin == plugin_id:
install_plugins.remove(plugin)
break
config_oper.set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 移除插件API
remove_plugin_api(plugin_id)
# 保存
SystemConfigOper().set(SystemConfigKey.UserInstalledPlugins, install_plugins)
# 移除插件
PluginManager().remove_plugin(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)
# 移除插件
plugin_manager.remove_plugin(plugin_id)
# 移除插件API
remove_plugin_api(plugin_id)
return schemas.Response(success=True)
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)}")
# 文件夹处理失败不影响插件卸载的整体流程
# 注册全部插件API
register_plugin_api()

View File

@@ -1,191 +0,0 @@
from typing import Any, List, Optional
from fastapi import APIRouter, Depends
from app import schemas
from app.chain.recommend import RecommendChain
from app.core.event import eventmanager
from app.core.security import verify_token
from app.schemas import RecommendSourceEventData
from app.schemas.types import ChainEventType
router = APIRouter()
@router.get("/source", summary="获取推荐数据源", response_model=List[schemas.RecommendMediaSource])
def source(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取推荐数据源
"""
# 广播事件,请示额外的推荐数据源支持
event_data = RecommendSourceEventData()
event = eventmanager.send_event(ChainEventType.RecommendSource, event_data)
# 使用事件返回的上下文数据
if event and event.event_data:
event_data: RecommendSourceEventData = event.event_data
if event_data.extra_sources:
return event_data.extra_sources
return []
@router.get("/bangumi_calendar", summary="Bangumi每日放送", response_model=List[schemas.MediaInfo])
async def bangumi_calendar(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览Bangumi每日放送
"""
return await RecommendChain().async_bangumi_calendar(page=page, count=count)
@router.get("/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo])
async def douban_showing(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣正在热映
"""
return await RecommendChain().async_douban_movie_showing(page=page, count=count)
@router.get("/douban_movies", summary="豆瓣电影", response_model=List[schemas.MediaInfo])
async def douban_movies(sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣电影信息
"""
return await RecommendChain().async_douban_movies(sort=sort, tags=tags, page=page, count=count)
@router.get("/douban_tvs", summary="豆瓣剧集", response_model=List[schemas.MediaInfo])
async def douban_tvs(sort: Optional[str] = "R",
tags: Optional[str] = "",
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
return await RecommendChain().async_douban_tvs(sort=sort, tags=tags, page=page, count=count)
@router.get("/douban_movie_top250", summary="豆瓣电影TOP250", response_model=List[schemas.MediaInfo])
async def douban_movie_top250(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览豆瓣剧集信息
"""
return await RecommendChain().async_douban_movie_top250(page=page, count=count)
@router.get("/douban_tv_weekly_chinese", summary="豆瓣国产剧集周榜", response_model=List[schemas.MediaInfo])
async def douban_tv_weekly_chinese(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
中国每周剧集口碑榜
"""
return await RecommendChain().async_douban_tv_weekly_chinese(page=page, count=count)
@router.get("/douban_tv_weekly_global", summary="豆瓣全球剧集周榜", response_model=List[schemas.MediaInfo])
async def douban_tv_weekly_global(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
全球每周剧集口碑榜
"""
return await RecommendChain().async_douban_tv_weekly_global(page=page, count=count)
@router.get("/douban_tv_animation", summary="豆瓣动画剧集", response_model=List[schemas.MediaInfo])
async def douban_tv_animation(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门动画剧集
"""
return await RecommendChain().async_douban_tv_animation(page=page, count=count)
@router.get("/douban_movie_hot", summary="豆瓣热门电影", response_model=List[schemas.MediaInfo])
async def douban_movie_hot(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门电影
"""
return await RecommendChain().async_douban_movie_hot(page=page, count=count)
@router.get("/douban_tv_hot", summary="豆瓣热门电视剧", response_model=List[schemas.MediaInfo])
async def douban_tv_hot(page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
热门电视剧
"""
return await RecommendChain().async_douban_tv_hot(page=page, count=count)
@router.get("/tmdb_movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
async def tmdb_movies(sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB电影信息
"""
return await RecommendChain().async_tmdb_movies(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)
@router.get("/tmdb_tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
async def tmdb_tvs(sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB剧集信息
"""
return await RecommendChain().async_tmdb_tvs(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)
@router.get("/tmdb_trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
async def tmdb_trending(page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
TMDB流行趋势
"""
return await RecommendChain().async_tmdb_trending(page=page)

View File

@@ -1,4 +1,4 @@
from typing import List, Any, Optional
from typing import List, Any
from fastapi import APIRouter, Depends
@@ -6,145 +6,81 @@ from app import schemas
from app.chain.media import MediaChain
from app.chain.search import SearchChain
from app.core.config import settings
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_token
from app.schemas import MediaRecognizeConvertEventData
from app.schemas.types import MediaType, ChainEventType
from app.schemas.types import MediaType
router = APIRouter()
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
async def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search_latest(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询搜索结果
"""
torrents = await SearchChain().async_last_search_results()
torrents = SearchChain().last_search_results()
return [torrent.to_dict() for torrent in torrents]
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
async def search_by_id(mediaid: str,
mtype: Optional[str] = None,
area: Optional[str] = "title",
title: Optional[str] = None,
year: Optional[str] = None,
season: Optional[str] = None,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search_by_id(mediaid: str,
mtype: str = None,
area: str = "title",
season: str = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID/豆瓣ID精确搜索站点资源 tmdb:/douban:/bangumi:
"""
if mtype:
media_type = MediaType(mtype)
else:
media_type = None
mtype = MediaType(mtype)
if season:
media_season = int(season)
else:
media_season = None
if sites:
site_list = [int(site) for site in sites.split(",") if site]
else:
site_list = None
torrents = None
media_chain = MediaChain()
search_chain = SearchChain()
# 根据前缀识别媒体ID
season = int(season)
if mediaid.startswith("tmdb:"):
tmdbid = int(mediaid.replace("tmdb:", ""))
if settings.RECOGNIZE_SOURCE == "douban":
# 通过TMDBID识别豆瓣ID
doubaninfo = await media_chain.async_get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=media_type)
doubaninfo = MediaChain().get_doubaninfo_by_tmdbid(tmdbid=tmdbid, mtype=mtype)
if doubaninfo:
torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"),
mtype=media_type, area=area, season=media_season,
sites=site_list, cache_local=True)
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else:
torrents = await search_chain.async_search_by_id(tmdbid=tmdbid, mtype=media_type, area=area,
season=media_season,
sites=site_list, cache_local=True)
torrents = SearchChain().search_by_id(tmdbid=tmdbid, mtype=mtype, area=area, season=season)
elif mediaid.startswith("douban:"):
doubanid = mediaid.replace("douban:", "")
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过豆瓣ID识别TMDBID
tmdbinfo = await media_chain.async_get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=media_type)
tmdbinfo = MediaChain().get_tmdbinfo_by_doubanid(doubanid=doubanid, mtype=mtype)
if tmdbinfo:
if tmdbinfo.get('season') and not media_season:
media_season = tmdbinfo.get('season')
torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=media_type, area=area, season=media_season,
sites=site_list, cache_local=True)
if tmdbinfo.get('season') and not season:
season = tmdbinfo.get('season')
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
torrents = await search_chain.async_search_by_id(doubanid=doubanid, mtype=media_type, area=area,
season=media_season,
sites=site_list, cache_local=True)
torrents = SearchChain().search_by_id(doubanid=doubanid, mtype=mtype, area=area, season=season)
elif mediaid.startswith("bangumi:"):
bangumiid = int(mediaid.replace("bangumi:", ""))
if settings.RECOGNIZE_SOURCE == "themoviedb":
# 通过BangumiID识别TMDBID
tmdbinfo = await media_chain.async_get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
tmdbinfo = MediaChain().get_tmdbinfo_by_bangumiid(bangumiid=bangumiid)
if tmdbinfo:
torrents = await search_chain.async_search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=media_type, area=area, season=media_season,
sites=site_list, cache_local=True)
torrents = SearchChain().search_by_id(tmdbid=tmdbinfo.get("id"),
mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到TMDB媒体信息")
else:
# 通过BangumiID识别豆瓣ID
doubaninfo = await media_chain.async_get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
doubaninfo = MediaChain().get_doubaninfo_by_bangumiid(bangumiid=bangumiid)
if doubaninfo:
torrents = await search_chain.async_search_by_id(doubanid=doubaninfo.get("id"),
mtype=media_type, area=area, season=media_season,
sites=site_list, cache_local=True)
torrents = SearchChain().search_by_id(doubanid=doubaninfo.get("id"),
mtype=mtype, area=area, season=season)
else:
return schemas.Response(success=False, message="未识别到豆瓣媒体信息")
else:
# 未知前缀,广播事件解析媒体信息
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid,
convert_type=settings.RECOGNIZE_SOURCE
)
event = await eventmanager.async_send_event(ChainEventType.MediaRecognizeConvert, event_data)
# 使用事件返回的上下文数据
if event and event.event_data:
event_data: MediaRecognizeConvertEventData = event.event_data
if event_data.media_dict:
search_id = event_data.media_dict.get("id")
if event_data.convert_type == "themoviedb":
torrents = await search_chain.async_search_by_id(tmdbid=search_id, mtype=media_type, area=area,
season=media_season, cache_local=True)
elif event_data.convert_type == "douban":
torrents = await search_chain.async_search_by_id(doubanid=search_id, mtype=media_type, area=area,
season=media_season, cache_local=True)
else:
if not title:
return schemas.Response(success=False, message="未知的媒体ID")
# 使用名称识别兜底
meta = MetaInfo(title)
if year:
meta.year = year
if media_type:
meta.type = media_type
if media_season:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await media_chain.async_recognize_media(meta=meta)
if mediainfo:
if settings.RECOGNIZE_SOURCE == "themoviedb":
torrents = await search_chain.async_search_by_id(tmdbid=mediainfo.tmdb_id, mtype=media_type,
area=area,
season=media_season, cache_local=True)
else:
torrents = await search_chain.async_search_by_id(doubanid=mediainfo.douban_id, mtype=media_type,
area=area,
season=media_season, cache_local=True)
# 返回搜索结果
return schemas.Response(success=False, message="未知的媒体ID")
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
else:
@@ -152,18 +88,14 @@ async def search_by_id(mediaid: str,
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
async def search_by_title(keyword: Optional[str] = None,
page: Optional[int] = 0,
sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def search_by_title(keyword: str = None,
page: int = 0,
site: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
"""
torrents = await SearchChain().async_search_by_title(
title=keyword, page=page,
sites=[int(site) for site in sites.split(",") if site] if sites else None,
cache_local=True
)
torrents = SearchChain().search_by_title(title=keyword, page=page, site=site)
if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源")
return schemas.Response(success=True, data=[torrent.to_dict() for torrent in torrents])

View File

@@ -1,28 +1,22 @@
from typing import List, Any, Dict, Optional
from typing import List, Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
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.event import EventManager
from app.core.security import verify_token
from app.db import get_db, get_async_db
from app.db import get_db
from app.db.models import User
from app.db.models.site import Site
from app.db.models.siteicon import SiteIcon
from app.db.models.sitestatistic import SiteStatistic
from app.db.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, get_current_active_superuser_async
from app.helper.sites import SitesHelper # noqa
from app.db.userauth import get_current_active_superuser
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas.types import SystemConfigKey, EventType
from app.utils.string import StringUtils
@@ -31,20 +25,20 @@ router = APIRouter()
@router.get("/", summary="所有站点", response_model=List[schemas.Site])
async def read_sites(db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser)) -> List[dict]:
def read_sites(db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
"""
获取站点列表
"""
return await Site.async_list_order_by_pri(db)
return Site.list_order_by_pri(db)
@router.post("/", summary="新增站点", response_model=schemas.Response)
async def add_site(
def add_site(
*,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
site_in: schemas.Site,
_: User = Depends(get_current_active_superuser)
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
新增站点
@@ -54,10 +48,10 @@ async def add_site(
if SitesHelper().auth_level < 2:
return schemas.Response(success=False, message="用户未通过认证,无法使用站点功能!")
domain = StringUtils.get_url_domain(site_in.url)
site_info = await SitesHelper().async_get_indexer(domain)
site_info = SitesHelper().get_indexer(domain)
if not site_info:
return schemas.Response(success=False, message="该站点不支持,请检查站点域名是否正确")
if await Site.async_get_by_domain(db, domain):
if Site.get_by_domain(db, domain):
return schemas.Response(success=False, message=f"{domain} 站点己存在")
# 保存站点信息
site_in.domain = domain
@@ -70,39 +64,39 @@ async def add_site(
site = Site(**site_in.dict())
site.create(db)
# 通知站点更新
await eventmanager.async_send_event(EventType.SiteUpdated, {
EventManager().send_event(EventType.SiteUpdated, {
"domain": domain
})
return schemas.Response(success=True)
@router.put("/", summary="更新站点", response_model=schemas.Response)
async def update_site(
def update_site(
*,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
site_in: schemas.Site,
_: User = Depends(get_current_active_superuser)
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
更新站点信息
"""
site = await Site.async_get(db, site_in.id)
site = Site.get(db, site_in.id)
if not site:
return schemas.Response(success=False, message="站点不存在")
# 校正地址格式
_scheme, _netloc = StringUtils.get_url_netloc(site_in.url)
site_in.url = f"{_scheme}://{_netloc}/"
await site.async_update(db, site_in.dict())
site.update(db, site_in.dict())
# 通知站点更新
await eventmanager.async_send_event(EventType.SiteUpdated, {
EventManager().send_event(EventType.SiteUpdated, {
"domain": site_in.domain
})
return schemas.Response(success=True)
@router.get("/cookiecloud", summary="CookieCloud同步", response_model=schemas.Response)
async def cookie_cloud_sync(background_tasks: BackgroundTasks,
_: User = Depends(get_current_active_superuser_async)) -> Any:
def cookie_cloud_sync(background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
运行CookieCloud同步站点信息
"""
@@ -111,7 +105,7 @@ async def cookie_cloud_sync(background_tasks: BackgroundTasks,
@router.get("/reset", summary="重置站点", response_model=schemas.Response)
def reset(db: AsyncSession = Depends(get_db),
def reset(db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
"""
清空所有站点数据并重新同步CookieCloud站点信息
@@ -122,25 +116,25 @@ def reset(db: AsyncSession = Depends(get_db),
# 启动定时服务
Scheduler().start("cookiecloud", manual=True)
# 插件站点删除
eventmanager.send_event(EventType.SiteDeleted,
{
"site_id": "*"
})
EventManager().send_event(EventType.SiteDeleted,
{
"site_id": "*"
})
return schemas.Response(success=True, message="站点已重置!")
@router.post("/priorities", summary="批量更新站点优先级", response_model=schemas.Response)
async def update_sites_priority(
def update_sites_priority(
priorities: List[dict],
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)) -> Any:
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
批量更新站点优先级
"""
for priority in priorities:
site = await Site.async_get(db, priority.get("id"))
site = Site.get(db, priority.get("id"))
if site:
await site.async_update(db, {"pri": priority.get("pri")})
site.update(db, {"pri": priority.get("pri")})
return schemas.Response(success=True)
@@ -149,9 +143,9 @@ def update_cookie(
site_id: int,
username: str,
password: str,
code: Optional[str] = None,
code: str = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
使用用户密码更新站点Cookie
"""
@@ -170,61 +164,6 @@ def update_cookie(
return schemas.Response(success=state, message=message)
@router.post("/userdata/{site_id}", summary="更新站点用户数据", response_model=schemas.Response)
def refresh_userdata(
site_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
"""
刷新站点用户数据
"""
site = Site.get(db, site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
indexer = SitesHelper().get_indexer(site.domain)
if not indexer:
return schemas.Response(success=False, message="站点不支持索引或未通过用户认证!")
user_data = SiteChain().refresh_userdata(site=indexer) or {}
return schemas.Response(success=True, data=user_data)
@router.get("/userdata/latest", summary="查询所有站点最新用户数据", response_model=List[schemas.SiteUserData])
async def read_userdata_latest(
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)) -> Any:
"""
查询所有站点最新用户数据
"""
user_datas = await SiteUserData.async_get_latest(db)
if not user_datas:
return []
return [user_data.to_dict() for user_data in user_datas]
@router.get("/userdata/{site_id}", summary="查询某站点用户数据", response_model=schemas.Response)
async def read_userdata(
site_id: int,
workdate: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)) -> Any:
"""
查询站点用户数据
"""
site = await Site.async_get(db, site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
user_data = await SiteUserData.async_get_by_domain(db, domain=site.domain, workdate=workdate)
if not user_data:
return schemas.Response(success=False, data=[])
return schemas.Response(success=True, data=user_data)
@router.get("/test/{site_id}", summary="连接测试", response_model=schemas.Response)
def test_site(site_id: int,
db: Session = Depends(get_db),
@@ -243,19 +182,19 @@ def test_site(site_id: int,
@router.get("/icon/{site_id}", summary="站点图标", response_model=schemas.Response)
async def site_icon(site_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def site_icon(site_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取站点图标base64或者url
"""
site = await Site.async_get(db, site_id)
site = Site.get(db, site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
icon = await SiteIcon.async_get_by_domain(db, site.domain)
icon = SiteIcon.get_by_domain(db, site.domain)
if not icon:
return schemas.Response(success=False, message="站点图标不存在!")
return schemas.Response(success=True, data={
@@ -263,69 +202,36 @@ async def site_icon(site_id: int,
})
@router.get("/category/{site_id}", summary="站点分类", response_model=List[schemas.SiteCategory])
async def site_category(site_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取站点分类
"""
site = await Site.async_get(db, site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
indexer = await SitesHelper().async_get_indexer(site.domain)
if not indexer:
raise HTTPException(
status_code=404,
detail=f"站点 {site.domain} 不支持",
)
category: Dict[str, List[dict]] = indexer.get('category') or []
if not category:
return []
result = []
for cats in category.values():
for cat in cats:
if cat not in result:
result.append(cat)
return result
@router.get("/resource/{site_id}", summary="站点资源", response_model=List[schemas.TorrentInfo])
async def site_resource(site_id: int,
keyword: Optional[str] = None,
cat: Optional[str] = None,
page: Optional[int] = 0,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)) -> Any:
def site_resource(site_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览站点资源
"""
site = await Site.async_get(db, site_id)
site = Site.get(db, site_id)
if not site:
raise HTTPException(
status_code=404,
detail=f"站点 {site_id} 不存在",
)
torrents = await TorrentsChain().async_browse(domain=site.domain, keyword=keyword, cat=cat, page=page)
torrents = TorrentsChain().browse(domain=site.domain)
if not torrents:
return []
return [torrent.to_dict() for torrent in torrents]
@router.get("/domain/{site_url}", summary="站点详情", response_model=schemas.Site)
async def read_site_by_domain(
def read_site_by_domain(
site_url: str,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
通过域名获取站点信息
"""
domain = StringUtils.get_url_domain(site_url)
site = await Site.async_get_by_domain(db, domain)
site = Site.get_by_domain(db, domain)
if not site:
raise HTTPException(
status_code=404,
@@ -334,36 +240,24 @@ async def read_site_by_domain(
return site
@router.get("/statistic/{site_url}", summary="特定站点统计信息", response_model=schemas.SiteStatistic)
async def read_statistic_by_domain(
@router.get("/statistic/{site_url}", summary="站点统计信息", response_model=schemas.SiteStatistic)
def read_site_by_domain(
site_url: str,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
通过域名获取站点统计信息
"""
domain = StringUtils.get_url_domain(site_url)
sitestatistic = await SiteStatistic.async_get_by_domain(db, domain)
sitestatistic = SiteStatistic.get_by_domain(db, domain)
if sitestatistic:
return sitestatistic
return schemas.SiteStatistic(domain=domain)
@router.get("/statistic", summary="所有站点统计信息", response_model=List[schemas.SiteStatistic])
async def read_statistics(
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
获取所有站点统计信息
"""
return await SiteStatistic.async_list(db)
@router.get("/rss", summary="所有订阅站点", response_model=List[schemas.Site])
async def read_rss_sites(db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> List[dict]:
def read_rss_sites(db: Session = Depends(get_db)) -> List[dict]:
"""
获取站点列表
"""
@@ -371,7 +265,7 @@ async def read_rss_sites(db: AsyncSession = Depends(get_async_db),
selected_sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
# 所有站点
all_site = await Site.async_list_order_by_pri(db)
all_site = Site.list_order_by_pri(db)
if not selected_sites:
return all_site
@@ -380,67 +274,16 @@ async def read_rss_sites(db: AsyncSession = Depends(get_async_db),
return rss_sites
@router.get("/auth", summary="查询认证站点", response_model=dict)
async def read_auth_sites(_: schemas.TokenPayload = Depends(verify_token)) -> dict:
"""
获取可认证站点列表
"""
return SitesHelper().get_authsites()
@router.post("/auth", summary="用户站点认证", response_model=schemas.Response)
def auth_site(
auth_info: schemas.SiteAuth,
_: User = Depends(get_current_active_superuser)
) -> Any:
"""
用户站点认证
"""
if not auth_info or not auth_info.site or not auth_info.params:
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)
async def site_mapping(_: User = Depends(get_current_active_superuser_async)):
"""
获取站点域名到名称的映射关系
"""
try:
sites = await SiteOper().async_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("/supporting", summary="获取支持的站点列表", response_model=dict)
async def support_sites(_: User = Depends(get_current_active_superuser_async)):
"""
获取支持的站点列表
"""
return SitesHelper().get_indexsites()
@router.get("/{site_id}", summary="站点详情", response_model=schemas.Site)
async def read_site(
def read_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
通过ID获取站点信息
"""
site = await Site.async_get(db, site_id)
site = Site.get(db, site_id)
if not site:
raise HTTPException(
status_code=404,
@@ -450,18 +293,18 @@ async def read_site(
@router.delete("/{site_id}", summary="删除站点", response_model=schemas.Response)
async def delete_site(
def delete_site(
site_id: int,
db: AsyncSession = Depends(get_async_db),
_: User = Depends(get_current_active_superuser_async)
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)
) -> Any:
"""
删除站点
"""
await Site.async_delete(db, site_id)
Site.delete(db, site_id)
# 插件站点删除
await eventmanager.async_send_event(EventType.SiteDeleted,
{
"site_id": site_id
})
EventManager().send_event(EventType.SiteDeleted,
{
"site_id": site_id
})
return schemas.Response(success=True)

View File

@@ -1,231 +0,0 @@
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import FileResponse, Response
from app import schemas
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token
from app.db.models import User
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async
from app.helper.progress import ProgressHelper
from app.schemas.types import ProgressKey
router = APIRouter()
@router.get("/qrcode/{name}", summary="生成二维码内容", response_model=schemas.Response)
def qrcode(name: str, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
"""
qrcode_data, errmsg = StorageChain().generate_qrcode(name)
if qrcode_data:
return schemas.Response(success=True, data=qrcode_data, message=errmsg)
return schemas.Response(success=False, message=errmsg)
@router.get("/check/{name}", summary="二维码登录确认", response_model=schemas.Response)
def check(name: str, ck: Optional[str] = None, t: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
二维码登录确认
"""
if ck or t:
data, errmsg = StorageChain().check_login(name, ck=ck, t=t)
else:
data, errmsg = StorageChain().check_login(name)
if data:
return schemas.Response(success=True, data=data)
return schemas.Response(success=False, message=errmsg)
@router.post("/save/{name}", summary="保存存储配置", response_model=schemas.Response)
def save(name: str,
conf: dict,
_: User = Depends(get_current_active_superuser)) -> Any:
"""
保存存储配置
"""
StorageChain().save_config(name, conf)
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',
_: User = Depends(get_current_active_superuser)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件项
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
file_list = StorageChain().list_files(fileitem)
if file_list:
if sort == "name":
file_list.sort(key=lambda x: x.name or "")
else:
file_list.sort(key=lambda x: x.modify_time or datetime.min, reverse=True)
return file_list
@router.post("/mkdir", summary="创建目录", response_model=schemas.Response)
def mkdir(fileitem: schemas.FileItem,
name: str,
_: User = Depends(get_current_active_superuser)) -> Any:
"""
创建目录
:param fileitem: 文件项
:param name: 目录名称
:param _: token
"""
if not name:
return schemas.Response(success=False)
result = StorageChain().create_folder(fileitem, name)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录", response_model=schemas.Response)
def delete(fileitem: schemas.FileItem,
_: User = Depends(get_current_active_superuser)) -> Any:
"""
删除文件或目录
:param fileitem: 文件项
:param _: token
"""
result = StorageChain().delete_file(fileitem)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/download", summary="下载文件")
def download(fileitem: schemas.FileItem,
_: User = Depends(get_current_active_superuser)) -> Any:
"""
下载文件或目录
:param fileitem: 文件项
:param _: token
"""
# 临时目录
tmp_file = StorageChain().download_file(fileitem)
if tmp_file:
return FileResponse(path=tmp_file)
return schemas.Response(success=False)
@router.post("/image", summary="预览图片")
def image(fileitem: schemas.FileItem,
_: User = Depends(get_current_active_superuser)) -> Any:
"""
下载文件或目录
:param fileitem: 文件项
:param _: token
"""
# 临时目录
tmp_file = StorageChain().download_file(fileitem)
if not tmp_file:
raise HTTPException(status_code=500, detail="图片读取出错")
return Response(content=tmp_file.read_bytes(), media_type="image/jpeg")
@router.post("/rename", summary="重命名文件或目录", response_model=schemas.Response)
def rename(fileitem: schemas.FileItem,
new_name: str,
recursive: Optional[bool] = False,
_: User = Depends(get_current_active_superuser)) -> Any:
"""
重命名文件或目录
:param fileitem: 文件项
:param new_name: 新名称
:param recursive: 是否递归修改
:param _: token
"""
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(ProgressKey.BatchRename)
progress.start()
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} ...")
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()
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()
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()
return schemas.Response(success=False, message=f"{sub_path.name} 重命名失败!")
progress.end()
# 重命名自己
result = StorageChain().rename_file(fileitem, new_name)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/usage/{name}", summary="存储空间信息", response_model=schemas.StorageUsage)
def usage(name: str, _: User = Depends(get_current_active_superuser)) -> Any:
"""
查询存储空间
"""
ret = StorageChain().storage_usage(name)
if ret:
return ret
return schemas.StorageUsage()
@router.get("/transtype/{name}", summary="支持的整理方式获取", response_model=schemas.StorageTransType)
async def transtype(name: str, _: User = Depends(get_current_active_superuser_async)) -> Any:
"""
查询支持的整理方式
"""
ret = StorageChain().support_transtype(name)
if ret:
return schemas.StorageTransType(transtype=ret)
return schemas.StorageTransType()

View File

@@ -1,26 +1,24 @@
from typing import List, Any, Annotated, Optional
import json
from typing import List, Any
import cn2an
from fastapi import APIRouter, Request, BackgroundTasks, Depends, HTTPException, Header
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.chain.subscribe import SubscribeChain
from app.core.config import settings
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_apitoken
from app.db import get_async_db, get_db
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.db.models.subscribehistory import SubscribeHistory
from app.db.models.user import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user_async
from app.db.userauth import get_current_active_user
from app.helper.subscribe import SubscribeHelper
from app.scheduler import Scheduler
from app.schemas.types import MediaType, EventType, SystemConfigKey
from app.schemas.types import MediaType
router = APIRouter()
@@ -35,29 +33,38 @@ def start_subscribe_add(title: str, year: str,
@router.get("/", summary="查询所有订阅", response_model=List[schemas.Subscribe])
async def read_subscribes(
db: AsyncSession = Depends(get_async_db),
def read_subscribes(
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询所有订阅
"""
return await Subscribe.async_list(db)
subscribes = Subscribe.list(db)
for subscribe in subscribes:
if subscribe.sites:
try:
subscribe.sites = json.loads(str(subscribe.sites))
except json.JSONDecodeError:
subscribe.sites = []
else:
subscribe.sites = []
return subscribes
@router.get("/list", summary="查询所有订阅API_TOKEN", response_model=List[schemas.Subscribe])
async def list_subscribes(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def list_subscribes(_: str = Depends(verify_apitoken)) -> Any:
"""
查询所有订阅 API_TOKEN认证?token=xxx
"""
return await read_subscribes()
return read_subscribes()
@router.post("/", summary="新增订阅", response_model=schemas.Response)
async def create_subscribe(
def create_subscribe(
*,
subscribe_in: schemas.Subscribe,
current_user: User = Depends(get_current_active_user_async),
) -> schemas.Response:
current_user: User = Depends(get_current_active_user),
) -> Any:
"""
新增订阅
"""
@@ -76,36 +83,39 @@ async def create_subscribe(
title = subscribe_in.name
else:
title = None
# 订阅用户
subscribe_in.username = current_user.name
# 转化为字典
subscribe_dict = subscribe_in.dict()
if subscribe_in.id:
subscribe_dict.pop("id", None)
sid, message = await SubscribeChain().async_add(mtype=mtype,
title=title,
exist_ok=True,
**subscribe_dict)
sid, message = SubscribeChain().add(mtype=mtype,
title=title,
year=subscribe_in.year,
tmdbid=subscribe_in.tmdbid,
season=subscribe_in.season,
doubanid=subscribe_in.doubanid,
bangumiid=subscribe_in.bangumiid,
username=current_user.name,
best_version=subscribe_in.best_version,
save_path=subscribe_in.save_path,
search_imdbid=subscribe_in.search_imdbid,
exist_ok=True)
return schemas.Response(
success=bool(sid), message=message, data={"id": sid}
)
@router.put("/", summary="更新订阅", response_model=schemas.Response)
async def update_subscribe(
def update_subscribe(
*,
subscribe_in: schemas.Subscribe,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
更新订阅信息
"""
subscribe = await Subscribe.async_get(db, subscribe_in.id)
subscribe = Subscribe.get(db, subscribe_in.id)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
if subscribe_in.sites is not None:
subscribe_in.sites = json.dumps(subscribe_in.sites)
# 避免更新缺失集数
old_subscribe_dict = subscribe.to_dict()
subscribe_dict = subscribe_in.dict()
if not subscribe_in.lack_episode:
# 没有缺失集数时缺失集数清空避免更新为0
@@ -119,81 +129,39 @@ async def update_subscribe(
# 是否手动修改过总集数
if subscribe_in.total_episode != subscribe.total_episode:
subscribe_dict["manual_total_episode"] = 1
# 更新到数据库
await subscribe.async_update(db, subscribe_dict)
# 重新获取更新后的订阅数据
updated_subscribe = await Subscribe.async_get(db, subscribe_in.id)
# 发送订阅调整事件
await eventmanager.async_send_event(EventType.SubscribeModified, {
"subscribe_id": subscribe_in.id,
"old_subscribe_info": old_subscribe_dict,
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
})
return schemas.Response(success=True)
@router.put("/status/{subid}", summary="更新订阅状态", response_model=schemas.Response)
async def update_subscribe_status(
subid: int,
state: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
更新订阅状态
"""
subscribe = await Subscribe.async_get(db, subid)
if not subscribe:
return schemas.Response(success=False, message="订阅不存在")
valid_states = ["R", "P", "S"]
if state not in valid_states:
return schemas.Response(success=False, message="无效的订阅状态")
old_subscribe_dict = subscribe.to_dict()
await subscribe.async_update(db, {
"state": state
})
# 重新获取更新后的订阅数据
updated_subscribe = await Subscribe.async_get(db, subid)
# 发送订阅调整事件
await eventmanager.async_send_event(EventType.SubscribeModified, {
"subscribe_id": subid,
"old_subscribe_info": old_subscribe_dict,
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
})
subscribe.update(db, subscribe_dict)
return schemas.Response(success=True)
@router.get("/media/{mediaid}", summary="查询订阅", response_model=schemas.Subscribe)
async def subscribe_mediaid(
def subscribe_mediaid(
mediaid: str,
season: Optional[int] = None,
title: Optional[str] = None,
db: AsyncSession = Depends(get_async_db),
season: int = None,
title: str = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据 TMDBID/豆瓣ID/BangumiId 查询订阅 tmdb:/douban:
"""
result = None
title_check = False
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return Subscribe()
result = await Subscribe.async_exists(db, tmdbid=int(tmdbid), season=season)
result = Subscribe.exists(db, tmdbid=int(tmdbid), season=season)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return Subscribe()
result = await Subscribe.async_get_by_doubanid(db, doubanid)
result = Subscribe.get_by_doubanid(db, doubanid)
if not result and title:
title_check = True
elif mediaid.startswith("bangumi:"):
bangumiid = mediaid[8:]
if not bangumiid or not str(bangumiid).isdigit():
return Subscribe()
result = await Subscribe.async_get_by_bangumiid(db, int(bangumiid))
if not result and title:
title_check = True
else:
result = await Subscribe.async_get_by_mediaid(db, mediaid)
result = Subscribe.get_by_bangumiid(db, int(bangumiid))
if not result and title:
title_check = True
# 使用名称检查订阅
@@ -201,7 +169,12 @@ async def subscribe_mediaid(
meta = MetaInfo(title)
if season:
meta.begin_season = season
result = await Subscribe.async_get_by_title(db, title=meta.name, season=meta.begin_season)
result = Subscribe.get_by_title(db, title=meta.name, season=meta.begin_season)
if result and result.sites:
try:
result.sites = json.loads(result.sites)
except json.JSONDecodeError:
result.sites = []
return result if result else Subscribe()
@@ -217,30 +190,18 @@ def refresh_subscribes(
@router.get("/reset/{subid}", summary="重置订阅", response_model=schemas.Response)
async def reset_subscribes(
def reset_subscribes(
subid: int,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重置订阅
"""
subscribe = await Subscribe.async_get(db, subid)
subscribe = Subscribe.get(db, subid)
if subscribe:
# 在更新之前获取旧数据
old_subscribe_dict = subscribe.to_dict()
# 更新订阅
await subscribe.async_update(db, {
"note": [],
"lack_episode": subscribe.total_episode,
"state": "R"
})
# 重新获取更新后的订阅数据
updated_subscribe = await Subscribe.async_get(db, subid)
# 发送订阅调整事件
await eventmanager.async_send_event(EventType.SubscribeModified, {
"subscribe_id": subid,
"old_subscribe_info": old_subscribe_dict,
"subscribe_info": updated_subscribe.to_dict() if updated_subscribe else {},
subscribe.update(db, {
"note": "",
"lack_episode": subscribe.total_episode
})
return schemas.Response(success=True)
return schemas.Response(success=False, message="订阅不存在")
@@ -257,7 +218,7 @@ def check_subscribes(
@router.get("/search", summary="搜索所有订阅", response_model=schemas.Response)
async def search_subscribes(
def search_subscribes(
background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
@@ -276,7 +237,7 @@ async def search_subscribes(
@router.get("/search/{subscribe_id}", summary="搜索订阅", response_model=schemas.Response)
async def search_subscribe(
def search_subscribe(
subscribe_id: int,
background_tasks: BackgroundTasks,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
@@ -296,49 +257,32 @@ async def search_subscribe(
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
async def delete_subscribe_by_mediaid(
def delete_subscribe_by_mediaid(
mediaid: str,
season: Optional[int] = None,
db: AsyncSession = Depends(get_async_db),
season: int = None,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
根据TMDBID或豆瓣ID删除订阅 tmdb:/douban:
"""
delete_subscribes = []
if mediaid.startswith("tmdb:"):
tmdbid = mediaid[5:]
if not tmdbid or not str(tmdbid).isdigit():
return schemas.Response(success=False)
subscribes = await Subscribe.async_get_by_tmdbid(db, int(tmdbid), season)
delete_subscribes.extend(subscribes)
Subscribe().delete_by_tmdbid(db, int(tmdbid), season)
elif mediaid.startswith("douban:"):
doubanid = mediaid[7:]
if not doubanid:
return schemas.Response(success=False)
subscribe = await Subscribe.async_get_by_doubanid(db, doubanid)
if subscribe:
delete_subscribes.append(subscribe)
else:
subscribe = await Subscribe.async_get_by_mediaid(db, mediaid)
if subscribe:
delete_subscribes.append(subscribe)
for subscribe in delete_subscribes:
# 在删除之前获取订阅信息
subscribe_info = subscribe.to_dict()
subscribe_id = subscribe.id
await Subscribe.async_delete(db, subscribe_id)
# 发送事件
await eventmanager.async_send_event(EventType.SubscribeDeleted, {
"subscribe_id": subscribe_id,
"subscribe_info": subscribe_info
})
Subscribe().delete_by_doubanid(db, doubanid)
return schemas.Response(success=True)
@router.post("/seerr", summary="OverSeerr/JellySeerr通知订阅", response_model=schemas.Response)
async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
authorization: Annotated[str | None, Header()] = None) -> Any:
authorization: str = Header(None)) -> Any:
"""
Jellyseerr/Overseerr网络勾子通知订阅
"""
@@ -390,42 +334,49 @@ async def seerr_subscribe(request: Request, background_tasks: BackgroundTasks,
@router.get("/history/{mtype}", summary="查询订阅历史", response_model=List[schemas.Subscribe])
async def subscribe_history(
def read_subscribe(
mtype: str,
page: Optional[int] = 1,
count: Optional[int] = 30,
db: AsyncSession = Depends(get_async_db),
page: int = 1,
count: int = 30,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询电影/电视剧订阅历史
"""
return await SubscribeHistory.async_list_by_type(db, mtype=mtype, page=page, count=count)
historys = SubscribeHistory.list_by_type(db, mtype=mtype, page=page, count=count)
for history in historys:
if history and history.sites:
try:
history.sites = json.loads(history.sites)
except json.JSONDecodeError:
history.sites = []
return historys
@router.delete("/history/{history_id}", summary="删除订阅历史", response_model=schemas.Response)
async def delete_subscribe(
def delete_subscribe(
history_id: int,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
删除订阅历史
"""
await SubscribeHistory.async_delete(db, history_id)
SubscribeHistory.delete(db, history_id)
return schemas.Response(success=True)
@router.get("/popular", summary="热门订阅(基于用户共享数据)", response_model=List[schemas.MediaInfo])
async def popular_subscribes(
def popular_subscribes(
stype: str,
page: Optional[int] = 1,
count: Optional[int] = 30,
min_sub: Optional[int] = None,
page: int = 1,
count: int = 30,
min_sub: int = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询热门订阅
"""
subscribes = await SubscribeHelper().async_get_statistic(stype=stype, page=page, count=count)
subscribes = SubscribeHelper().get_statistic(stype=stype, page=page, count=count)
if subscribes:
ret_medias = []
for sub in subscribes:
@@ -460,167 +411,40 @@ async def popular_subscribes(
return []
@router.get("/user/{username}", summary="用户订阅", response_model=List[schemas.Subscribe])
async def user_subscribes(
username: str,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询用户订阅
"""
return await Subscribe.async_list_by_username(db, username)
@router.get("/files/{subscribe_id}", summary="订阅相关文件信息", response_model=schemas.SubscrbieInfo)
def subscribe_files(
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
def read_subscribe(
subscribe_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
订阅相关文件信息
"""
subscribe = Subscribe.get(db, subscribe_id)
if subscribe:
return SubscribeChain().subscribe_files_info(subscribe)
return schemas.SubscrbieInfo()
@router.post("/share", summary="分享订阅", response_model=schemas.Response)
async def subscribe_share(
sub: schemas.SubscribeShare,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
分享订阅
"""
state, errmsg = await SubscribeHelper().async_sub_share(subscribe_id=sub.subscribe_id,
share_title=sub.share_title,
share_comment=sub.share_comment,
share_user=sub.share_user)
return schemas.Response(success=state, message=errmsg)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
async def subscribe_share_delete(
share_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除分享
"""
state, errmsg = await SubscribeHelper().async_share_delete(share_id=share_id)
return schemas.Response(success=state, message=errmsg)
@router.post("/fork", summary="复用订阅", response_model=schemas.Response)
async def subscribe_fork(
sub: schemas.SubscribeShare,
current_user: User = Depends(get_current_active_user_async)) -> Any:
"""
复用订阅
"""
sub_dict = sub.dict()
sub_dict.pop("id")
for key in list(sub_dict.keys()):
if not hasattr(schemas.Subscribe(), key):
sub_dict.pop(key)
result = await create_subscribe(subscribe_in=schemas.Subscribe(**sub_dict),
current_user=current_user)
if result.success:
await SubscribeHelper().async_sub_fork(share_id=sub.id)
return result
@router.get("/follow", summary="查询已Follow的订阅分享人", response_model=List[str])
async def followed_subscribers(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询已Follow的订阅分享人
"""
return SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
@router.post("/follow", summary="Follow订阅分享人", response_model=schemas.Response)
async def follow_subscriber(
share_uid: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
Follow订阅分享人
"""
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
if share_uid and share_uid not in subscribers:
subscribers.append(share_uid)
await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers)
return schemas.Response(success=True)
@router.delete("/follow", summary="取消Follow订阅分享人", response_model=schemas.Response)
async def unfollow_subscriber(
share_uid: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
取消Follow订阅分享人
"""
subscribers = SystemConfigOper().get(SystemConfigKey.FollowSubscribers) or []
if share_uid and share_uid in subscribers:
subscribers.remove(share_uid)
await SystemConfigOper().async_set(SystemConfigKey.FollowSubscribers, subscribers)
return schemas.Response(success=True)
@router.get("/shares", summary="查询分享的订阅", response_model=List[schemas.SubscribeShare])
async def popular_subscribes(
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询分享的订阅
"""
return await SubscribeHelper().async_get_shares(name=name, page=page, count=count)
@router.get("/share/statistics", summary="查询订阅分享统计", response_model=List[schemas.SubscribeShareStatistics])
async def subscribe_share_statistics(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询订阅分享统计
返回每个分享人分享的媒体数量以及总的复用人次
"""
return await SubscribeHelper().async_get_share_statistics()
@router.get("/{subscribe_id}", summary="订阅详情", response_model=schemas.Subscribe)
async def read_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据订阅编号查询订阅信息
"""
if not subscribe_id:
return Subscribe()
return await Subscribe.async_get(db, subscribe_id)
subscribe = Subscribe.get(db, subscribe_id)
if subscribe and subscribe.sites:
try:
subscribe.sites = json.loads(subscribe.sites)
except json.JSONDecodeError:
subscribe.sites = []
return subscribe
@router.delete("/{subscribe_id}", summary="删除订阅", response_model=schemas.Response)
async def delete_subscribe(
def delete_subscribe(
subscribe_id: int,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)
) -> Any:
"""
删除订阅信息
"""
subscribe = await Subscribe.async_get(db, subscribe_id)
subscribe = Subscribe.get(db, subscribe_id)
if subscribe:
# 在删除之前获取订阅信息
subscribe_info = subscribe.to_dict()
await Subscribe.async_delete(db, subscribe_id)
# 发送事件
await eventmanager.async_send_event(EventType.SubscribeDeleted, {
"subscribe_id": subscribe_id,
"subscribe_info": subscribe_info
})
# 统计订阅
SubscribeHelper().sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
subscribe.delete(db, subscribe_id)
# 统计订阅
SubscribeHelper().sub_done_async({
"tmdbid": subscribe.tmdbid,
"doubanid": subscribe.doubanid
})
return schemas.Response(success=True)

View File

@@ -1,204 +1,57 @@
import asyncio
import io
import json
import re
from collections import deque
import time
from datetime import datetime
from pathlib import Path
from typing import Optional, Union, Annotated
from typing import Union, Any
import aiofiles
import pillow_avif # noqa 用于自动注册AVIF支持
from PIL import Image
from anyio import Path as AsyncPath
from fastapi import APIRouter, Body, Depends, HTTPException, Header, Request, Response
import tailer
from dotenv import set_key
from fastapi import APIRouter, HTTPException, Depends, Response
from fastapi.responses import StreamingResponse
from app import schemas
from app.chain.search import SearchChain
from app.chain.system import SystemChain
from app.core.cache import AsyncFileCache
from app.core.config import global_vars, settings
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.config import settings, global_vars
from app.core.module import ModuleManager
from app.core.security import verify_apitoken, verify_resource_token, verify_token
from app.core.security import verify_token
from app.db.models import User
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_superuser, get_current_active_superuser_async, \
get_current_active_user_async
from app.helper.mediaserver import MediaServerHelper
from app.db.userauth import get_current_active_superuser
from app.helper.message import MessageHelper
from app.helper.progress import ProgressHelper
from app.helper.rule import RuleHelper
from app.helper.sites import SitesHelper # noqa # noqa
from app.helper.subscribe import SubscribeHelper
from app.helper.system import SystemHelper
from app.log import logger
from app.helper.sites import SitesHelper
from app.scheduler import Scheduler
from app.schemas import ConfigChangeEventData
from app.schemas.types import SystemConfigKey, EventType
from app.utils.crypto import HashUtils
from app.utils.http import RequestUtils, AsyncRequestUtils
from app.utils.security import SecurityUtils
from app.utils.url import UrlUtils
from app.schemas.types import SystemConfigKey
from app.utils.http import RequestUtils
from app.utils.system import SystemUtils
from version import APP_VERSION
router = APIRouter()
async def fetch_image(
url: str,
proxy: bool = False,
use_cache: bool = False,
if_none_match: Optional[str] = None,
allowed_domains: Optional[set[str]] = None) -> Response:
"""
处理图片缓存逻辑支持HTTP缓存和磁盘缓存
"""
if not url:
raise HTTPException(status_code=404, detail="URL not provided")
if allowed_domains is None:
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS)
# 验证URL安全性
if not SecurityUtils.is_safe_url(url, allowed_domains):
raise HTTPException(status_code=404, detail="Unsafe URL")
# 缓存路径
sanitized_path = SecurityUtils.sanitize_url_path(url)
cache_path = Path("images") / sanitized_path
if not cache_path.suffix:
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
cache_path = cache_path.with_suffix(".jpg")
# 缓存对像,缓存过期时间为全局图片缓存天数
cache_backend = AsyncFileCache(base=settings.CACHE_PATH,
ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600)
if use_cache:
content = await cache_backend.get(cache_path.as_posix(), region="images")
if content:
# 检查 If-None-Match
etag = HashUtils.md5(content)
headers = RequestUtils.generate_cache_headers(etag, max_age=86400 * 7)
if if_none_match == etag:
return Response(status_code=304, headers=headers)
# 返回缓存图片
return Response(
content=content,
media_type=UrlUtils.get_mime_type(url, "image/jpeg"),
headers=headers
)
# 请求远程图片
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
proxies = settings.PROXY if proxy else None
response = await AsyncRequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer,
accept_type="image/avif,image/webp,image/apng,*/*").get_res(url=url)
if not response:
raise HTTPException(status_code=502, detail="Failed to fetch the image from the remote server")
# 验证下载的内容是否为有效图片
try:
content = response.content
Image.open(io.BytesIO(content)).verify()
except Exception as e:
logger.debug(f"Invalid image format for URL {url}: {e}")
raise HTTPException(status_code=502, detail="Invalid image format")
# 获取请求响应头
response_headers = response.headers
cache_control_header = response_headers.get("Cache-Control", "")
cache_directive, max_age = RequestUtils.parse_cache_control(cache_control_header)
# 保存缓存
if use_cache:
await cache_backend.set(cache_path.as_posix(), content, region="images")
logger.debug(f"Image cached at {cache_path.as_posix()}")
# 检查 If-None-Match
etag = HashUtils.md5(content)
if if_none_match == etag:
headers = RequestUtils.generate_cache_headers(etag, cache_directive, max_age)
return Response(status_code=304, headers=headers)
# 响应
headers = RequestUtils.generate_cache_headers(etag, cache_directive, max_age)
return Response(
content=content,
media_type=response_headers.get("Content-Type") or UrlUtils.get_mime_type(url, "image/jpeg"),
headers=headers
)
@router.get("/img/{proxy}", summary="图片代理")
async def proxy_img(
imgurl: str,
proxy: bool = False,
cache: bool = False,
if_none_match: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_resource_token)
) -> Response:
def get_img(imgurl: str, proxy: bool = False) -> Any:
"""
图片代理,可选是否使用代理服务器,支持 HTTP 缓存
通过图片代理使用代理服务器
"""
# 媒体服务器添加图片代理支持
hosts = [config.config.get("host") for config in MediaServerHelper().get_configs().values() if
config and config.config and config.config.get("host")]
allowed_domains = set(settings.SECURITY_IMAGE_DOMAINS) | set(hosts)
return await fetch_image(url=imgurl, proxy=proxy, use_cache=cache,
if_none_match=if_none_match, allowed_domains=allowed_domains)
if not imgurl:
return None
if proxy:
response = RequestUtils(ua=settings.USER_AGENT, proxies=settings.PROXY).get_res(url=imgurl)
else:
response = RequestUtils(ua=settings.USER_AGENT).get_res(url=imgurl)
if response:
return Response(content=response.content, media_type="image/jpeg")
return None
@router.get("/cache/image", summary="图片缓存")
async def cache_img(
url: str,
if_none_match: Annotated[str | None, Header()] = None,
_: schemas.TokenPayload = Depends(verify_resource_token)
) -> Response:
@router.get("/env", summary="查询系统环境变量", response_model=schemas.Response)
def get_env_setting(_: User = Depends(get_current_active_superuser)):
"""
本地缓存图片文件,支持 HTTP 缓存,如果启用全局图片缓存,则使用磁盘缓存
"""
# 如果没有启用全局图片缓存,则不使用磁盘缓存
proxy = "doubanio.com" not in url
return await fetch_image(url=url, proxy=proxy, use_cache=settings.GLOBAL_IMAGE_CACHE,
if_none_match=if_none_match)
@router.get("/global", summary="查询非敏感系统设置", response_model=schemas.Response)
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",
"COOKIECLOUD_KEY", "COOKIECLOUD_PASSWORD", "GITHUB_TOKEN", "REPO_GITHUB_TOKEN", "U115_APP_ID",
"ALIPAN_APP_ID", "TVDB_V4_API_KEY", "TVDB_V4_API_PIN"}
)
# 追加用户唯一ID和订阅分享管理权限
share_admin = SubscribeHelper().is_admin_user()
info.update({
"USER_UNIQUE_ID": SubscribeHelper().get_user_uuid(),
"SUBSCRIBE_SHARE_MANAGE": share_admin,
"WORKFLOW_SHARE_MANAGE": share_admin
})
return schemas.Response(success=True,
data=info)
@router.get("/env", summary="查询系统配置", response_model=schemas.Response)
async def get_env_setting(_: User = Depends(get_current_active_user_async)):
"""
查询系统环境变量,包括当前版本号(仅管理员)
查询系统环境变量,包括当前版本号
"""
info = settings.dict(
exclude={"SECRET_KEY", "RESOURCE_SECRET_KEY"}
exclude={"SECRET_KEY", "SUPERUSER_PASSWORD"}
)
info.update({
"VERSION": APP_VERSION,
@@ -210,71 +63,56 @@ async def get_env_setting(_: User = Depends(get_current_active_user_async)):
data=info)
@router.post("/env", summary="更新系统配置", response_model=schemas.Response)
async def set_env_setting(env: dict,
_: User = Depends(get_current_active_superuser_async)):
@router.post("/env", summary="更新系统环境变量", response_model=schemas.Response)
def set_env_setting(env: dict,
_: User = Depends(get_current_active_superuser)):
"""
更新系统环境变量(仅管理员)
更新系统环境变量
"""
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 v[0] is False}
if failed_updates:
return schemas.Response(
success=False,
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():
# 发送配置变更事件
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
key=key,
value=getattr(settings, key, None),
change_type="update"
))
return schemas.Response(
success=True,
message="所有配置项更新成功",
data={
"success_updates": success_updates
}
)
for k, v in env.items():
if k == "undefined":
continue
if hasattr(settings, k):
if v == "None":
v = None
setattr(settings, k, v)
if v is None:
v = ''
else:
v = str(v)
set_key(settings.CONFIG_PATH / "app.env", k, v)
return schemas.Response(success=True)
@router.get("/progress/{process_type}", summary="实时进度")
async def get_progress(request: Request, process_type: str, _: schemas.TokenPayload = Depends(verify_resource_token)):
def get_progress(process_type: str, token: str):
"""
实时获取处理进度返回格式为SSE
"""
progress = ProgressHelper(process_type)
if not token or not verify_token(token):
raise HTTPException(
status_code=403,
detail="认证失败!",
)
async def event_generator():
try:
while not global_vars.is_system_stopped:
if await request.is_disconnected():
break
detail = progress.get()
yield f"data: {json.dumps(detail)}\n\n"
await asyncio.sleep(0.5)
except asyncio.CancelledError:
return
progress = ProgressHelper()
def event_generator():
while True:
if global_vars.is_system_stopped():
break
detail = progress.get(process_type)
yield 'data: %s\n\n' % json.dumps(detail)
time.sleep(0.2)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/setting/{key}", summary="查询系统设置", response_model=schemas.Response)
async def get_setting(key: str,
_: User = Depends(get_current_active_user_async)):
def get_setting(key: str,
_: User = Depends(get_current_active_superuser)):
"""
查询系统设置(仅管理员)
查询系统设置
"""
if hasattr(settings, key):
value = getattr(settings, key)
@@ -286,173 +124,97 @@ async def get_setting(key: str,
@router.post("/setting/{key}", summary="更新系统设置", response_model=schemas.Response)
async def set_setting(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
_: User = Depends(get_current_active_superuser_async),
):
def set_setting(key: str, value: Union[list, dict, bool, int, str] = None,
_: User = Depends(get_current_active_superuser)):
"""
更新系统设置(仅管理员)
更新系统设置
"""
if hasattr(settings, key):
success, message = settings.update_setting(key=key, value=value)
if success:
# 发送配置变更事件
await eventmanager.async_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
success = await SystemConfigOper().async_set(key, value)
if success:
# 发送配置变更事件
await eventmanager.async_send_event(etype=EventType.ConfigChanged, data=ConfigChangeEventData(
key=key,
value=value,
change_type="update"
))
return schemas.Response(success=True)
if value == "None":
value = None
setattr(settings, key, value)
if value is None:
value = ''
else:
value = str(value)
set_key(settings.CONFIG_PATH / "app.env", key, value)
else:
return schemas.Response(success=False, message=f"配置项 '{key}' 不存在")
SystemConfigOper().set(key, value)
return schemas.Response(success=True)
@router.get("/message", summary="实时消息")
async def get_message(request: Request, role: Optional[str] = "system",
_: schemas.TokenPayload = Depends(verify_resource_token)):
def get_message(token: str, role: str = "system"):
"""
实时获取系统消息返回格式为SSE
"""
if not token or not verify_token(token):
raise HTTPException(
status_code=403,
detail="认证失败!",
)
message = MessageHelper()
async def event_generator():
try:
while not global_vars.is_system_stopped:
if await request.is_disconnected():
break
detail = message.get(role)
yield f"data: {detail or ''}\n\n"
await asyncio.sleep(3)
except asyncio.CancelledError:
return
def event_generator():
while True:
if global_vars.is_system_stopped():
break
detail = message.get(role)
yield 'data: %s\n\n' % (detail or '')
time.sleep(3)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.get("/logging", summary="实时日志")
async def get_logging(request: Request, length: Optional[int] = 50, logfile: Optional[str] = "moviepilot.log",
_: schemas.TokenPayload = Depends(verify_resource_token)):
def get_logging(token: str, length: int = 50, logfile: str = "moviepilot.log"):
"""
实时获取系统日志
length = -1 时, 返回text/plain
否则 返回格式SSE
"""
base_path = AsyncPath(settings.LOG_PATH)
log_path = base_path / logfile
if not token or not verify_token(token):
raise HTTPException(
status_code=403,
detail="认证失败!",
)
if not await SecurityUtils.async_is_safe_path(base_path=base_path, user_path=log_path, allowed_suffixes={".log"}):
raise HTTPException(status_code=404, detail="Not Found")
log_path = settings.LOG_PATH / logfile
if not await log_path.exists() or not await log_path.is_file():
raise HTTPException(status_code=404, detail="Not Found")
async def log_generator():
try:
# 使用固定大小的双向队列来限制内存使用
lines_queue = deque(maxlen=max(length, 50))
# 获取文件大小
file_stat = await log_path.stat()
file_size = file_stat.st_size
# 读取历史日志
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f:
# 优化大文件读取策略
if file_size > 100 * 1024:
# 只读取最后100KB的内容
bytes_to_read = min(file_size, 100 * 1024)
position = file_size - bytes_to_read
await f.seek(position)
content = await f.read()
# 找到第一个完整的行
first_newline = content.find('\n')
if first_newline != -1:
content = content[first_newline + 1:]
else:
# 小文件直接读取全部内容
content = await f.read()
# 按行分割并添加到队列,只保留非空行
lines = [line.strip() for line in content.splitlines() if line.strip()]
# 只取最后N行
for line in lines[-max(length, 50):]:
lines_queue.append(line)
# 输出历史日志
for line in lines_queue:
yield f"data: {line}\n\n"
# 实时监听新日志
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as f:
# 移动文件指针到文件末尾,继续监听新增内容
await f.seek(0, 2)
# 记录初始文件大小
initial_stat = await log_path.stat()
initial_size = initial_stat.st_size
# 实时监听新日志,使用更短的轮询间隔
while not global_vars.is_system_stopped:
if await request.is_disconnected():
break
# 检查文件是否有新内容
current_stat = await log_path.stat()
current_size = current_stat.st_size
if current_size > initial_size:
# 文件有新内容,读取新行
line = await f.readline()
if line:
line = line.strip()
if line:
yield f"data: {line}\n\n"
initial_size = current_size
else:
# 没有新内容,短暂等待
await asyncio.sleep(0.5)
except asyncio.CancelledError:
return
except Exception as err:
logger.error(f"日志读取异常: {err}")
yield f"data: 日志读取异常: {err}\n\n"
def log_generator():
# 读取文件末尾50行不使用tailer模块
with open(log_path, 'r', encoding='utf-8') as f:
for line in f.readlines()[-max(length, 50):]:
yield 'data: %s\n\n' % line
while True:
if global_vars.is_system_stopped():
break
for t in tailer.follow(open(log_path, 'r', encoding='utf-8')):
yield 'data: %s\n\n' % (t or '')
time.sleep(1)
# 根据length参数返回不同的响应
if length == -1:
# 返回全部日志作为文本响应
if not await log_path.exists():
if not log_path.exists():
return Response(content="日志文件不存在!", media_type="text/plain")
try:
# 使用 aiofiles 异步读取文件
async with aiofiles.open(log_path, mode="r", encoding="utf-8", errors="ignore") as file:
text = await file.read()
# 倒序输出
text = "\n".join(text.split("\n")[::-1])
return Response(content=text, media_type="text/plain")
except Exception as e:
return Response(content=f"读取日志文件失败: {e}", media_type="text/plain")
with open(log_path, 'r', encoding='utf-8') as file:
text = file.read()
# 倒序输出
text = '\n'.join(text.split('\n')[::-1])
return Response(content=text, media_type="text/plain")
else:
# 返回SSE流响应
return StreamingResponse(log_generator(), media_type="text/event-stream")
@router.get("/versions", summary="查询Github所有Release版本", response_model=schemas.Response)
async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
"""
查询Github所有Release版本
"""
version_res = await AsyncRequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
f"https://api.github.com/repos/jxxghp/MoviePilot/releases")
if version_res:
ver_json = version_res.json()
@@ -461,10 +223,10 @@ async def latest_version(_: schemas.TokenPayload = Depends(verify_token)):
return schemas.Response(success=False)
@router.get("/ruletest", summary="过滤规则测试", response_model=schemas.Response)
@router.get("/ruletest", summary="优先级规则测试", response_model=schemas.Response)
def ruletest(title: str,
rulegroup_name: str,
subtitle: Optional[str] = None,
subtitle: str = None,
ruletype: str = None,
_: schemas.TokenPayload = Depends(verify_token)):
"""
过滤规则测试,规则类型 1-订阅2-洗版3-搜索
@@ -473,101 +235,50 @@ def ruletest(title: str,
title=title,
description=subtitle,
)
# 查询规则组详情
rulegroup = RuleHelper().get_rule_group(rulegroup_name)
if not rulegroup:
return schemas.Response(success=False, message=f"过滤规则组 {rulegroup_name} 不存在!")
# 根据标题查询媒体信息
media_info = SearchChain().recognize_media(MetaInfo(title=title, subtitle=subtitle))
if not media_info:
return schemas.Response(success=False, message="未识别到媒体信息!")
if ruletype == "2":
rule_string = SystemConfigOper().get(SystemConfigKey.BestVersionFilterRules)
elif ruletype == "3":
rule_string = SystemConfigOper().get(SystemConfigKey.SearchFilterRules)
else:
rule_string = SystemConfigOper().get(SystemConfigKey.SubscribeFilterRules)
if not rule_string:
return schemas.Response(success=False, message="优先级规则未设置!")
# 过滤
result = SearchChain().filter_torrents(rule_groups=[rulegroup.name],
torrent_list=[torrent], mediainfo=media_info)
result = SearchChain().filter_torrents(rule_string=rule_string,
torrent_list=[torrent])
if not result:
return schemas.Response(success=False, message="不符合过滤规则!")
return schemas.Response(success=False, message="不符合优先级规则!")
return schemas.Response(success=True, data={
"priority": 100 - result[0].pri_order + 1
})
@router.get("/nettest", summary="测试网络连通性")
async def nettest(
url: str,
proxy: bool,
include: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
):
def nettest(url: str,
proxy: bool,
_: schemas.TokenPayload = Depends(verify_token)):
"""
测试网络连通性
"""
# 记录开始的毫秒数
start_time = datetime.now()
headers = None
# 当前使用的加速代理
proxy_name = ""
if "github" in url:
# 这是github的连通性测试
headers = settings.GITHUB_HEADERS
if "{GITHUB_PROXY}" in url:
url = url.replace(
"{GITHUB_PROXY}", UrlUtils.standardize_base_url(settings.GITHUB_PROXY or "")
)
if settings.GITHUB_PROXY:
proxy_name = "Github加速代理"
if "{PIP_PROXY}" in url:
url = url.replace(
"{PIP_PROXY}",
UrlUtils.standardize_base_url(
settings.PIP_PROXY or "https://pypi.org/simple/"
),
)
if settings.PIP_PROXY:
proxy_name = "PIP加速代理"
url = url.replace("{TMDBAPIKEY}", settings.TMDB_API_KEY)
result = await AsyncRequestUtils(
proxies=settings.PROXY if proxy else None,
headers=headers,
timeout=10,
ua=settings.NORMAL_USER_AGENT,
).get_res(url)
result = RequestUtils(proxies=settings.PROXY if proxy else None,
ua=settings.USER_AGENT).get_res(url)
# 计时结束的毫秒数
end_time = datetime.now()
time = round((end_time - start_time).total_seconds() * 1000)
# 计算相关秒数
if result is None:
return schemas.Response(
success=False, message=f"{proxy_name}无法连接", 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}")
if proxy_name:
message = f"{proxy_name}已失效,请检查配置"
else:
message = f"无效响应,不匹配 {include}"
return schemas.Response(
success=False,
message=message,
data={"time": time},
)
return schemas.Response(success=True, data={"time": time})
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)
})
else:
if proxy_name:
# 加速代理失败
message = f"{proxy_name}已失效,错误码:{result.status_code}"
else:
message = f"错误码:{result.status_code}"
if "github" in url:
# 非加速代理访问github
if result.status_code == 401:
message = "Github Token已失效请检查配置"
elif result.status_code in {403, 429}:
message = "触发限流请配置Github Token"
return schemas.Response(success=False, message=message, data={"time": time})
return schemas.Response(success=False, message="网络连接失败!")
@router.get("/modulelist", summary="查询已加载的模块ID列表", response_model=schemas.Response)
@@ -596,37 +307,34 @@ def moduletest(moduleid: str, _: schemas.TokenPayload = Depends(verify_token)):
@router.get("/restart", summary="重启系统", response_model=schemas.Response)
def restart_system(_: User = Depends(get_current_active_superuser)):
"""
重启系统(仅管理员)
重启系统
"""
if not SystemHelper.can_restart():
if not SystemUtils.can_restart():
return schemas.Response(success=False, message="当前运行环境不支持重启操作!")
# 标识停止事件
global_vars.stop_system()
# 执行重启
ret, msg = SystemHelper.restart()
ret, msg = SystemUtils.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)):
"""
重新加载模块
"""
ModuleManager().reload()
Scheduler().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)):
def execute_command(jobid: str,
_: User = Depends(get_current_active_superuser)):
"""
执行命令(仅管理员)
执行命令
"""
if not jobid:
return schemas.Response(success=False, message="命令不能为空!")
Scheduler().start(jobid)
return schemas.Response(success=True)
@router.get("/runscheduler2", summary="运行服务API_TOKEN", response_model=schemas.Response)
def run_scheduler2(jobid: str,
_: Annotated[str, Depends(verify_apitoken)]):
"""
执行命令API_TOKEN认证
"""
if not jobid:
return schemas.Response(success=False, message="命令不能为空!")
Scheduler().start(jobid)
return schemas.Response(success=True)

View File

@@ -1,4 +1,4 @@
from typing import List, Any, Optional
from typing import List, Any
from fastapi import APIRouter, Depends
@@ -11,28 +11,28 @@ router = APIRouter()
@router.get("/seasons/{tmdbid}", summary="TMDB所有季", response_model=List[schemas.TmdbSeason])
async def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_seasons(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询themoviedb所有季信息
"""
seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid)
seasons_info = TmdbChain().tmdb_seasons(tmdbid=tmdbid)
if seasons_info:
return seasons_info
return []
@router.get("/similar/{tmdbid}/{type_name}", summary="类似电影/电视剧", response_model=List[schemas.MediaInfo])
async def tmdb_similar(tmdbid: int,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_similar(tmdbid: int,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询类似电影/电视剧type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
medias = await TmdbChain().async_movie_similar(tmdbid=tmdbid)
medias = TmdbChain().movie_similar(tmdbid=tmdbid)
elif mediatype == MediaType.TV:
medias = await TmdbChain().async_tv_similar(tmdbid=tmdbid)
medias = TmdbChain().tv_similar(tmdbid=tmdbid)
else:
return []
if medias:
@@ -41,17 +41,17 @@ async def tmdb_similar(tmdbid: int,
@router.get("/recommend/{tmdbid}/{type_name}", summary="推荐电影/电视剧", response_model=List[schemas.MediaInfo])
async def tmdb_recommend(tmdbid: int,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_recommend(tmdbid: int,
type_name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询推荐电影/电视剧type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
medias = await TmdbChain().async_movie_recommend(tmdbid=tmdbid)
medias = TmdbChain().movie_recommend(tmdbid=tmdbid)
elif mediatype == MediaType.TV:
medias = await TmdbChain().async_tv_recommend(tmdbid=tmdbid)
medias = TmdbChain().tv_recommend(tmdbid=tmdbid)
else:
return []
if medias:
@@ -59,64 +59,100 @@ async def tmdb_recommend(tmdbid: int,
return []
@router.get("/collection/{collection_id}", summary="系列合集详情", response_model=List[schemas.MediaInfo])
async def tmdb_collection(collection_id: int,
page: Optional[int] = 1,
count: Optional[int] = 20,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据合集ID查询合集详情
"""
medias = await TmdbChain().async_tmdb_collection(collection_id=collection_id)
if medias:
return [media.to_dict() for media in medias][(page - 1) * count:page * count]
return []
@router.get("/credits/{tmdbid}/{type_name}", summary="演员阵容", response_model=List[schemas.MediaPerson])
async def tmdb_credits(tmdbid: int,
type_name: str,
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_credits(tmdbid: int,
type_name: str,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询演员阵容type_name: 电影/电视剧
"""
mediatype = MediaType(type_name)
if mediatype == MediaType.MOVIE:
persons = await TmdbChain().async_movie_credits(tmdbid=tmdbid, page=page)
persons = TmdbChain().movie_credits(tmdbid=tmdbid, page=page)
elif mediatype == MediaType.TV:
persons = await TmdbChain().async_tv_credits(tmdbid=tmdbid, page=page)
persons = TmdbChain().tv_credits(tmdbid=tmdbid, page=page)
else:
return []
return persons or []
@router.get("/person/{person_id}", summary="人物详情", response_model=schemas.MediaPerson)
async def tmdb_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_person(person_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物详情
"""
return await TmdbChain().async_person_detail(person_id=person_id)
return TmdbChain().person_detail(person_id=person_id)
@router.get("/person/credits/{person_id}", summary="人物参演作品", response_model=List[schemas.MediaInfo])
async def tmdb_person_credits(person_id: int,
page: Optional[int] = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_person_credits(person_id: int,
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据人物ID查询人物参演作品
"""
medias = await TmdbChain().async_person_credits(person_id=person_id, page=page)
medias = TmdbChain().person_credits(person_id=person_id, page=page)
if medias:
return [media.to_dict() for media in medias]
return []
@router.get("/movies", summary="TMDB电影", response_model=List[schemas.MediaInfo])
def tmdb_movies(sort_by: str = "popularity.desc",
with_genres: str = "",
with_original_language: str = "",
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB电影信息
"""
movies = TmdbChain().tmdb_discover(mtype=MediaType.MOVIE,
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
if not movies:
return []
return [movie.to_dict() for movie in movies]
@router.get("/tvs", summary="TMDB剧集", response_model=List[schemas.MediaInfo])
def tmdb_tvs(sort_by: str = "popularity.desc",
with_genres: str = "",
with_original_language: str = "",
page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB剧集信息
"""
tvs = TmdbChain().tmdb_discover(mtype=MediaType.TV,
sort_by=sort_by,
with_genres=with_genres,
with_original_language=with_original_language,
page=page)
if not tvs:
return []
return [tv.to_dict() for tv in tvs]
@router.get("/trending", summary="TMDB流行趋势", response_model=List[schemas.MediaInfo])
def tmdb_trending(page: int = 1,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
浏览TMDB剧集信息
"""
infos = TmdbChain().tmdb_trending(page=page)
if not infos:
return []
return [info.to_dict() for info in infos]
@router.get("/{tmdbid}/{season}", summary="TMDB季所有集", response_model=List[schemas.TmdbEpisode])
async def tmdb_season_episodes(tmdbid: int, season: int, episode_group: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
def tmdb_season_episodes(tmdbid: int, season: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
根据TMDBID查询某季的所有信信息
"""
return await TmdbChain().async_tmdb_episodes(tmdbid=tmdbid, season=season, episode_group=episode_group)
return TmdbChain().tmdb_episodes(tmdbid=tmdbid, season=season)

View File

@@ -1,197 +0,0 @@
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, get_current_active_superuser_async
from app.utils.crypto import HashUtils
router = APIRouter()
@router.get("/cache", summary="获取种子缓存", response_model=schemas.Response)
async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
"""
获取当前种子缓存数据
"""
torrents_chain = TorrentsChain()
# 获取spider和rss两种缓存
if settings.SUBSCRIBE_MODE == "rss":
cache_info = await torrents_chain.async_get_torrents("rss")
else:
cache_info = await torrents_chain.async_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)
async def delete_cache(domain: str, torrent_hash: str, _: User = Depends(get_current_active_superuser_async)):
"""
删除指定的种子缓存
:param domain: 站点域名
:param torrent_hash: 种子hash使用title+description的md5
:param _: 当前用户,必须是超级用户
"""
torrents_chain = TorrentsChain()
try:
# 获取当前缓存
cache_data = await torrents_chain.async_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="未找到指定的种子")
# 保存更新后的缓存
await torrents_chain.async_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)
async def clear_cache(_: User = Depends(get_current_active_superuser_async)):
"""
清理所有种子缓存
"""
torrents_chain = TorrentsChain()
try:
await torrents_chain.async_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)
async def reidentify_cache(domain: str, torrent_hash: str,
tmdbid: Optional[int] = None, doubanid: Optional[str] = None,
_: User = Depends(get_current_active_superuser_async)):
"""
重新识别指定的种子
: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 = await torrents_chain.async_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 = await media_chain.async_recognize_media(meta=meta, tmdbid=tmdbid, doubanid=doubanid)
else:
# 自动重新识别
mediainfo = await media_chain.async_recognize_by_meta(meta)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
else:
# 清理多余数据
mediainfo.clear()
# 更新上下文中的媒体信息
target_context.media_info = mediainfo
# 保存更新后的缓存
await torrents_chain.async_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)}")

View File

@@ -1,22 +1,17 @@
from pathlib import Path
from typing import Any, List, Annotated, Optional
from typing import Any
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.storage import StorageChain
from app.chain.transfer import TransferChain
from app.core.config import settings, global_vars
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_apitoken
from app.db import get_db
from app.db.models import User
from app.db.models.transferhistory import TransferHistory
from app.db.user_oper import get_current_active_superuser
from app.helper.directory import DirectoryHelper
from app.schemas import MediaType, FileItem, ManualTransferItem
from app.schemas import MediaType
router = APIRouter()
@@ -38,19 +33,11 @@ def query_name(path: str, filetype: str,
if not new_path:
return schemas.Response(success=False, message="未识别到新名称")
if filetype == "dir":
media_path = DirectoryHelper.get_media_root_path(
rename_format=settings.RENAME_FORMAT(mediainfo.type),
rename_path=Path(new_path),
)
if media_path:
new_name = media_path.name
parents = Path(new_path).parents
if len(parents) > 2:
new_name = parents[1].name
else:
# fallback
parents = Path(new_path).parents
if len(parents) > 2:
new_name = parents[1].name
else:
new_name = parents[0].name
new_name = parents[0].name
else:
new_name = Path(new_path).name
return schemas.Response(success=True, data={
@@ -58,116 +45,103 @@ def query_name(path: str, filetype: str,
})
@router.get("/queue", summary="查询整理队列", response_model=List[schemas.TransferJob])
async def query_queue(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询整理队列
:param _: Token校验
"""
return TransferChain().get_queue_tasks()
@router.delete("/queue", summary="从整理队列中删除任务", response_model=schemas.Response)
async def remove_queue(fileitem: schemas.FileItem, _: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询整理队列
:param fileitem: 文件项
:param _: Token校验
"""
TransferChain().remove_from_queue(fileitem)
# 取消整理
global_vars.stop_transfer(fileitem.path)
return schemas.Response(success=True)
@router.post("/manual", summary="手动转移", response_model=schemas.Response)
def manual_transfer(transer_item: ManualTransferItem,
background: Optional[bool] = False,
def manual_transfer(storage: str = "local",
path: str = None,
drive_id: str = None,
fileid: str = None,
filetype: str = None,
logid: int = None,
target: str = None,
tmdbid: int = None,
doubanid: str = None,
type_name: str = None,
season: int = None,
transfer_type: str = None,
episode_format: str = None,
episode_detail: str = None,
episode_part: str = None,
episode_offset: int = 0,
min_filesize: int = 0,
scrape: bool = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_active_superuser)) -> Any:
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
手动转移,文件或历史记录,支持自定义剧集识别格式
:param transer_item: 手工整理项
:param background: 后台运行
:param storage: 存储类型local/aliyun/u115
:param path: 转移路径或文件
:param drive_id: 云盘ID网盘等
:param fileid: 文件ID网盘等
:param filetype: 文件类型dir/file
:param logid: 转移历史记录ID
:param target: 目标路径
:param type_name: 媒体类型、电影/电视剧
:param tmdbid: tmdbid
:param doubanid: 豆瓣ID
:param season: 剧集季号
:param transfer_type: 转移类型move/copy 等
:param episode_format: 剧集识别格式
:param episode_detail: 剧集识别详细信息
:param episode_part: 剧集识别分集信息
:param episode_offset: 剧集识别偏移量
:param min_filesize: 最小文件大小(MB)
:param scrape: 是否刮削元数据
:param db: 数据库
:param _: Token校验
"""
force = False
target_path = Path(transer_item.target_path) if transer_item.target_path else None
if transer_item.logid:
target = Path(target) if target else None
transfer = TransferChain()
if logid:
# 查询历史记录
history: TransferHistory = TransferHistory.get(db, transer_item.logid)
history: TransferHistory = TransferHistory.get(db, logid)
if not history:
return schemas.Response(success=False, message=f"整理记录不存在ID{transer_item.logid}")
return schemas.Response(success=False, message=f"历史记录不存在ID{logid}")
# 强制转移
force = True
if history.status and ("move" in history.mode):
# 重新整理成功的转移,则使用成功的 dest 做 in_path
src_fileitem = FileItem(**history.dest_fileitem)
in_path = Path(history.dest)
else:
# 源路径
src_fileitem = FileItem(**history.src_fileitem)
in_path = Path(history.src)
# 目的路径
if history.dest_fileitem:
if history.dest and str(history.dest) != "None":
# 删除旧的已整理文件
dest_fileitem = FileItem(**history.dest_fileitem)
state = StorageChain().delete_media_file(dest_fileitem)
if not state:
return schemas.Response(success=False, message=f"{dest_fileitem.path} 删除失败")
# 从历史数据获取信息
if transer_item.from_history:
transer_item.type_name = history.type if history.type else transer_item.type_name
transer_item.tmdbid = int(history.tmdbid) if history.tmdbid else transer_item.tmdbid
transer_item.doubanid = str(history.doubanid) if history.doubanid else transer_item.doubanid
transer_item.season = int(str(history.seasons).replace("S", "")) if history.seasons else transer_item.season
if history.episodes:
if "-" in str(history.episodes):
# E01-E03多集合并
episode_start, episode_end = str(history.episodes).split("-")
episode_list: list[int] = []
for i in range(int(episode_start.replace("E", "")), int(episode_end.replace("E", "")) + 1):
episode_list.append(i)
transer_item.episode_detail = ",".join(str(e) for e in episode_list)
else:
# E01单集
transer_item.episode_detail = str(history.episodes).replace("E", "")
elif transer_item.fileitem:
src_fileitem = transer_item.fileitem
transfer.delete_files(Path(history.dest))
elif path:
in_path = Path(path)
else:
return schemas.Response(success=False, message=f"缺少参数")
return schemas.Response(success=False, message=f"缺少参数path/logid")
# 类型
mtype = MediaType(transer_item.type_name) if transer_item.type_name else None
mtype = MediaType(type_name) if type_name else None
# 自定义格式
epformat = None
if transer_item.episode_offset or transer_item.episode_part \
or transer_item.episode_detail or transer_item.episode_format:
if episode_offset or episode_part or episode_detail or episode_format:
epformat = schemas.EpisodeFormat(
format=transer_item.episode_format,
detail=transer_item.episode_detail,
part=transer_item.episode_part,
offset=transer_item.episode_offset,
format=episode_format,
detail=episode_detail,
part=episode_part,
offset=episode_offset,
)
# 开始转移
state, errormsg = TransferChain().manual_transfer(
fileitem=src_fileitem,
target_storage=transer_item.target_storage,
target_path=target_path,
tmdbid=transer_item.tmdbid,
doubanid=transer_item.doubanid,
state, errormsg = transfer.manual_transfer(
storage=storage,
in_path=in_path,
drive_id=drive_id,
fileid=fileid,
filetype=filetype,
target=target,
tmdbid=tmdbid,
doubanid=doubanid,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
transfer_type=transer_item.transfer_type,
season=season,
transfer_type=transfer_type,
epformat=epformat,
min_filesize=transer_item.min_filesize,
scrape=transer_item.scrape,
library_type_folder=transer_item.library_type_folder,
library_category_folder=transer_item.library_category_folder,
force=force,
background=background
min_filesize=min_filesize,
scrape=scrape,
force=force
)
# 失败
if not state:
@@ -179,7 +153,7 @@ def manual_transfer(transer_item: ManualTransferItem,
@router.get("/now", summary="立即执行下载器文件整理", response_model=schemas.Response)
def now(_: Annotated[str, Depends(verify_apitoken)]) -> Any:
def now(_: str = Depends(verify_apitoken)) -> Any:
"""
立即执行下载器文件整理 API_TOKEN认证?token=xxx
"""

213
app/api/endpoints/u115.py Normal file
View File

@@ -0,0 +1,213 @@
from pathlib import Path
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from starlette.responses import Response
from app import schemas
from app.chain.transfer import TransferChain
from app.core.config import settings
from app.core.metainfo import MetaInfoPath
from app.core.security import verify_token, verify_uri_token
from app.helper.progress import ProgressHelper
from app.helper.u115 import U115Helper
from app.schemas.types import ProgressKey
from app.utils.http import RequestUtils
router = APIRouter()
@router.get("/qrcode", summary="生成二维码内容", response_model=schemas.Response)
def qrcode(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
生成二维码
"""
qrcode_data = U115Helper().generate_qrcode()
if qrcode_data:
return schemas.Response(success=True, data={
'codeContent': qrcode_data
})
return schemas.Response(success=False)
@router.get("/check", summary="二维码登录确认", response_model=schemas.Response)
def check(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
二维码登录确认
"""
data, errmsg = U115Helper().check_login()
if data:
return schemas.Response(success=True, data=data)
return schemas.Response(success=False, message=errmsg)
@router.get("/storage", summary="查询存储空间信息", response_model=schemas.Response)
def storage(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询存储空间信息
"""
storage_info = U115Helper().storage()
if storage_info:
return schemas.Response(success=True, data={
"total": storage_info[0],
"used": storage_info[1]
})
return schemas.Response(success=False)
@router.post("/list", summary="所有目录和文件115网盘", response_model=List[schemas.FileItem])
def list_115(fileitem: schemas.FileItem,
sort: str = 'updated_at',
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询当前目录下所有目录和文件
:param fileitem: 文件项
:param sort: 排序方式name:按名称排序time:按修改时间排序
:param _: token
:return: 所有目录和文件
"""
if not fileitem.fileid:
return []
if not fileitem.path:
path = "/"
else:
path = fileitem.path
if fileitem.fileid == "root":
fileid = "0"
else:
fileid = fileitem.fileid
if fileitem.type == "file":
name = Path(path).name
suffix = Path(name).suffix[1:]
return [schemas.FileItem(
fileid=fileid,
type="file",
path=path.rstrip('/'),
name=name,
extension=suffix,
pickcode=fileitem.pickcode
)]
file_list = U115Helper().list(parent_file_id=fileid, path=path)
if sort == "name":
file_list.sort(key=lambda x: x.name)
else:
file_list.sort(key=lambda x: x.modify_time, reverse=True)
return file_list
@router.post("/mkdir", summary="创建目录115网盘", response_model=schemas.Response)
def mkdir_115(fileitem: schemas.FileItem,
name: str,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建目录
"""
if not fileitem.fileid or not name:
return schemas.Response(success=False)
result = U115Helper().create_folder(parent_file_id=fileitem.fileid, name=name, path=fileitem.path)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.post("/delete", summary="删除文件或目录115网盘", response_model=schemas.Response)
def delete_115(fileitem: schemas.FileItem,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除文件或目录
"""
if not fileitem.fileid:
return schemas.Response(success=False)
result = U115Helper().delete(fileitem.fileid)
if result:
return schemas.Response(success=True)
return schemas.Response(success=False)
@router.get("/download", summary="下载文件115网盘")
def download_115(pickcode: str,
_: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
下载文件或目录
"""
if not pickcode:
return schemas.Response(success=False)
ticket = U115Helper().download(pickcode)
if ticket:
# 请求数据,并以文件流的方式返回
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
if res:
return Response(content=res.content, media_type="application/octet-stream")
return schemas.Response(success=False)
@router.post("/rename", summary="重命名文件或目录115网盘", response_model=schemas.Response)
def rename_115(fileitem: schemas.FileItem,
new_name: str,
recursive: bool = False,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重命名文件或目录
"""
if not fileitem.fileid or not new_name:
return schemas.Response(success=False)
result = U115Helper().rename(fileitem.fileid, 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] = list_115(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_115(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)
@router.get("/image", summary="读取图片115网盘")
def image_115(pickcode: str, _: schemas.TokenPayload = Depends(verify_uri_token)) -> Any:
"""
读取图片
"""
if not pickcode:
return schemas.Response(success=False)
ticket = U115Helper().download(pickcode)
if ticket:
# 请求数据获取内容编码为图片base64返回
res = RequestUtils(headers=ticket.headers).get_res(ticket.url)
if res:
content_type = res.headers.get("Content-Type")
return Response(content=res.content, media_type=content_type)
raise HTTPException(status_code=500, detail="下载图片出错")

View File

@@ -1,16 +1,15 @@
import base64
import re
from typing import Annotated, Any, List, Union
from typing import Any, List, Union
from fastapi import APIRouter, Body, Depends, HTTPException, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from app import schemas
from app.core.security import get_password_hash
from app.db import get_async_db
from app.db import get_db
from app.db.models.user import User
from app.db.user_oper import get_current_active_superuser_async, \
get_current_active_user_async, get_current_active_user
from app.db.userauth import get_current_active_superuser, get_current_active_user
from app.db.userconfig_oper import UserConfigOper
from app.utils.otp import OtpUtils
@@ -18,43 +17,45 @@ router = APIRouter()
@router.get("/", summary="所有用户", response_model=List[schemas.User])
async def list_users(
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_superuser_async),
def read_users(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
查询用户列表
"""
return await current_user.async_list(db)
users = current_user.list(db)
return users
@router.post("/", summary="新增用户", response_model=schemas.Response)
async def create_user(
def create_user(
*,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
current_user: User = Depends(get_current_active_superuser_async),
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
新增用户
"""
user = await current_user.async_get_by_name(db, name=user_in.name)
user = current_user.get_by_name(db, name=user_in.name)
if user:
return schemas.Response(success=False, message="用户已存在")
user_info = user_in.dict()
if user_info.get("password"):
user_info["hashed_password"] = get_password_hash(user_info["password"])
user_info.pop("password")
user = await User(**user_info).async_create(db)
return schemas.Response(success=True if user else False)
user = User(**user_info)
user.create(db)
return schemas.Response(success=True)
@router.put("/", summary="更新用户", response_model=schemas.Response)
async def update_user(
def update_user(
*,
db: AsyncSession = Depends(get_async_db),
user_in: schemas.UserUpdate,
current_user: User = Depends(get_current_active_superuser_async),
db: Session = Depends(get_db),
user_in: schemas.UserCreate,
_: User = Depends(get_current_active_superuser),
) -> Any:
"""
更新用户
@@ -68,24 +69,16 @@ async def update_user(
message="密码需要同时包含字母、数字、特殊字符中的至少两项且长度大于6位")
user_info["hashed_password"] = get_password_hash(user_info["password"])
user_info.pop("password")
user = await current_user.async_get_by_id(db, user_id=user_info["id"])
user_name = user_info.get("name")
if not user_name:
return schemas.Response(success=False, message="用户名不能为空")
# 新用户名去重
users = await current_user.async_list(db)
for u in users:
if u.name == user_name and u.id != user_info["id"]:
return schemas.Response(success=False, message="用户名已被使用")
user = User.get_by_name(db, name=user_info["name"])
if not user:
return schemas.Response(success=False, message="用户不存在")
await user.async_update(db, user_info)
user.update(db, user_info)
return schemas.Response(success=True)
@router.get("/current", summary="当前登录用户信息", response_model=schemas.User)
async def read_current_user(
current_user: User = Depends(get_current_active_user_async)
def read_current_user(
current_user: User = Depends(get_current_active_user)
) -> Any:
"""
当前登录用户信息
@@ -94,18 +87,18 @@ async def read_current_user(
@router.post("/avatar/{user_id}", summary="上传用户头像", response_model=schemas.Response)
async def upload_avatar(user_id: int, db: AsyncSession = Depends(get_async_db), file: UploadFile = File(...),
_: User = Depends(get_current_active_user_async)):
def upload_avatar(user_id: int, db: Session = Depends(get_db), file: UploadFile = File(...),
_: User = Depends(get_current_active_user)):
"""
上传用户头像
"""
# 将文件转换为Base64
file_base64 = base64.b64encode(file.file.read())
# 更新到用户表
user = await User.async_get(db, user_id)
user = User.get(db, user_id)
if not user:
return schemas.Response(success=False, message="用户不存在")
await user.async_update(db, {
user.update(db, {
"avatar": f"data:image/ico;base64,{file_base64}"
})
return schemas.Response(success=True, message=file.filename)
@@ -120,33 +113,33 @@ def otp_generate(
@router.post('/otp/judge', summary='判断otp验证是否通过', response_model=schemas.Response)
async def otp_judge(
def otp_judge(
data: dict,
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async)
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
) -> Any:
uri = data.get("uri")
otp_password = data.get("otpPassword")
if not OtpUtils.is_legal(uri, otp_password):
return schemas.Response(success=False, message="验证码错误")
await current_user.async_update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
current_user.update_otp_by_name(db, current_user.name, True, OtpUtils.get_secret(uri))
return schemas.Response(success=True)
@router.post('/otp/disable', summary='关闭当前用户的otp验证', response_model=schemas.Response)
async def otp_disable(
db: AsyncSession = Depends(get_async_db),
current_user: User = Depends(get_current_active_user_async)
def otp_disable(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
) -> Any:
await current_user.async_update_otp_by_name(db, current_user.name, False, "")
current_user.update_otp_by_name(db, current_user.name, False, "")
return schemas.Response(success=True)
@router.get('/otp/{userid}', summary='判断当前用户是否开启otp验证', response_model=schemas.Response)
async def otp_enable(userid: str, db: AsyncSession = Depends(get_async_db)) -> Any:
user: User = await User.async_get_by_name(db, userid)
def otp_enable(userid: str, db: Session = Depends(get_db)) -> Any:
user: User = User.get_by_name(db, userid)
if not user:
return schemas.Response(success=False)
return schemas.Response(success=False, message="用户不存在")
return schemas.Response(success=user.is_otp)
@@ -163,11 +156,8 @@ def get_config(key: str,
@router.post("/config/{key}", summary="更新用户配置", response_model=schemas.Response)
def set_config(
key: str,
value: Annotated[Union[list, dict, bool, int, str] | None, Body()] = None,
current_user: User = Depends(get_current_active_user),
):
def set_config(key: str, value: Union[list, dict, bool, int, str] = None,
current_user: User = Depends(get_current_active_user)):
"""
更新用户配置
"""
@@ -175,50 +165,33 @@ def set_config(
return schemas.Response(success=True)
@router.delete("/id/{user_id}", summary="删除用户", response_model=schemas.Response)
async def delete_user_by_id(
@router.delete("/{user_name}", summary="删除用户", response_model=schemas.Response)
def delete_user(
*,
db: AsyncSession = Depends(get_async_db),
user_id: int,
current_user: User = Depends(get_current_active_superuser_async),
) -> Any:
"""
通过唯一ID删除用户
"""
user = await current_user.async_get_by_id(db, user_id=user_id)
if not user:
return schemas.Response(success=False, message="用户不存在")
await current_user.async_delete(db, user_id)
return schemas.Response(success=True)
@router.delete("/name/{user_name}", summary="删除用户", response_model=schemas.Response)
async def delete_user_by_name(
*,
db: AsyncSession = Depends(get_async_db),
db: Session = Depends(get_db),
user_name: str,
current_user: User = Depends(get_current_active_superuser_async),
current_user: User = Depends(get_current_active_superuser),
) -> Any:
"""
通过用户名删除用户
删除用户
"""
user = await current_user.async_get_by_name(db, name=user_name)
user = current_user.get_by_name(db, name=user_name)
if not user:
return schemas.Response(success=False, message="用户不存在")
await current_user.async_delete(db, user.id)
user.delete_by_name(db, user_name)
return schemas.Response(success=True)
@router.get("/{username}", summary="用户详情", response_model=schemas.User)
async def read_user_by_name(
username: str,
current_user: User = Depends(get_current_active_user_async),
db: AsyncSession = Depends(get_async_db),
@router.get("/{user_id}", summary="用户详情", response_model=schemas.User)
def read_user_by_id(
user_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db),
) -> Any:
"""
查询用户详情
"""
user = await current_user.async_get_by_name(db, name=username)
user = current_user.get(db, rid=user_id)
if not user:
raise HTTPException(
status_code=404,
@@ -226,7 +199,7 @@ async def read_user_by_name(
)
if user == current_user:
return user
if not current_user.is_superuser:
if not user.is_superuser:
raise HTTPException(
status_code=400,
detail="用户权限不足"

View File

@@ -1,4 +1,4 @@
from typing import Any, Annotated
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Request, Depends
@@ -19,10 +19,10 @@ def start_webhook_chain(body: Any, form: Any, args: Any):
@router.post("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
request: Request,
_: Annotated[str, Depends(verify_apitoken)]
_: str = Depends(verify_apitoken)
) -> Any:
"""
Webhook响应配置请求中需要添加参数token=API_TOKEN&source=媒体服务器名
Webhook响应
"""
body = await request.body()
form = await request.form()
@@ -32,10 +32,10 @@ async def webhook_message(background_tasks: BackgroundTasks,
@router.get("/", summary="Webhook消息响应", response_model=schemas.Response)
async def webhook_message(background_tasks: BackgroundTasks,
request: Request, _: Annotated[str, Depends(verify_apitoken)]) -> Any:
def webhook_message(background_tasks: BackgroundTasks,
request: Request, _: str = Depends(verify_apitoken)) -> Any:
"""
Webhook响应配置请求中需要添加参数token=API_TOKEN&source=媒体服务器名
Webhook响应
"""
args = request.query_params
background_tasks.add_task(start_webhook_chain, None, None, args)

View File

@@ -1,310 +0,0 @@
import json
from datetime import datetime
from typing import List, Any, Optional
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.chain.workflow import WorkflowChain
from app.core.config import global_vars
from app.core.plugin import PluginManager
from app.core.security import verify_token
from app.core.workflow import WorkFlowManager
from app.db import get_async_db, get_db
from app.db.models import Workflow
from app.db.systemconfig_oper import SystemConfigOper
from app.db.workflow_oper import WorkflowOper
from app.helper.workflow import WorkflowHelper
from app.scheduler import Scheduler
from app.schemas.types import EventType, EVENT_TYPE_NAMES
router = APIRouter()
@router.get("/", summary="所有工作流", response_model=List[schemas.Workflow])
async def list_workflows(db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取工作流列表
"""
return await WorkflowOper(db).async_list()
@router.post("/", summary="创建工作流", response_model=schemas.Response)
async def create_workflow(workflow: schemas.Workflow,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
创建工作流
"""
if workflow.name and await WorkflowOper(db).async_get_by_name(workflow.name):
return schemas.Response(success=False, message="已存在相同名称的工作流")
if not workflow.add_time:
workflow.add_time = datetime.strftime(datetime.now(), "%Y-%m-%d %H:%M:%S")
if not workflow.state:
workflow.state = "P"
if not workflow.trigger_type:
workflow.trigger_type = "timer"
workflow_obj = Workflow(**workflow.dict())
await workflow_obj.async_create(db)
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(verify_token)) -> Any:
"""
获取所有动作
"""
return PluginManager().get_plugin_actions(plugin_id)
@router.get("/actions", summary="所有动作", response_model=List[dict])
async def list_actions(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取所有动作
"""
return WorkFlowManager().list_actions()
@router.get("/event_types", summary="获取所有事件类型", response_model=List[dict])
async def get_event_types(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取所有事件类型
"""
return [{
"title": EVENT_TYPE_NAMES.get(event_type, event_type.name),
"value": event_type.value
} for event_type in EventType]
@router.post("/share", summary="分享工作流", response_model=schemas.Response)
async def workflow_share(
workflow: schemas.WorkflowShare,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
分享工作流
"""
if not workflow.id or not workflow.share_title or not workflow.share_user:
return schemas.Response(success=False, message="请填写工作流ID、分享标题和分享人")
state, errmsg = await WorkflowHelper().async_workflow_share(workflow_id=workflow.id,
share_title=workflow.share_title or "",
share_comment=workflow.share_comment or "",
share_user=workflow.share_user or "")
return schemas.Response(success=state, message=errmsg)
@router.delete("/share/{share_id}", summary="删除分享", response_model=schemas.Response)
async def workflow_share_delete(
share_id: int,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除分享
"""
state, errmsg = await WorkflowHelper().async_share_delete(share_id=share_id)
return schemas.Response(success=state, message=errmsg)
@router.post("/fork", summary="复用工作流", response_model=schemas.Response)
async def workflow_fork(
workflow: schemas.WorkflowShare,
db: AsyncSession = Depends(get_async_db),
_: schemas.User = Depends(verify_token)) -> Any:
"""
复用工作流
"""
if not workflow.name:
return schemas.Response(success=False, message="工作流名称不能为空")
# 解析JSON数据添加错误处理
try:
actions = json.loads(workflow.actions or "[]")
except json.JSONDecodeError:
return schemas.Response(success=False, message="actions字段JSON格式错误")
try:
flows = json.loads(workflow.flows or "[]")
except json.JSONDecodeError:
return schemas.Response(success=False, message="flows字段JSON格式错误")
try:
context = json.loads(workflow.context or "{}")
except json.JSONDecodeError:
return schemas.Response(success=False, message="context字段JSON格式错误")
# 创建工作流
workflow_dict = {
"name": workflow.name,
"description": workflow.description,
"timer": workflow.timer,
"trigger_type": workflow.trigger_type or "timer",
"event_type": workflow.event_type,
"event_conditions": json.loads(workflow.event_conditions or "{}") if workflow.event_conditions else {},
"actions": actions,
"flows": flows,
"context": context,
"state": "P" # 默认暂停状态
}
# 检查名称是否重复
workflow_oper = WorkflowOper(db)
if await workflow_oper.async_get_by_name(workflow_dict["name"]):
return schemas.Response(success=False, message="已存在相同名称的工作流")
# 创建新工作流
workflow = await Workflow(**workflow_dict).async_create(db)
# 更新复用次数
if workflow:
await WorkflowHelper().async_workflow_fork(share_id=workflow.id)
return schemas.Response(success=True, message="复用成功")
@router.get("/shares", summary="查询分享的工作流", response_model=List[schemas.WorkflowShare])
async def workflow_shares(
name: Optional[str] = None,
page: Optional[int] = 1,
count: Optional[int] = 30,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
查询分享的工作流
"""
return await WorkflowHelper().async_get_shares(name=name, page=page, count=count)
@router.post("/{workflow_id}/run", summary="执行工作流", response_model=schemas.Response)
def run_workflow(workflow_id: int,
from_begin: Optional[bool] = True,
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
执行工作流
"""
state, errmsg = WorkflowChain().process(workflow_id, from_begin=from_begin)
if not state:
return schemas.Response(success=False, message=errmsg)
return schemas.Response(success=True)
@router.post("/{workflow_id}/start", summary="启用工作流", response_model=schemas.Response)
def start_workflow(workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
启用工作流
"""
workflow = WorkflowOper(db).get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
if not workflow.trigger_type or workflow.trigger_type == "timer":
# 添加定时任务
Scheduler().update_workflow_job(workflow)
else:
# 事件触发:添加到事件触发器
WorkFlowManager().load_workflow_events(workflow_id)
# 更新状态
workflow.update_state(db, workflow_id, "W")
return schemas.Response(success=True)
@router.post("/{workflow_id}/pause", summary="停用工作流", response_model=schemas.Response)
def pause_workflow(workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
停用工作流
"""
workflow = WorkflowOper(db).get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
# 根据触发类型进行不同处理
if workflow.trigger_type == "timer":
# 定时触发:移除定时任务
Scheduler().remove_workflow_job(workflow)
elif workflow.trigger_type == "event":
# 事件触发:从事件触发器中移除
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
# 停止工作流
global_vars.stop_workflow(workflow_id)
# 更新状态
workflow.update_state(db, workflow_id, "P")
return schemas.Response(success=True)
@router.post("/{workflow_id}/reset", summary="重置工作流", response_model=schemas.Response)
async def reset_workflow(workflow_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
重置工作流
"""
workflow = await WorkflowOper(db).async_get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
# 停止工作流
global_vars.stop_workflow(workflow_id)
# 重置工作流
await Workflow.async_reset(db, workflow_id, reset_count=True)
# 删除缓存
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
return schemas.Response(success=True)
@router.get("/{workflow_id}", summary="工作流详情", response_model=schemas.Workflow)
async def get_workflow(workflow_id: int,
db: AsyncSession = Depends(get_async_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
获取工作流详情
"""
return await WorkflowOper(db).async_get(workflow_id)
@router.put("/{workflow_id}", summary="更新工作流", response_model=schemas.Response)
def update_workflow(workflow: schemas.Workflow,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
更新工作流
"""
if not workflow.id:
return schemas.Response(success=False, message="工作流ID不能为空")
workflow_oper = WorkflowOper(db)
wf = workflow_oper.get(workflow.id)
if not wf:
return schemas.Response(success=False, message="工作流不存在")
if not wf.trigger_type:
workflow.trigger_type = "timer"
wf.update(db, workflow.dict())
# 更新后的工作流对象
updated_workflow = workflow_oper.get(workflow.id)
# 更新定时任务
Scheduler().update_workflow_job(updated_workflow)
# 更新事件注册
WorkFlowManager().update_workflow_event(updated_workflow)
return schemas.Response(success=True, message="更新成功")
@router.delete("/{workflow_id}", summary="删除工作流", response_model=schemas.Response)
def delete_workflow(workflow_id: int,
db: Session = Depends(get_db),
_: schemas.TokenPayload = Depends(verify_token)) -> Any:
"""
删除工作流
"""
workflow = WorkflowOper(db).get(workflow_id)
if not workflow:
return schemas.Response(success=False, message="工作流不存在")
if not workflow.trigger_type or workflow.trigger_type == "timer":
# 定时触发:删除定时任务
Scheduler().remove_workflow_job(workflow)
else:
# 事件触发:从事件触发器中移除
WorkFlowManager().remove_workflow_event(workflow_id, workflow.event_type)
# 删除工作流
Workflow.delete(db, workflow_id)
# 删除缓存
SystemConfigOper().delete(f"WorkflowCache-{workflow_id}")
return schemas.Response(success=True, message="删除成功")

View File

@@ -1,16 +1,14 @@
from typing import Any, List, Annotated
from typing import Any, List
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app import schemas
from app.chain.media import MediaChain
from app.chain.subscribe import SubscribeChain
from app.chain.tvdb import TvdbChain
from app.core.metainfo import MetaInfo
from app.core.security import verify_apikey
from app.db import get_db, get_async_db
from app.db import get_db
from app.db.models.subscribe import Subscribe
from app.schemas import RadarrMovie, SonarrSeries
from app.schemas.types import MediaType
@@ -20,7 +18,7 @@ arr_router = APIRouter(tags=['servarr'])
@arr_router.get("/system/status", summary="系统状态")
async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
def arr_system_status(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr系统状态
"""
@@ -74,7 +72,7 @@ async def arr_system_status(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get("/qualityProfile", summary="质量配置")
async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
def arr_qualityProfile(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr质量配置
"""
@@ -115,7 +113,7 @@ async def arr_qualityProfile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get("/rootfolder", summary="根目录")
async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
def arr_rootfolder(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr根目录
"""
@@ -131,7 +129,7 @@ async def arr_rootfolder(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get("/tag", summary="标签")
async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
def arr_tag(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr标签
"""
@@ -144,7 +142,7 @@ async def arr_tag(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get("/languageprofile", summary="语言")
async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
def arr_languageprofile(_: str = Depends(verify_apikey)) -> Any:
"""
模拟Radarr、Sonarr语言
"""
@@ -170,7 +168,7 @@ async def arr_languageprofile(_: Annotated[str, Depends(verify_apikey)]) -> Any:
@arr_router.get("/movie", summary="所有订阅电影", response_model=List[schemas.RadarrMovie])
async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_movies(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
"""
查询Rardar电影
"""
@@ -241,7 +239,7 @@ async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession
"""
# 查询所有电影订阅
result = []
subscribes = await Subscribe.async_list(db)
subscribes = Subscribe.list(db)
for subscribe in subscribes:
if subscribe.type != MediaType.MOVIE.value:
continue
@@ -261,7 +259,7 @@ async def arr_movies(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession
@arr_router.get("/movie/lookup", summary="查询电影", response_model=List[schemas.RadarrMovie])
def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
def arr_movie_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Rardar电影 term: `tmdb:${id}`
存在和不存在均不能返回错误
@@ -307,12 +305,11 @@ def arr_movie_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: S
@arr_router.get("/movie/{mid}", summary="电影订阅详情", response_model=schemas.RadarrMovie)
async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Rardar电影订阅
"""
subscribe = await Subscribe.async_get(db, mid)
subscribe = Subscribe.get(db, mid)
if subscribe:
return RadarrMovie(
id=subscribe.id,
@@ -334,25 +331,25 @@ async def arr_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
@arr_router.post("/movie", summary="新增电影订阅")
async def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],
movie: RadarrMovie,
db: AsyncSession = Depends(get_async_db)
) -> Any:
def arr_add_movie(movie: RadarrMovie,
db: Session = Depends(get_db),
_: str = Depends(verify_apikey)
) -> Any:
"""
新增Rardar电影订阅
"""
# 检查订阅是否已存在
subscribe = await Subscribe.async_get_by_tmdbid(db, movie.tmdbId)
subscribe = Subscribe.get_by_tmdbid(db, movie.tmdbId)
if subscribe:
return {
"id": subscribe.id
}
# 添加订阅
sid, message = await SubscribeChain().async_add(title=movie.title,
year=movie.year,
mtype=MediaType.MOVIE,
tmdbid=movie.tmdbId,
username="Seerr")
sid, message = SubscribeChain().add(title=movie.title,
year=movie.year,
mtype=MediaType.MOVIE,
tmdbid=movie.tmdbId,
username="Seerr")
if sid:
return {
"id": sid
@@ -365,14 +362,13 @@ async def arr_add_movie(_: Annotated[str, Depends(verify_apikey)],
@arr_router.delete("/movie/{mid}", summary="删除电影订阅", response_model=schemas.Response)
async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_remove_movie(mid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
删除Rardar电影订阅
"""
subscribe = await Subscribe.async_get(db, mid)
subscribe = Subscribe.get(db, mid)
if subscribe:
await subscribe.async_delete(db, mid)
subscribe.delete(db, mid)
return schemas.Response(success=True)
else:
raise HTTPException(
@@ -382,7 +378,7 @@ async def arr_remove_movie(mid: int, _: Annotated[str, Depends(verify_apikey)],
@arr_router.get("/series", summary="所有剧集", response_model=List[schemas.SonarrSeries])
async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_series(_: str = Depends(verify_apikey), db: Session = Depends(get_db)) -> Any:
"""
查询Sonarr剧集
"""
@@ -490,7 +486,7 @@ async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession
"""
# 查询所有电视剧订阅
result = []
subscribes = await Subscribe.async_list(db)
subscribes = Subscribe.list(db)
for subscribe in subscribes:
if subscribe.type != MediaType.TV.value:
continue
@@ -518,102 +514,100 @@ async def arr_series(_: Annotated[str, Depends(verify_apikey)], db: AsyncSession
@arr_router.get("/series/lookup", summary="查询剧集")
def arr_series_lookup(term: str, _: Annotated[str, Depends(verify_apikey)], db: Session = Depends(get_db)) -> Any:
def arr_series_lookup(term: str, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Sonarr剧集 term: `tvdb:${id}` title
"""
# 季信息
seas: List[int] = []
# tvdbid 列表
tvdbids: List[int] = []
# 获取TVDBID
if not term.startswith("tvdb:"):
title = term.replace("+", " ")
tvdbids = TvdbChain().get_tvdbid_by_name(title=title)
else:
tvdbid = int(term.replace("tvdb:", ""))
tvdbids.append(tvdbid)
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')),
mediainfo = MediaChain().recognize_media(meta=MetaInfo(term),
mtype=MediaType.TV)
if not mediainfo:
continue
# 查询是否存在
exists = MediaChain().media_exists(mediainfo)
if exists:
hasfile = True
else:
hasfile = False
return [SonarrSeries()]
tvdbid = mediainfo.tvdb_id
if not tvdbid:
return [SonarrSeries()]
else:
mediainfo = None
tvdbid = int(term.replace("tvdb:", ""))
# 查询订阅信息
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:
# 查询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
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,
})
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)
subid = subscribes[-1].id
else:
subid = None
monitored = False
for sea in seas:
seasons.append({
"seasonNumber": sea,
"monitored": False,
})
return sonarr_series_list if sonarr_series_list else [SonarrSeries()]
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
)]
@arr_router.get("/series/{tid}", summary="剧集详情")
async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_serie(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
查询Sonarr剧集
"""
subscribe = await Subscribe.async_get(db, tid)
subscribe = Subscribe.get(db, tid)
if subscribe:
return SonarrSeries(
id=subscribe.id,
@@ -643,17 +637,17 @@ async def arr_serie(tid: int, _: Annotated[str, Depends(verify_apikey)],
@arr_router.post("/series", summary="新增剧集订阅")
async def arr_add_series(tv: schemas.SonarrSeries,
_: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_add_series(tv: schemas.SonarrSeries,
db: Session = Depends(get_db),
_: str = Depends(verify_apikey)) -> Any:
"""
新增Sonarr剧集订阅
"""
# 检查订阅是否存在
left_seasons = []
for season in tv.seasons:
subscribe = await Subscribe.async_get_by_tmdbid(db, tmdbid=tv.tmdbId,
season=season.get("seasonNumber"))
subscribe = Subscribe.get_by_tmdbid(db, tmdbid=tv.tmdbId,
season=season.get("seasonNumber"))
if subscribe:
continue
left_seasons.append(season)
@@ -668,12 +662,12 @@ async def arr_add_series(tv: schemas.SonarrSeries,
for season in left_seasons:
if not season.get("monitored"):
continue
sid, message = await SubscribeChain().async_add(title=tv.title,
year=tv.year,
season=season.get("seasonNumber"),
tmdbid=tv.tmdbId,
mtype=MediaType.TV,
username="Seerr")
sid, message = SubscribeChain().add(title=tv.title,
year=tv.year,
season=season.get("seasonNumber"),
tmdbid=tv.tmdbId,
mtype=MediaType.TV,
username="Seerr")
if sid:
return {
@@ -686,23 +680,14 @@ async def arr_add_series(tv: schemas.SonarrSeries,
)
@arr_router.put("/series", summary="更新剧集订阅")
async def arr_update_series(tv: schemas.SonarrSeries, _: Annotated[str, Depends(verify_apikey)]) -> Any:
"""
更新Sonarr剧集订阅
"""
return await arr_add_series(tv)
@arr_router.delete("/series/{tid}", summary="删除剧集订阅")
async def arr_remove_series(tid: int, _: Annotated[str, Depends(verify_apikey)],
db: AsyncSession = Depends(get_async_db)) -> Any:
def arr_remove_series(tid: int, db: Session = Depends(get_db), _: str = Depends(verify_apikey)) -> Any:
"""
删除Sonarr剧集订阅
"""
subscribe = await Subscribe.async_get(db, tid)
subscribe = Subscribe.get(db, tid)
if subscribe:
await subscribe.async_delete(db, tid)
subscribe.delete(db, tid)
return schemas.Response(success=True)
else:
raise HTTPException(

View File

@@ -1,9 +1,9 @@
import gzip
import json
from typing import Annotated, Callable, Any, Dict, Optional
from hashlib import md5
from typing import Annotated, Callable
from typing import Any, Dict, Optional
import aiofiles
from anyio import Path as AsyncPath
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
from fastapi.responses import PlainTextResponse
from fastapi.routing import APIRoute
@@ -11,7 +11,7 @@ from fastapi.routing import APIRoute
from app import schemas
from app.core.config import settings
from app.log import logger
from app.utils.crypto import CryptoJsUtils, HashUtils
from app.utils.common import decrypt
class GzipRequest(Request):
@@ -21,7 +21,7 @@ class GzipRequest(Request):
body = await super().body()
if "gzip" in self.headers.getlist("Content-Encoding"):
body = gzip.decompress(body)
self._body = body # noqa
self._body = body
return self._body
@@ -47,17 +47,17 @@ async def verify_server_enabled():
cookie_router = APIRouter(route_class=GzipRoute,
tags=["servcookie"],
tags=['servcookie'],
dependencies=[Depends(verify_server_enabled)])
@cookie_router.get("/", response_class=PlainTextResponse)
async def get_root():
def get_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@cookie_router.post("/", response_class=PlainTextResponse)
async def post_root():
def post_root():
return "Hello MoviePilot! COOKIECLOUD API ROOT = /cookiecloud"
@@ -66,31 +66,31 @@ async def update_cookie(req: schemas.CookieData):
"""
上传Cookie数据
"""
file_path = AsyncPath(settings.COOKIE_PATH) / f"{req.uuid}.json"
file_path = settings.COOKIE_PATH / f"{req.uuid}.json"
content = json.dumps({"encrypted": req.encrypted})
async with aiofiles.open(file_path, encoding="utf-8", mode="w") as file:
await file.write(content)
async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file:
read_content = await file.read()
with open(file_path, encoding="utf-8", mode="w") as file:
file.write(content)
with open(file_path, encoding="utf-8", mode="r") as file:
read_content = file.read()
if read_content == content:
return {"action": "done"}
else:
return {"action": "error"}
async def load_encrypt_data(uuid: str) -> Dict[str, Any]:
def load_encrypt_data(uuid: str) -> Dict[str, Any]:
"""
加载本地加密原始数据
"""
file_path = AsyncPath(settings.COOKIE_PATH) / f"{uuid}.json"
file_path = settings.COOKIE_PATH / f"{uuid}.json"
# 检查文件是否存在
if not file_path.exists():
raise HTTPException(status_code=404, detail="Item not found")
# 读取文件
async with aiofiles.open(file_path, encoding="utf-8", mode="r") as file:
read_content = await file.read()
with open(file_path, encoding="utf-8", mode="r") as file:
read_content = file.read()
data = json.loads(read_content.encode("utf-8"))
return data
@@ -100,14 +100,15 @@ def get_decrypted_cookie_data(uuid: str, password: str,
"""
加载本地加密数据并解密为Cookie
"""
combined_string = f"{uuid}-{password}"
aes_key = HashUtils.md5(combined_string)[:16].encode("utf-8")
key_md5 = md5()
key_md5.update((uuid + '-' + password).encode('utf-8'))
aes_key = (key_md5.hexdigest()[:16]).encode('utf-8')
if encrypted:
try:
decrypted_data = CryptoJsUtils.decrypt(encrypted, aes_key).decode("utf-8")
decrypted_data = decrypt(encrypted, aes_key).decode('utf-8')
decrypted_data = json.loads(decrypted_data)
if "cookie_data" in decrypted_data:
if 'cookie_data' in decrypted_data:
return decrypted_data
except Exception as e:
logger.error(f"解密Cookie数据失败{str(e)}")
@@ -122,7 +123,7 @@ async def get_cookie(
"""
GET 下载加密数据
"""
return await load_encrypt_data(uuid)
return load_encrypt_data(uuid)
@cookie_router.post("/get/{uuid}")
@@ -132,5 +133,5 @@ async def post_cookie(
"""
POST 下载加密数据
"""
data = await load_encrypt_data(uuid)
data = load_encrypt_data(uuid)
return get_decrypted_cookie_data(uuid, request.password, data["encrypted"])

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,12 @@ 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):
class BangumiChain(ChainBase, metaclass=Singleton):
"""
Bangumi处理链
Bangumi处理链,单例运行
"""
def calendar(self) -> Optional[List[MediaInfo]]:
@@ -16,12 +17,6 @@ class BangumiChain(ChainBase):
"""
return self.run_module("bangumi_calendar")
def discover(self, **kwargs) -> Optional[List[MediaInfo]]:
"""
发现Bangumi番剧
"""
return self.run_module("bangumi_discover", **kwargs)
def bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
获取Bangumi信息
@@ -57,51 +52,3 @@ class BangumiChain(ChainBase):
:param person_id: 人物ID
"""
return self.run_module("bangumi_person_credits", person_id=person_id)
async def async_calendar(self) -> Optional[List[MediaInfo]]:
"""
获取Bangumi每日放送异步版本
"""
return await self.async_run_module("async_bangumi_calendar")
async def async_discover(self, **kwargs) -> Optional[List[MediaInfo]]:
"""
发现Bangumi番剧异步版本
"""
return await self.async_run_module("async_bangumi_discover", **kwargs)
async def async_bangumi_info(self, bangumiid: int) -> Optional[dict]:
"""
获取Bangumi信息异步版本
:param bangumiid: BangumiID
:return: Bangumi信息
"""
return await self.async_run_module("async_bangumi_info", bangumiid=bangumiid)
async def async_bangumi_credits(self, bangumiid: int) -> List[schemas.MediaPerson]:
"""
根据BangumiID查询电影演职员表异步版本
:param bangumiid: BangumiID
"""
return await self.async_run_module("async_bangumi_credits", bangumiid=bangumiid)
async def async_bangumi_recommend(self, bangumiid: int) -> Optional[List[MediaInfo]]:
"""
根据BangumiID查询推荐电影异步版本
:param bangumiid: BangumiID
"""
return await self.async_run_module("async_bangumi_recommend", bangumiid=bangumiid)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据人物ID查询Bangumi人物详情异步版本
:param person_id: 人物ID
"""
return await self.async_run_module("async_bangumi_person_detail", person_id=person_id)
async def async_person_credits(self, person_id: int) -> Optional[List[MediaInfo]]:
"""
根据人物ID查询人物参演作品异步版本
:param person_id: 人物ID
"""
return await self.async_run_module("async_bangumi_person_credits", person_id=person_id)

View File

@@ -2,20 +2,21 @@ from typing import Optional, List
from app import schemas
from app.chain import ChainBase
from app.utils.singleton import Singleton
class DashboardChain(ChainBase):
class DashboardChain(ChainBase, metaclass=Singleton):
"""
各类仪表板统计处理链
"""
def media_statistic(self, server: Optional[str] = None) -> Optional[List[schemas.Statistic]]:
def media_statistic(self) -> Optional[List[schemas.Statistic]]:
"""
媒体数量统计
"""
return self.run_module("media_statistic", server=server)
return self.run_module("media_statistic")
def downloader_info(self, downloader: Optional[str] = None) -> Optional[List[schemas.DownloaderInfo]]:
def downloader_info(self) -> Optional[List[schemas.DownloaderInfo]]:
"""
下载器信息
"""
return self.run_module("downloader_info", downloader=downloader)
return self.run_module("downloader_info")

View File

@@ -4,11 +4,12 @@ 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):
class DoubanChain(ChainBase, metaclass=Singleton):
"""
豆瓣处理链
豆瓣处理链,单例运行
"""
def person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
@@ -18,7 +19,7 @@ class DoubanChain(ChainBase):
"""
return self.run_module("douban_person_detail", person_id=person_id)
def person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
def person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]:
"""
根据人物ID查询人物参演作品
:param person_id: 人物ID
@@ -26,7 +27,7 @@ class DoubanChain(ChainBase):
"""
return self.run_module("douban_person_credits", person_id=person_id, page=page)
def movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def movie_top250(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取豆瓣电影TOP250
:param page: 页码
@@ -34,26 +35,26 @@ class DoubanChain(ChainBase):
"""
return self.run_module("movie_top250", page=page, count=count)
def movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def movie_showing(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取正在上映的电影
"""
return self.run_module("movie_showing", page=page, count=count)
def tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取本周中国剧集榜
"""
return self.run_module("tv_weekly_chinese", page=page, count=count)
def tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def tv_weekly_global(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取本周全球剧集榜
"""
return self.run_module("tv_weekly_global", page=page, count=count)
def douban_discover(self, mtype: MediaType, sort: str, tags: str,
page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
page: int = 0, count: int = 30) -> Optional[List[MediaInfo]]:
"""
发现豆瓣电影、剧集
:param mtype: 媒体类型
@@ -66,19 +67,19 @@ class DoubanChain(ChainBase):
return self.run_module("douban_discover", mtype=mtype, sort=sort, tags=tags,
page=page, count=count)
def tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def tv_animation(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取动画剧集
"""
return self.run_module("tv_animation", page=page, count=count)
def movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def movie_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取热门电影
"""
return self.run_module("movie_hot", page=page, count=count)
def tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
def tv_hot(self, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]:
"""
获取热门剧集
"""
@@ -111,111 +112,3 @@ class DoubanChain(ChainBase):
:param doubanid: 豆瓣ID
"""
return self.run_module("douban_tv_recommend", doubanid=doubanid)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据人物ID查询豆瓣人物详情异步版本
:param person_id: 人物ID
"""
return await self.async_run_module("async_douban_person_detail", person_id=person_id)
async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> List[MediaInfo]:
"""
根据人物ID查询人物参演作品异步版本
:param person_id: 人物ID
:param page: 页码
"""
return await self.async_run_module("async_douban_person_credits", person_id=person_id, page=page)
async def async_movie_top250(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取豆瓣电影TOP250异步版本
:param page: 页码
:param count: 每页数量
"""
return await self.async_run_module("async_movie_top250", page=page, count=count)
async def async_movie_showing(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取正在上映的电影(异步版本)
"""
return await self.async_run_module("async_movie_showing", page=page, count=count)
async def async_tv_weekly_chinese(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取本周中国剧集榜(异步版本)
"""
return await self.async_run_module("async_tv_weekly_chinese", page=page, count=count)
async def async_tv_weekly_global(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取本周全球剧集榜(异步版本)
"""
return await self.async_run_module("async_tv_weekly_global", page=page, count=count)
async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str,
page: Optional[int] = 0, count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
发现豆瓣电影、剧集(异步版本)
:param mtype: 媒体类型
:param sort: 排序方式
:param tags: 标签
:param page: 页码
:param count: 数量
:return: 媒体信息列表
"""
return await self.async_run_module("async_douban_discover", mtype=mtype, sort=sort, tags=tags,
page=page, count=count)
async def async_tv_animation(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取动画剧集(异步版本)
"""
return await self.async_run_module("async_tv_animation", page=page, count=count)
async def async_movie_hot(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取热门电影(异步版本)
"""
return await self.async_run_module("async_movie_hot", page=page, count=count)
async def async_tv_hot(self, page: Optional[int] = 1,
count: Optional[int] = 30) -> Optional[List[MediaInfo]]:
"""
获取热门剧集(异步版本)
"""
return await self.async_run_module("async_tv_hot", page=page, count=count)
async def async_movie_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电影演职人员异步版本
:param doubanid: 豆瓣ID
"""
return await self.async_run_module("async_douban_movie_credits", doubanid=doubanid)
async def async_tv_credits(self, doubanid: str) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电视剧演职人员异步版本
:param doubanid: 豆瓣ID
"""
return await self.async_run_module("async_douban_tv_credits", doubanid=doubanid)
async def async_movie_recommend(self, doubanid: str) -> List[MediaInfo]:
"""
根据豆瓣ID查询推荐电影异步版本
:param doubanid: 豆瓣ID
"""
return await self.async_run_module("async_douban_movie_recommend", doubanid=doubanid)
async def async_tv_recommend(self, doubanid: str) -> List[MediaInfo]:
"""
根据豆瓣ID查询推荐电视剧异步版本
:param doubanid: 豆瓣ID
"""
return await self.async_run_module("async_douban_tv_recommend", doubanid=doubanid)

View File

@@ -8,8 +8,7 @@ from typing import List, Optional, Tuple, Set, Dict, Union
from app import schemas
from app.chain import ChainBase
from app.core.cache import FileCache
from app.core.config import settings, global_vars
from app.core.config import settings
from app.core.context import MediaInfo, TorrentInfo, Context
from app.core.event import eventmanager, Event
from app.core.meta import MetaBase
@@ -17,12 +16,11 @@ 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, ContentType, \
ChainEventType
from app.schemas import ExistMediaInfo, NotExistMediaInfo, DownloadingTorrent, Notification
from app.schemas.types import MediaType, TorrentStatus, EventType, MessageChannel, NotificationType
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
@@ -32,17 +30,80 @@ 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, userid: str = None, username: str = None,
download_episodes: str = None):
"""
发送添加下载的消息
:param meta: 元数据
:param mediainfo: 媒体信息
:param torrent: 种子信息
:param channel: 通知渠道
:param userid: 用户ID指定时精确发送对应用户
: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,
userid=userid,
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')))
def download_torrent(self, torrent: TorrentInfo,
channel: MessageChannel = None,
source: Optional[str] = None,
userid: Union[str, int] = None
) -> Tuple[Optional[Union[str, bytes]], str, list]:
) -> Tuple[Optional[Union[Path, str]], str, list]:
"""
下载种子文件,如果是磁力链,会返回磁力链接本身
:return: 种子内容,种子目录名,种子文件清单
:return: 种子路径,种子目录名,种子文件清单
"""
def __get_redict_url(url: str, ua: Optional[str] = None, cookie: Optional[str] = None) -> Optional[str]:
def __get_redict_url(url: str, ua: str = None, cookie: str = None) -> Optional[str]:
"""
获取下载链接, url格式[base64]url
"""
@@ -61,8 +122,6 @@ class DownloadChain(ChainBase):
# 是否使用cookie
if not req_params.get('cookie'):
cookie = None
# 代理
proxy = req_params.get('proxy')
# 请求头
if req_params.get('header'):
headers = req_params.get('header')
@@ -73,16 +132,14 @@ class DownloadChain(ChainBase):
res = RequestUtils(
ua=ua,
cookies=cookie,
headers=headers,
proxies=settings.PROXY if proxy else None
headers=headers
).get_res(url, params=req_params.get('params'))
else:
# POST请求
res = RequestUtils(
ua=ua,
cookies=cookie,
headers=headers,
proxies=settings.PROXY if proxy else None
headers=headers
).post_res(url, params=req_params.get('params'))
if not res:
return None
@@ -118,21 +175,20 @@ class DownloadChain(ChainBase):
logger.error(f"{torrent.title} 无法获取下载地址:{torrent.enclosure}")
return None, "", []
# 下载种子文件
_, content, download_folder, files, error_msg = TorrentHelper().download_torrent(
torrent_file, content, download_folder, files, error_msg = self.torrent.download_torrent(
url=torrent_url,
cookie=site_cookie,
ua=torrent.site_ua or settings.USER_AGENT,
ua=torrent.site_ua,
proxy=torrent.site_proxy)
if isinstance(content, str):
# 磁力链
return content, "", []
if not content:
if not torrent_file:
logger.error(f"下载种子文件失败:{torrent.title} - {torrent_url}")
self.post_message(Notification(
channel=channel,
source=source if channel else None,
mtype=NotificationType.Manual,
title=f"{torrent.title} 种子下载失败!",
text=f"错误信息:{error_msg}\n站点:{torrent.site_name}",
@@ -140,156 +196,101 @@ class DownloadChain(ChainBase):
return None, "", []
# 返回 种子文件路径,种子目录名,种子文件清单
return content, download_folder, files
return torrent_file, download_folder, files
def download_single(self, context: Context,
torrent_file: Path = None,
torrent_content: Optional[Union[str, bytes]] = None,
def download_single(self, context: Context, torrent_file: Path = None,
episodes: Set[int] = None,
channel: MessageChannel = None,
source: Optional[str] = None,
downloader: Optional[str] = None,
save_path: Optional[str] = None,
save_path: str = None,
userid: Union[str, int] = None,
username: Optional[str] = None,
label: Optional[str] = None) -> Optional[str]:
username: str = None) -> Optional[str]:
"""
下载及发送通知
:param context: 资源上下文
:param torrent_file: 种子文件路径
:param torrent_content: 种子内容(磁力链或种子文件内容)
:param episodes: 需要下载的集数
:param channel: 通知渠道
:param source: 来源消息通知、Subscribe、Manual等
:param downloader: 下载器
:param save_path: 保存路径
:param userid: 用户ID
:param username: 调用下载的用户名/插件名
:param label: 自定义标签
"""
_torrent = context.torrent_info
_media = context.media_info
_meta = context.meta_info
_site_downloader = _torrent.site_downloader
# 发送资源下载事件,允许外部拦截下载
event_data = ResourceDownloadEventData(
context=context,
episodes=episodes or context.meta_info.episode_list,
channel=channel,
origin=source,
downloader=downloader,
options={
"save_path": save_path,
"userid": userid,
"username": username,
"media_category": _media.category
}
)
# 触发资源下载事件
event = eventmanager.send_event(ChainEventType.ResourceDownload, event_data)
if event and event.event_data:
event_data: ResourceDownloadEventData = event.event_data
# 如果事件被取消,跳过资源下载
if event_data.cancel:
logger.debug(
f"Resource download canceled by event: {event_data.source},"
f"Reason: {event_data.reason}")
return None
# 如果事件修改了下载路径,使用新路径
if event_data.options and event_data.options.get("save_path"):
save_path = event_data.options.get("save_path")
# 补充完整的media数据
if not _media.genre_ids:
new_media = self.recognize_media(mtype=_media.type, tmdbid=_media.tmdb_id,
doubanid=_media.douban_id, bangumiid=_media.bangumi_id,
episode_group=_media.episode_group)
doubanid=_media.douban_id, bangumiid=_media.bangumi_id)
if new_media:
_media = new_media
# 实际下载的集数
download_episodes = StringUtils.format_ep(list(episodes)) if episodes else None
_folder_name = ""
if not torrent_file and not torrent_content:
if not torrent_file:
# 下载种子文件,得到的可能是文件也可能是磁力链
torrent_content, _folder_name, _file_list = self.download_torrent(_torrent,
channel=channel,
source=source,
userid=userid)
elif torrent_file:
if torrent_file.exists():
torrent_content = torrent_file.read_bytes()
else:
# 缓存处理器
cache_backend = FileCache()
# 读取缓存的种子文件
torrent_content = cache_backend.get(torrent_file.as_posix(), region="torrents")
if not torrent_content:
return None
# 获取种子文件的文件夹名和文件清单
_folder_name, _file_list = TorrentHelper().get_fileinfo_from_torrent_content(torrent_content)
content, _folder_name, _file_list = self.download_torrent(_torrent,
channel=channel,
userid=userid)
if not content:
return None
else:
content = torrent_file
# 获取种子文件的文件夹名和文件清单
_folder_name, _file_list = self.torrent.get_torrent_info(torrent_file)
# 下载目录
if save_path:
# 下载目录使用自定义的
download_dir = Path(save_path)
# 有自定义下载目录时,尝试匹配目录配置
dir_info = self.directoryhelper.get_download_dir(_media, to_path=Path(save_path))
else:
# 根据媒体信息查询下载目录配置
dir_info = DirectoryHelper().get_dir(_media, storage="local", include_unsorted=True)
# 拼装子目录
if dir_info:
# 一级目录
if not dir_info.media_type and dir_info.download_type_folder:
# 一级自动分类
download_dir = Path(dir_info.download_path) / _media.type.value
else:
# 一级不分类
download_dir = Path(dir_info.download_path)
# 二级目录
if not dir_info.media_category and dir_info.download_category_folder and _media and _media.category:
# 二级自动分类
download_dir = download_dir / _media.category
dir_info = self.directoryhelper.get_download_dir(_media)
# 拼装子目录
if dir_info:
# 一级目录
if not dir_info.media_type and dir_info.auto_category:
# 一级自动分类
download_dir = Path(dir_info.path) / _media.type.value
else:
# 未找到下载目录,且没有自定义下载目录
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
title="下载失败", role="system")
return None
# 一级不分类
download_dir = Path(dir_info.path)
# 二级目录
if not dir_info.category and dir_info.auto_category and _media and _media.category:
# 二级自动分类
download_dir = download_dir / _media.category
elif save_path:
# 自定义下载目录
download_dir = Path(save_path)
else:
# 未找到下载目录,且没有自定义下载目录
logger.error(f"未找到下载目录:{_media.type.value} {_media.title_year}")
self.messagehelper.put(f"{_media.type.value} {_media.title_year} 未找到下载目录!",
title="下载失败", role="system")
return None
# 添加下载
result: Optional[tuple] = self.download(content=torrent_content,
result: Optional[tuple] = self.download(content=content,
cookie=_torrent.site_cookie,
episodes=episodes,
download_dir=download_dir,
category=_media.category,
label=label,
downloader=downloader or _site_downloader)
category=_media.category)
if result:
_downloader, _hash, _layout, error_msg = result
_hash, error_msg = result
else:
_downloader, _hash, _layout, error_msg = None, None, None, "找到下载器"
_hash, error_msg = None, "知错误"
if _hash:
# `不创建子文件夹` 或 `不存在子文件夹`
if _layout == "NoSubfolder" or not _folder_name:
# 下载路径记录至文件
download_path = download_dir / _file_list[0] if _file_list else download_dir
# 原始布局
elif _folder_name:
# 下载文件路径
if _folder_name:
download_path = download_dir / _folder_name
# 创建子文件夹
else:
download_path = download_dir / Path(_file_list[0]).stem if _file_list else download_dir
# 文件保存路径
_save_path = download_dir if _layout == "NoSubfolder" or not _folder_name else download_path
download_path = download_dir / _file_list[0] if _file_list else download_dir
# 登记下载记录
downloadhis = DownloadHistoryOper()
downloadhis.add(
self.downloadhis.add(
path=str(download_path),
type=_media.type.value,
title=_media.title,
@@ -301,7 +302,6 @@ class DownloadChain(ChainBase):
seasons=_meta.season,
episodes=download_episodes or _meta.episode,
image=_media.get_backdrop_image(),
downloader=_downloader,
download_hash=_hash,
torrent_name=_torrent.title,
torrent_description=_torrent.description,
@@ -309,10 +309,7 @@ class DownloadChain(ChainBase):
userid=userid,
username=username,
channel=channel.value if channel else None,
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
media_category=_media.category,
episode_group=_media.episode_group,
note={"source": source}
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
)
# 登记下载文件
@@ -326,47 +323,29 @@ class DownloadChain(ChainBase):
continue
# 只处理视频格式
if not Path(file).suffix \
or Path(file).suffix.lower() not in settings.RMT_MEDIAEXT:
or Path(file).suffix not in settings.RMT_MEDIAEXT:
continue
files_to_add.append({
"download_hash": _hash,
"downloader": _downloader,
"fullpath": str(_save_path / file),
"savepath": str(_save_path),
"downloader": settings.DEFAULT_DOWNLOADER,
"fullpath": str(download_dir / _folder_name / file),
"savepath": str(download_dir / _folder_name),
"filepath": file,
"torrentname": _meta.org_string,
})
if files_to_add:
downloadhis.add_files(files_to_add)
self.downloadhis.add_files(files_to_add)
# 下载成功发送消息
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,
)
# 发送消息群发不带channel和userid
self.post_download_message(meta=_meta, mediainfo=_media, torrent=_torrent,
username=username, download_episodes=download_episodes)
# 下载成功后处理
self.download_added(context=context, download_dir=download_dir, torrent_content=torrent_content)
self.download_added(context=context, download_dir=download_dir, torrent_path=torrent_file)
# 广播事件
self.eventmanager.send_event(EventType.DownloadAdded, {
"hash": _hash,
"context": context,
"username": username,
"downloader": _downloader,
"episodes": episodes or _meta.episode_list,
"source": source
"username": username
})
else:
# 下载失败
@@ -375,7 +354,6 @@ class DownloadChain(ChainBase):
# 只发送给对应渠道和用户
self.post_message(Notification(
channel=channel,
source=source if channel else None,
mtype=NotificationType.Manual,
title="添加下载任务失败:%s %s"
% (_media.title_year, _meta.season_episode),
@@ -389,12 +367,10 @@ class DownloadChain(ChainBase):
def batch_download(self,
contexts: List[Context],
no_exists: Dict[Union[int, str], Dict[int, NotExistMediaInfo]] = None,
save_path: Optional[str] = None,
save_path: str = None,
channel: MessageChannel = None,
source: Optional[str] = None,
userid: Optional[str] = None,
username: Optional[str] = None,
downloader: Optional[str] = None
userid: str = None,
username: str = None
) -> Tuple[List[Context], Dict[Union[int, str], Dict[int, NotExistMediaInfo]]]:
"""
根据缺失数据,自动种子列表中组合择优下载
@@ -402,10 +378,8 @@ class DownloadChain(ChainBase):
:param no_exists: 缺失的剧集信息
:param save_path: 保存路径
:param channel: 通知渠道
:param source: 来源(消息通知、订阅、手工下载等)
:param userid: 用户ID
:param username: 调用下载的用户名/插件名
:param downloader: 下载器
:return: 已经下载的资源列表、剩余未下载到的剧集 no_exists[tmdb_id/douban_id] = {season: NotExistMediaInfo}
"""
# 已下载的项目
@@ -466,41 +440,22 @@ class DownloadChain(ChainBase):
return 9999
return no_exist[season].total_episode
# 发送资源选择事件,允许外部修改上下文数据
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
event_data = ResourceSelectionEventData(
contexts=contexts,
downloader=downloader,
origin=source
)
event = eventmanager.send_event(ChainEventType.ResourceSelection, event_data)
# 如果事件修改了上下文数据,使用更新后的数据
if event and event.event_data:
event_data: ResourceSelectionEventData = event.event_data
if event_data.updated and event_data.updated_contexts is not None:
logger.debug(f"Contexts updated by event: "
f"{len(event_data.updated_contexts)} items (source: {event_data.source})")
contexts = event_data.updated_contexts
# 分组排序
contexts = TorrentHelper().sort_group_torrents(contexts)
# 如果是电影,直接下载
for context in contexts:
if global_vars.is_system_stopped:
break
if context.media_info.type == MediaType.MOVIE:
logger.info(f"开始下载电影 {context.torrent_info.title} ...")
if self.download_single(context, save_path=save_path, channel=channel,
source=source, userid=userid, username=username,
downloader=downloader):
userid=userid, username=username):
# 下载成功
logger.info(f"{context.torrent_info.title} 添加下载成功")
downloaded_list.append(context)
# 电视剧整季匹配
logger.info(f"开始匹配电视剧整季:{no_exists}")
if no_exists:
logger.info(f"开始匹配电视剧整季:{no_exists}")
# 先把整季缺失的拿出来,看是否刚好有所有季都满足的种子 {tmdbid: [seasons]}
need_seasons: Dict[int, list] = {}
for need_mid, need_tv in no_exists.items():
@@ -517,8 +472,6 @@ class DownloadChain(ChainBase):
for need_mid, need_season in need_seasons.items():
# 循环种子
for context in contexts:
if global_vars.is_system_stopped:
break
# 媒体信息
media = context.media_info
# 识别元数据
@@ -553,7 +506,7 @@ class DownloadChain(ChainBase):
if isinstance(content, str):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法确定种子文件集数")
continue
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
torrent_episodes = self.torrent.get_torrent_episodes(torrent_files)
logger.info(f"{meta.org_string} 解析种子文件集数为 {torrent_episodes}")
if not torrent_episodes:
continue
@@ -572,21 +525,18 @@ class DownloadChain(ChainBase):
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(
context=context,
torrent_content=content,
torrent_file=content if isinstance(content, Path) else None,
save_path=save_path,
channel=channel,
source=source,
userid=userid,
username=username,
downloader=downloader
username=username
)
else:
# 下载
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(context, save_path=save_path,
channel=channel, source=source,
userid=userid, username=username,
downloader=downloader)
download_id = self.download_single(context,
save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id:
# 下载成功
@@ -601,8 +551,8 @@ class DownloadChain(ChainBase):
# 全部下载完成
break
# 电视剧季内的集匹配
logger.info(f"开始电视剧完整集匹配:{no_exists}")
if no_exists:
logger.info(f"开始电视剧完整集匹配:{no_exists}")
# TMDBID列表
need_tv_list = list(no_exists)
for need_mid in need_tv_list:
@@ -626,8 +576,6 @@ class DownloadChain(ChainBase):
need_episodes = list(range(start_episode, total_episode + 1))
# 循环种子
for context in contexts:
if global_vars.is_system_stopped:
break
# 媒体信息
media = context.media_info
# 识别元数据
@@ -654,10 +602,9 @@ class DownloadChain(ChainBase):
if torrent_episodes.issubset(set(need_episodes)):
# 下载
logger.info(f"开始下载 {meta.title} ...")
download_id = self.download_single(context, save_path=save_path,
channel=channel, source=source,
userid=userid, username=username,
downloader=downloader)
download_id = self.download_single(context,
save_path=save_path, channel=channel,
userid=userid, username=username)
if download_id:
# 下载成功
logger.info(f"{meta.title} 添加下载成功")
@@ -670,8 +617,8 @@ class DownloadChain(ChainBase):
logger.info(f"{need_season} 剩余需要集:{need_episodes}")
# 仍然缺失的剧集从整季中选择需要的集数文件下载仅支持QB和TR
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
if no_exists:
logger.info(f"开始电视剧多集拆包匹配:{no_exists}")
# TMDBID列表
no_exists_list = list(no_exists)
for need_mid in no_exists_list:
@@ -694,8 +641,6 @@ class DownloadChain(ChainBase):
continue
# 循环种子
for context in contexts:
if global_vars.is_system_stopped:
break
# 媒体信息
media = context.media_info
# 识别元数据
@@ -727,7 +672,7 @@ class DownloadChain(ChainBase):
logger.warn(f"{meta.org_string} 下载地址是磁力链,无法解析种子文件集数")
continue
# 种子全部集
torrent_episodes = TorrentHelper().get_torrent_episodes(torrent_files)
torrent_episodes = self.torrent.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))
@@ -739,14 +684,12 @@ class DownloadChain(ChainBase):
logger.info(f"开始下载 {torrent.title} ...")
download_id = self.download_single(
context=context,
torrent_content=content,
torrent_file=content if isinstance(content, Path) else None,
episodes=selected_episodes,
save_path=save_path,
channel=channel,
source=source,
userid=userid,
username=username,
downloader=downloader
username=username
)
if not download_id:
continue
@@ -816,12 +759,11 @@ class DownloadChain(ChainBase):
if not totals:
totals = {}
mediaserver = MediaServerOper()
if mediainfo.type == MediaType.MOVIE:
# 电影
itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,
title=mediainfo.title,
tmdbid=mediainfo.tmdb_id)
itemid = self.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}")
@@ -832,8 +774,7 @@ class DownloadChain(ChainBase):
# 补充媒体信息
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id,
episode_group=mediainfo.episode_group)
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f"媒体信息识别失败!")
return False, {}
@@ -841,10 +782,10 @@ class DownloadChain(ChainBase):
logger.error(f"媒体信息中没有季集信息:{mediainfo.title_year}")
return False, {}
# 电视剧
itemid = mediaserver.get_item_id(mtype=mediainfo.type.value,
title=mediainfo.title,
tmdbid=mediainfo.tmdb_id,
season=mediainfo.season)
itemid = self.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:
@@ -900,7 +841,7 @@ class DownloadChain(ChainBase):
# 全部存在
return True, no_exists
def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None, source: Optional[str] = None):
def remote_downloading(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
查询正在下载的任务,并发送消息
"""
@@ -908,7 +849,6 @@ class DownloadChain(ChainBase):
if not torrents:
self.post_message(Notification(
channel=channel,
source=source,
mtype=NotificationType.Download,
title="没有正在下载的任务!",
userid=userid,
@@ -926,7 +866,6 @@ class DownloadChain(ChainBase):
index += 1
self.post_message(Notification(
channel=channel,
source=source,
mtype=NotificationType.Download,
title=title,
text="\n".join(messages),
@@ -934,16 +873,16 @@ class DownloadChain(ChainBase):
link=settings.MP_DOMAIN('#/downloading')
))
def downloading(self, name: Optional[str] = None) -> List[DownloadingTorrent]:
def downloading(self) -> List[DownloadingTorrent]:
"""
查询正在下载的任务
"""
torrents = self.list_torrents(downloader=name, status=TorrentStatus.DOWNLOADING)
torrents = self.list_torrents(status=TorrentStatus.DOWNLOADING)
if not torrents:
return []
ret_torrents = []
for torrent in torrents:
history = DownloadHistoryOper().get_by_hash(torrent.hash)
history = self.downloadhis.get_by_hash(torrent.hash)
if history:
# 媒体信息
torrent.media = {
@@ -960,21 +899,21 @@ class DownloadChain(ChainBase):
ret_torrents.append(torrent)
return ret_torrents
def set_downloading(self, hash_str, oper: str, name: Optional[str] = None) -> bool:
def set_downloading(self, hash_str, oper: str) -> bool:
"""
控制下载任务 start/stop
"""
if oper == "start":
return self.start_torrents(hashs=[hash_str], downloader=name)
return self.start_torrents(hashs=[hash_str])
elif oper == "stop":
return self.stop_torrents(hashs=[hash_str], downloader=name)
return self.stop_torrents(hashs=[hash_str])
return False
def remove_downloading(self, hash_str: str, name: Optional[str] = None) -> bool:
def remove_downloading(self, hash_str: str) -> bool:
"""
删除下载任务
"""
return self.remove_torrents(hashs=[hash_str], downloader=name)
return self.remove_torrents(hashs=[hash_str])
@eventmanager.register(EventType.DownloadFileDeleted)
def download_file_deleted(self, event: Event):

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
import json
import threading
from typing import List, Union, Optional, Generator, Any
from typing import List, Union, Optional
from app import schemas
from app.chain import ChainBase
from app.core.config import global_vars
from app.core.config import settings
from app.db.mediaserver_oper import MediaServerOper
from app.helper.service import ServiceConfigHelper
from app.log import logger
from app.schemas import MediaServerLibrary, MediaServerItem, MediaServerSeasonInfo, MediaServerPlayItem
lock = threading.Lock()
@@ -16,97 +16,46 @@ class MediaServerChain(ChainBase):
媒体服务器处理链
"""
def librarys(self, server: str, username: Optional[str] = None,
hidden: bool = False) -> List[MediaServerLibrary]:
def __init__(self):
super().__init__()
self.dboper = MediaServerOper()
def librarys(self, server: str = None, username: str = None) -> List[schemas.MediaServerLibrary]:
"""
获取媒体服务器所有媒体库
"""
return self.run_module("mediaserver_librarys", server=server, username=username, hidden=hidden)
return self.run_module("mediaserver_librarys", server=server, username=username)
def items(self, server: str, library_id: Union[str, int],
start_index: Optional[int] = 0, limit: Optional[int] = -1) -> Generator[Any, None, None]:
def items(self, server: str, library_id: Union[str, int]) -> List[schemas.MediaServerItem]:
"""
获取媒体服务器项目列表,支持分页和不分页逻辑,默认不分页获取所有数据
:param server: 媒体服务器名称
:param library_id: 媒体库ID用于标识要获取的媒体库
:param start_index: 起始索引,用于分页获取数据。默认为 0即从第一个项目开始获取
:param limit: 每次请求的最大项目数,用于分页。如果为 None 或 -1则表示一次性获取所有数据默认为 -1
:return: 返回一个生成器对象,用于逐步获取媒体服务器中的项目
说明:
- 特别注意的是这里使用yield from返回迭代器避免同时使用return与yield导致Python生成器解析异常
- 如果 `limit` 为 None 或 -1 时,表示一次性获取所有数据,分页处理将不再生效
- 在这种情况下,内存消耗可能会较大,特别是在数据量非常大的场景下
- 如果未来评估结果显示,不分页场景下的内存消耗远大于分页处理时的网络请求开销,可以考虑在此方法中实现自分页的处理
- 即通过 `while` 循环在上层进行分页控制,逐步获取所有数据,避免内存爆炸,当前该逻辑由具体实例来实现不分页的处理
- Plex 实际上已默认支持内部分页处理Jellyfin 与 Emby 获取数据时存在内部过滤场景,如排除合集等,分页数据可能是错误的
if limit is not None and limit != -1:
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
start_index=start_index, limit=limit)
else:
# 自分页逻辑,通过循环逐步获取所有数据
page_size = 10
while True:
data_generator = self.run_module("mediaserver_items", server=server, library_id=library_id,
start_index=start_index, limit=page_size)
if not data_generator:
break
count = 0
for item in data_generator:
if item:
count += 1
yield item
if count < page_size:
break
start_index += page_size
获取媒体服务器所有项目
"""
yield from self.run_module("mediaserver_items", server=server, library_id=library_id,
start_index=start_index, limit=limit)
return self.run_module("mediaserver_items", server=server, library_id=library_id)
def iteminfo(self, server: str, item_id: Union[str, int]) -> MediaServerItem:
def iteminfo(self, server: str, item_id: Union[str, int]) -> schemas.MediaServerItem:
"""
获取媒体服务器项目信息
"""
return self.run_module("mediaserver_iteminfo", server=server, item_id=item_id)
def episodes(self, server: str, item_id: Union[str, int]) -> List[MediaServerSeasonInfo]:
def episodes(self, server: str, item_id: Union[str, int]) -> List[schemas.MediaServerSeasonInfo]:
"""
获取媒体服务器剧集信息
"""
return self.run_module("mediaserver_tv_episodes", server=server, item_id=item_id)
def playing(self, server: str, count: Optional[int] = 20,
username: Optional[str] = None) -> List[MediaServerPlayItem]:
def playing(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器正在播放信息
"""
return self.run_module("mediaserver_playing", count=count, server=server, username=username)
def latest(self, server: str, count: Optional[int] = 20,
username: Optional[str] = None) -> List[MediaServerPlayItem]:
def latest(self, count: int = 20, server: str = None, username: str = None) -> List[schemas.MediaServerPlayItem]:
"""
获取媒体服务器最新入库条目
"""
return self.run_module("mediaserver_latest", count=count, server=server, username=username)
def get_latest_wallpapers(self, server: Optional[str] = None, count: Optional[int] = 10,
remote: bool = True, username: Optional[str] = None) -> List[str]:
"""
获取最新最新入库条目海报作为壁纸缓存1小时
"""
return self.run_module("mediaserver_latest_images", server=server, count=count,
remote=remote, username=username)
def get_latest_wallpaper(self, server: Optional[str] = None,
remote: bool = True, username: Optional[str] = None) -> Optional[str]:
"""
获取最新最新入库条目海报作为壁纸缓存1小时
"""
wallpapers = self.get_latest_wallpapers(server=server, count=1, remote=remote, username=username)
return wallpapers[0] if wallpapers else None
def get_play_url(self, server: str, item_id: Union[str, int]) -> Optional[str]:
"""
获取播放地址
@@ -118,60 +67,50 @@ class MediaServerChain(ChainBase):
同步媒体库所有数据到本地数据库
"""
# 设置的媒体服务器
mediaservers = ServiceConfigHelper.get_mediaserver_configs()
if not mediaservers:
if not settings.MEDIASERVER:
return
# 同步黑名单
sync_blacklist = settings.MEDIASERVER_SYNC_BLACKLIST.split(
",") if settings.MEDIASERVER_SYNC_BLACKLIST else []
mediaservers = settings.MEDIASERVER.split(",")
with lock:
# 汇总统计
total_count = 0
# 清空登记薄
dboper = MediaServerOper()
dboper.empty()
self.dboper.empty()
# 遍历媒体服务器
for mediaserver in mediaservers:
if not mediaserver:
continue
logger.info(f"正在准备同步媒体服务器 {mediaserver.name} 的数据")
if not mediaserver.enabled:
logger.info(f"媒体服务器 {mediaserver.name} 未启用,跳过")
continue
server_name = mediaserver.name
sync_libraries = mediaserver.sync_libraries or []
logger.info(f"开始同步媒体服务器 {server_name} 的数据 ...")
libraries = self.librarys(server_name)
if not libraries:
logger.info(f"没有获取到媒体服务器 {server_name} 的媒体库,跳过")
continue
for library in libraries:
if sync_libraries \
and "all" not in sync_libraries \
and str(library.id) not in sync_libraries:
logger.info(f"{library.name} 未在 {server_name} 同步媒体库列表中,跳过")
logger.info(f"开始同步媒体 {mediaserver} 的数据 ...")
for library in self.librarys(mediaserver):
# 同步黑名单 跳过
if library.name in sync_blacklist:
continue
logger.info(f"正在同步 {server_name} 媒体库 {library.name} ...")
logger.info(f"正在同步 {mediaserver} 媒体库 {library.name} ...")
library_count = 0
for item in self.items(server=server_name, library_id=library.id):
if global_vars.is_system_stopped:
return
if not item or not item.item_id:
for item in self.items(mediaserver, library.id):
if not item:
continue
if not item.item_id:
continue
logger.debug(f"正在同步 {item.title} ...")
# 计数
library_count += 1
seasoninfo = {}
# 类型
item_type = "电视剧" if item.item_type in ["Series", "show"] else "电影"
item_type = "电视剧" if item.item_type in ['Series', 'show'] else "电影"
if item_type == "电视剧":
# 查询剧集信息
espisodes_info = self.episodes(server_name, item.item_id) or []
espisodes_info = self.episodes(mediaserver, item.item_id) or []
for episode in espisodes_info:
seasoninfo[episode.season] = episode.episodes
# 插入数据
item_dict = item.dict()
item_dict["seasoninfo"] = seasoninfo
item_dict["item_type"] = item_type
dboper.add(**item_dict)
logger.info(f"{server_name} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
item_dict['seasoninfo'] = json.dumps(seasoninfo)
item_dict['item_type'] = item_type
self.dboper.add(**item_dict)
logger.info(f"{mediaserver} 媒体库 {library.name} 同步完成,共同步数量:{library_count}")
# 总数累加
total_count += library_count
logger.info(f"媒体服务器 {server_name} 数据同步完成,同步数量:{total_count}")
logger.info("【MediaServer】媒体库数据同步完成,同步数量:%s" % total_count)

File diff suppressed because it is too large Load Diff

View File

@@ -1,449 +0,0 @@
import io
from pathlib import Path
from typing import List, Optional
import pillow_avif # noqa 用于自动注册AVIF支持
from PIL import Image
from app.chain import ChainBase
from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain
from app.chain.tmdb import TmdbChain
from app.core.cache import cached, FileCache
from app.core.config import settings, global_vars
from app.log import logger
from app.schemas import MediaType
from app.utils.common import log_execution_time
from app.utils.http import RequestUtils
from app.utils.security import SecurityUtils
from app.utils.singleton import Singleton
class RecommendChain(ChainBase, metaclass=Singleton):
"""
推荐处理链,单例运行
"""
# 推荐缓存时间
recommend_ttl = 24 * 3600
# 推荐缓存页数
cache_max_pages = 5
# 推荐缓存区域
recommend_cache_region = "recommend"
def refresh_recommend(self):
"""
刷新推荐
"""
logger.debug("Starting to refresh Recommend data.")
# 推荐来源方法
recommend_methods = [
self.tmdb_movies,
self.tmdb_tvs,
self.tmdb_trending,
self.bangumi_calendar,
self.douban_movie_showing,
self.douban_movies,
self.douban_tvs,
self.douban_movie_top250,
self.douban_tv_weekly_chinese,
self.douban_tv_weekly_global,
self.douban_tv_animation,
self.douban_movie_hot,
self.douban_tv_hot,
]
# 缓存并刷新所有推荐数据
recommends = []
# 记录哪些方法已完成
methods_finished = set()
# 这里避免区间内连续调用相同来源,因此遍历方案为每页遍历所有推荐来源,再进行页数遍历
for page in range(1, self.cache_max_pages + 1):
for method in recommend_methods:
if global_vars.is_system_stopped:
return
if method in methods_finished:
continue
logger.debug(f"Fetch {method.__name__} data for page {page}.")
data = method(page=page)
if not data:
logger.debug("All recommendation methods have finished fetching data. Ending pagination early.")
methods_finished.add(method)
continue
recommends.extend(data)
# 如果所有方法都已经完成,提前结束循环
if len(methods_finished) == len(recommend_methods):
break
# 缓存收集到的海报
self.__cache_posters(recommends)
logger.debug("Recommend data refresh completed.")
def __cache_posters(self, datas: List[dict]):
"""
提取 poster_path 并缓存图片
:param datas: 数据列表
"""
if not settings.GLOBAL_IMAGE_CACHE:
return
for data in datas:
if global_vars.is_system_stopped:
return
poster_path = data.get("poster_path")
if poster_path:
poster_url = poster_path.replace("original", "w500")
logger.debug(f"Caching poster image: {poster_url}")
self.__fetch_and_save_image(poster_url)
@staticmethod
def __fetch_and_save_image(url: str):
"""
请求并保存图片
:param url: 图片路径
"""
# 生成缓存路径
sanitized_path = SecurityUtils.sanitize_url_path(url)
cache_path = Path("images") / sanitized_path
# 没有文件类型,则添加后缀,在恶意文件类型和实际需求下的折衷选择
if not cache_path.suffix:
cache_path = cache_path.with_suffix(".jpg")
# 获取缓存后端,并设置缓存时间为全局配置的缓存天数
cache_backend = FileCache(base=settings.CACHE_PATH,
ttl=settings.GLOBAL_IMAGE_CACHE_DAYS * 24 * 3600)
# 本地存在缓存图片,则直接跳过
if cache_backend.get(cache_path.as_posix(), region="images"):
logger.debug(f"Cache hit: Image already exists at {cache_path}")
return
# 请求远程图片
referer = "https://movie.douban.com/" if "doubanio.com" in url else None
proxies = settings.PROXY if not referer else None
response = RequestUtils(ua=settings.NORMAL_USER_AGENT, proxies=proxies, referer=referer).get_res(url=url)
if not response:
logger.debug(f"Empty response for URL: {url}")
return
# 验证下载的内容是否为有效图片
try:
Image.open(io.BytesIO(response.content)).verify()
except Exception as e:
logger.debug(f"Invalid image format for URL {url}: {e}")
return
# 保存缓存
cache_backend.set(cache_path.as_posix(), response.content, region="images")
logger.debug(f"Successfully cached image at {cache_path} for URL: {url}")
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def tmdb_movies(self, sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]:
"""
TMDB热门电影
"""
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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "zh|en|ja|ko",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]:
"""
TMDB热门电视剧
"""
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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
"""
TMDB流行趋势
"""
infos = TmdbChain().tmdb_trending(page=page)
return [info.to_dict() for info in infos] if infos else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
Bangumi每日放送
"""
medias = BangumiChain().calendar()
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣正在热映
"""
movies = DoubanChain().movie_showing(page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣最新电影
"""
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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "",
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣最新电视剧
"""
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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣电影TOP250
"""
movies = DoubanChain().movie_top250(page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣国产剧集榜
"""
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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣全球剧集榜
"""
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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣热门动漫
"""
tvs = DoubanChain().tv_animation(page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣热门电影
"""
movies = DoubanChain().movie_hot(page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
def douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
豆瓣热门电视剧
"""
tvs = DoubanChain().tv_hot(page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_tmdb_movies(self, sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]:
"""
异步TMDB热门电影
"""
movies = await TmdbChain().async_run_module("async_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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
with_genres: Optional[str] = "",
with_original_language: Optional[str] = "zh|en|ja|ko",
with_keywords: Optional[str] = "",
with_watch_providers: Optional[str] = "",
vote_average: Optional[float] = 0.0,
vote_count: Optional[int] = 0,
release_date: Optional[str] = "",
page: Optional[int] = 1) -> List[dict]:
"""
异步TMDB热门电视剧
"""
tvs = await TmdbChain().async_run_module("async_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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_tmdb_trending(self, page: Optional[int] = 1) -> List[dict]:
"""
异步TMDB流行趋势
"""
infos = await TmdbChain().async_run_module("async_tmdb_trending", page=page)
return [info.to_dict() for info in infos] if infos else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_bangumi_calendar(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步Bangumi每日放送
"""
medias = await BangumiChain().async_run_module("async_bangumi_calendar")
return [media.to_dict() for media in medias[(page - 1) * count: page * count]] if medias else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_movie_showing(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣正在热映
"""
movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣最新电影
"""
movies = await DoubanChain().async_run_module("async_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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_tvs(self, sort: Optional[str] = "R", tags: Optional[str] = "",
page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣最新电视剧
"""
tvs = await DoubanChain().async_run_module("async_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)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_movie_top250(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣电影TOP250
"""
movies = await DoubanChain().async_run_module("async_movie_top250", page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_tv_weekly_chinese(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣国产剧集榜
"""
tvs = await DoubanChain().async_run_module("async_tv_weekly_chinese", page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_tv_weekly_global(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣全球剧集榜
"""
tvs = await DoubanChain().async_run_module("async_tv_weekly_global", page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_tv_animation(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣热门动漫
"""
tvs = await DoubanChain().async_run_module("async_tv_animation", page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_movie_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣热门电影
"""
movies = await DoubanChain().async_run_module("async_movie_hot", page=page, count=count)
return [media.to_dict() for media in movies] if movies else []
@log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region)
async def async_douban_tv_hot(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
"""
异步豆瓣热门电视剧
"""
tvs = await DoubanChain().async_run_module("async_tv_hot", page=page, count=count)
return [media.to_dict() for media in tvs] if tvs else []

View File

@@ -1,22 +1,18 @@
import asyncio
import random
import time
import pickle
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import Dict, Tuple
from typing import Dict
from typing import List, Optional
from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase
from app.core.config import global_vars, settings
from app.core.context import Context
from app.core.context import MediaInfo, TorrentInfo
from app.core.event import eventmanager, Event
from app.core.metainfo import MetaInfo
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.progress import ProgressHelper
from app.helper.sites import SitesHelper # noqa
from app.helper.sites import SitesHelper
from app.helper.torrent import TorrentHelper
from app.log import logger
from app.schemas import NotExistMediaInfo
@@ -28,20 +24,22 @@ 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]:
def search_by_id(self, tmdbid: int = None, doubanid: str = None,
mtype: MediaType = None, area: str = "title", season: int = None) -> List[Context]:
"""
根据TMDBID/豆瓣ID搜索资源精确匹配不过滤本地存在的资源
根据TMDBID/豆瓣ID搜索资源精确匹配但不不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param mtype: 媒体,电影 or 电视剧
:param area: 搜索范围title or imdbid
:param season: 季数
:param sites: 站点ID列表
:param cache_local: 是否缓存到本地
"""
mediainfo = self.recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if not mediainfo:
@@ -54,114 +52,92 @@ class SearchChain(ChainBase):
season: NotExistMediaInfo(episodes=[])
}
}
results = self.process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
# 保存到本地文件
if cache_local:
self.save_cache(results, self.__result_temp_file)
results = self.process(mediainfo=mediainfo, area=area, no_exists=no_exists)
# 保存结果
bytes_results = pickle.dumps(results)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
return results
def search_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
def search_by_title(self, title: str, page: int = 0, site: int = None) -> List[Context]:
"""
根据标题搜索资源,不识别不过滤,直接返回站点内容
:param title: 标题,为空时返回所有站点首页内容
:param page: 页码
:param sites: 站点ID列表
:param cache_local: 是否缓存到本地
:param site: 站点ID
"""
if title:
logger.info(f'开始搜索资源,关键词:{title} ...')
else:
logger.info(f'开始浏览资源,站点:{sites} ...')
logger.info(f'开始浏览资源,站点:{site} ...')
# 搜索
torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or []
torrents = self.__search_all_sites(keywords=[title], sites=[site] if site else None, page=page) or []
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
torrent_info=torrent) for torrent in torrents]
# 保存到本地文件
if cache_local:
self.save_cache(contexts, self.__result_temp_file)
# 保存结果
bytes_results = pickle.dumps(contexts)
self.systemconfig.set(SystemConfigKey.SearchResults, bytes_results)
return contexts
def last_search_results(self) -> List[Context]:
"""
获取上次搜索结果
"""
return self.load_cache(self.__result_temp_file)
results = self.systemconfig.get(SystemConfigKey.SearchResults)
if not results:
return []
try:
return pickle.loads(results)
except Exception as e:
logger.error(f'加载搜索结果失败:{str(e)} - {traceback.format_exc()}')
return []
async def async_last_search_results(self) -> List[Context]:
def process(self, mediainfo: MediaInfo,
keyword: str = None,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
sites: List[int] = None,
priority_rule: str = None,
filter_rule: Dict[str, str] = None,
area: str = "title") -> List[Context]:
"""
异步获取上次搜索结果
"""
return await self.async_load_cache(self.__result_temp_file)
async def async_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]:
"""
根据TMDBID/豆瓣ID异步搜索资源精确匹配不过滤本地存在的资源
:param tmdbid: TMDB ID
:param doubanid: 豆瓣 ID
:param mtype: 媒体,电影 or 电视剧
根据媒体信息搜索种子资源精确匹配应用过滤规则同时根据no_exists过滤本地已存在的资源
:param mediainfo: 媒体信息
:param keyword: 搜索关键词
:param no_exists: 缺失的媒体信息
:param sites: 站点ID列表为空时搜索所有站点
:param priority_rule: 优先级规则,为空时使用搜索优先级规则
:param filter_rule: 过滤规则,为空是使用默认过滤规则
:param area: 搜索范围title or imdbid
:param season: 季数
:param sites: 站点ID列表
:param cache_local: 是否缓存到本地
"""
mediainfo = await self.async_recognize_media(tmdbid=tmdbid, doubanid=doubanid, mtype=mtype)
if not mediainfo:
logger.error(f'{tmdbid} 媒体信息识别失败!')
return []
no_exists = None
if season:
no_exists = {
tmdbid or doubanid: {
season: NotExistMediaInfo(episodes=[])
}
}
results = await self.async_process(mediainfo=mediainfo, sites=sites, area=area, no_exists=no_exists)
# 保存到本地文件
if cache_local:
await self.async_save_cache(results, self.__result_temp_file)
return results
async def async_search_by_title(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
"""
根据标题异步搜索资源,不识别不过滤,直接返回站点内容
:param title: 标题,为空时返回所有站点首页内容
:param page: 页码
:param sites: 站点ID列表
:param cache_local: 是否缓存到本地
"""
if title:
logger.info(f'开始搜索资源,关键词:{title} ...')
else:
logger.info(f'开始浏览资源,站点:{sites} ...')
# 搜索
torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or []
if not torrents:
logger.warn(f'{title} 未搜索到资源')
return []
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
torrent_info=torrent) for torrent in torrents]
# 保存到本地文件
if cache_local:
await self.async_save_cache(contexts, self.__result_temp_file)
return contexts
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
"""
执行优先级过滤
"""
return self.filter_torrents(rule_string=priority_rule,
torrent_list=torrent_list,
season_episodes=season_episodes,
mediainfo=mediainfo) or []
# 豆瓣标题处理
if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title)
mediainfo.title = meta.name
mediainfo.season = meta.begin_season
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
@staticmethod
def __prepare_params(mediainfo: MediaInfo,
keyword: Optional[str] = None,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None
) -> Tuple[Dict[int, List[int]], List[str]]:
"""
准备搜索参数
"""
# 缺失的季集
mediakey = mediainfo.tmdb_id or mediainfo.douban_id
if no_exists and no_exists.get(mediakey):
@@ -182,319 +158,127 @@ class SearchChain(ChainBase):
keywords = list(dict.fromkeys([k for k in [mediainfo.title,
mediainfo.original_title,
mediainfo.en_title,
mediainfo.hk_title,
mediainfo.tw_title,
mediainfo.sg_title] if k]))
# 限制搜索关键词数量
if settings.MAX_SEARCH_NAME_LIMIT:
keywords = keywords[:settings.MAX_SEARCH_NAME_LIMIT]
return season_episodes, keywords
def __parse_result(self, torrents: List[TorrentInfo],
mediainfo: MediaInfo,
keyword: Optional[str] = None,
rule_groups: List[str] = None,
season_episodes: Dict[int, List[int]] = None,
custom_words: List[str] = None,
filter_params: Dict[str, str] = None) -> List[Context]:
"""
处理搜索结果
"""
def __do_filter(torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
"""
执行优先级过滤
"""
return self.filter_torrents(rule_groups=rule_groups,
torrent_list=torrent_list,
mediainfo=mediainfo) or []
# 执行搜索
torrents: List[TorrentInfo] = self.__search_all_sites(
mediainfo=mediainfo,
keywords=keywords,
sites=sites,
area=area
)
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 未搜索到资源')
return []
# 开始新进度
progress = ProgressHelper(ProgressKey.Search)
progress.start()
# 开始过滤
progress.update(value=0, text=f'开始过滤,总 {len(torrents)} 个资源,请稍候...')
# 匹配订阅附加参数
if filter_params:
logger.info(f'开始附加参数过滤,附加参数:{filter_params} ...')
torrents = [torrent for torrent in torrents if TorrentHelper().filter_torrent(torrent, filter_params)]
# 开始过滤规则过滤
if rule_groups is None:
# 取搜索过滤规则
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups)
if rule_groups:
logger.info(f'开始过滤规则/剧集过滤,使用规则组:{rule_groups} ...')
torrents = __do_filter(torrents)
if not torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
logger.info(f"过滤规则/剧集过滤完成,剩余 {len(torrents)} 个资源")
# 过滤完成
progress.update(value=50, text=f'过滤完成,剩余 {len(torrents)} 个资源')
self.progress.start(ProgressKey.Search)
# 开始匹配
_match_torrents = []
# 总数
_total = len(torrents)
# 已处理数
_count = 0
# 开始匹配
_match_torrents = []
torrenthelper = TorrentHelper()
try:
if mediainfo:
# 英文标题应该在别名/原标题中,不需要再匹配
logger.info(f"开始匹配结果 标题:{mediainfo.title},原标题:{mediainfo.original_title},别名:{mediainfo.names}")
progress.update(value=51, text=f'开始匹配,总 {_total} 个资源 ...')
self.progress.update(value=0, text=f'开始匹配,总 {_total} 个资源 ...', key=ProgressKey.Search)
for torrent in torrents:
if global_vars.is_system_stopped:
break
_count += 1
progress.update(value=(_count / _total) * 96,
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...')
self.progress.update(value=(_count / _total) * 96,
text=f'正在匹配 {torrent.site_name},已完成 {_count} / {_total} ...',
key=ProgressKey.Search)
if not torrent.title:
continue
# 识别元数据
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description,
custom_words=custom_words)
if torrent.title != torrent_meta.org_string:
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
# 季集数过滤
if season_episodes \
and not torrenthelper.match_season_episodes(torrent=torrent,
meta=torrent_meta,
season_episodes=season_episodes):
continue
# 比对IMDBID
if torrent.imdbid \
and mediainfo.imdb_id \
and torrent.imdbid == mediainfo.imdb_id:
logger.info(f'{mediainfo.title} 通过IMDBID匹配到资源{torrent.site_name} - {torrent.title}')
_match_torrents.append((torrent, torrent_meta))
_match_torrents.append(torrent)
continue
# 识别
torrent_meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != torrent_meta.org_string:
logger.info(f"种子名称应用识别词后发生改变:{torrent.title} => {torrent_meta.org_string}")
# 比对种子
if torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent):
if self.torrenthelper.match_torrent(mediainfo=mediainfo,
torrent_meta=torrent_meta,
torrent=torrent):
# 匹配成功
_match_torrents.append((torrent, torrent_meta))
_match_torrents.append(torrent)
continue
# 匹配完成
logger.info(f"匹配完成,共匹配到 {len(_match_torrents)} 个资源")
progress.update(value=97,
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源')
self.progress.update(value=97,
text=f'匹配完成,共匹配到 {len(_match_torrents)} 个资源',
key=ProgressKey.Search)
else:
_match_torrents = torrents
# 去掉mediainfo中多余的数据
mediainfo.clear()
# 组装上下文
contexts = [Context(torrent_info=t[0],
media_info=mediainfo,
meta_info=t[1]) for t in _match_torrents]
finally:
torrents.clear()
del torrents
_match_torrents.clear()
del _match_torrents
# 开始过滤
self.progress.update(value=98, text=f'开始过滤,总 {len(_match_torrents)} 个资源,请稍候...',
key=ProgressKey.Search)
# 开始过滤规则过滤
if _match_torrents:
logger.info(f'开始过滤规则过滤,当前规则:{filter_rule} ...')
_match_torrents = self.filter_torrents_by_rule(torrents=_match_torrents,
mediainfo=mediainfo,
filter_rule=filter_rule)
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合过滤规则的资源')
return []
logger.info(f"过滤规则过滤完成,剩余 {len(_match_torrents)} 个资源")
# 开始优先级规则/剧集过滤
if priority_rule is None:
# 取搜索优先级规则
priority_rule = self.systemconfig.get(SystemConfigKey.SearchFilterRules)
if priority_rule:
logger.info(f'开始优先级规则/剧集过滤,当前规则:{priority_rule} ...')
_match_torrents = __do_filter(_match_torrents)
if not _match_torrents:
logger.warn(f'{keyword or mediainfo.title} 没有符合优先级规则的资源')
return []
logger.info(f"优先级规则/剧集过滤完成,剩余 {len(_match_torrents)} 个资源")
# 去掉mediainfo中多余的数据
mediainfo.clear()
# 组装上下文
contexts = [Context(meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
media_info=mediainfo,
torrent_info=torrent) for torrent in _match_torrents]
self.progress.update(value=99, text=f'过滤完成,剩余 {len(contexts)} 个资源', key=ProgressKey.Search)
# 排序
progress.update(value=99,
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...')
contexts = torrenthelper.sort_torrents(contexts)
self.progress.update(value=99,
text=f'正在对 {len(contexts)} 个资源进行排序,请稍候...',
key=ProgressKey.Search)
contexts = self.torrenthelper.sort_torrents(contexts)
# 结束进度
self.progress.update(value=100,
text=f'搜索完成,共 {len(contexts)} 个资源',
key=ProgressKey.Search)
logger.info(f'搜索完成,共 {len(contexts)} 个资源')
progress.update(value=100,
text=f'搜索完成,共 {len(contexts)} 个资源')
progress.end()
self.progress.end(ProgressKey.Search)
# 去重后返回
return self.__remove_duplicate(contexts)
# 返回
return contexts
@staticmethod
def __remove_duplicate(_torrents: List[Context]) -> List[Context]:
"""
去除重复的种子
:param _torrents: 种子列表
:return: 去重后的种子列表
"""
if not settings.SEARCH_MULTIPLE_NAME:
return _torrents
# 通过encosure去重
return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t
for t in _torrents}.values())
def process(self, mediainfo: MediaInfo,
keyword: Optional[str] = None,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
sites: List[int] = None,
rule_groups: List[str] = None,
area: Optional[str] = "title",
custom_words: List[str] = None,
filter_params: Dict[str, str] = None) -> List[Context]:
"""
根据媒体信息搜索种子资源精确匹配应用过滤规则同时根据no_exists过滤本地已存在的资源
:param mediainfo: 媒体信息
:param keyword: 搜索关键词
:param no_exists: 缺失的媒体信息
:param sites: 站点ID列表为空时搜索所有站点
:param rule_groups: 过滤规则组名称列表
:param area: 搜索范围title or imdbid
:param custom_words: 自定义识别词列表
:param filter_params: 过滤参数
"""
# 豆瓣标题处理
if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title)
mediainfo.title = meta.name
mediainfo.season = meta.begin_season
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = self.recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
# 准备搜索参数
season_episodes, keywords = self.__prepare_params(
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists
)
# 站点搜索结果
torrents: List[TorrentInfo] = []
# 站点搜索次数
search_count = 0
# 多关键字执行搜索
for search_word in keywords:
# 强制休眠 1-10 秒
if search_count > 0:
logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
time.sleep(random.randint(1, 10))
# 搜索站点
torrents.extend(
self.__search_all_sites(
mediainfo=mediainfo,
keyword=search_word,
sites=sites,
area=area
) or []
)
search_count += 1
# 处理结果
return self.__parse_result(
torrents=torrents,
mediainfo=mediainfo,
keyword=keyword,
rule_groups=rule_groups,
season_episodes=season_episodes,
custom_words=custom_words,
filter_params=filter_params
)
async def async_process(self, mediainfo: MediaInfo,
keyword: Optional[str] = None,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
sites: List[int] = None,
rule_groups: List[str] = None,
area: Optional[str] = "title",
custom_words: List[str] = None,
filter_params: Dict[str, str] = None) -> List[Context]:
"""
根据媒体信息异步搜索种子资源精确匹配应用过滤规则同时根据no_exists过滤本地已存在的资源
:param mediainfo: 媒体信息
:param keyword: 搜索关键词
:param no_exists: 缺失的媒体信息
:param sites: 站点ID列表为空时搜索所有站点
:param rule_groups: 过滤规则组名称列表
:param area: 搜索范围title or imdbid
:param custom_words: 自定义识别词列表
:param filter_params: 过滤参数
"""
# 豆瓣标题处理
if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title)
mediainfo.title = meta.name
mediainfo.season = meta.begin_season
logger.info(f'开始搜索资源,关键词:{keyword or mediainfo.title} ...')
# 补充媒体信息
if not mediainfo.names:
mediainfo: MediaInfo = await self.async_recognize_media(mtype=mediainfo.type,
tmdbid=mediainfo.tmdb_id,
doubanid=mediainfo.douban_id)
if not mediainfo:
logger.error(f'媒体信息识别失败!')
return []
# 准备搜索参数
season_episodes, keywords = self.__prepare_params(
mediainfo=mediainfo,
keyword=keyword,
no_exists=no_exists
)
# 站点搜索结果
torrents: List[TorrentInfo] = []
# 站点搜索次数
search_count = 0
# 多关键字执行搜索
for search_word in keywords:
# 强制休眠 1-10 秒
if search_count > 0:
logger.info(f"已搜索 {search_count} 次,强制休眠 1-10 秒 ...")
await asyncio.sleep(random.randint(1, 10))
# 搜索站点
torrents.extend(
await self.__async_search_all_sites(
mediainfo=mediainfo,
keyword=search_word,
sites=sites,
area=area
) or []
)
search_count += 1
# 有结果则停止
if torrents:
logger.info(f"共搜索到 {len(torrents)} 个资源,停止搜索")
break
# 处理结果
return await run_in_threadpool(self.__parse_result,
torrents=torrents,
mediainfo=mediainfo,
keyword=keyword,
rule_groups=rule_groups,
season_episodes=season_episodes,
custom_words=custom_words,
filter_params=filter_params
)
def __search_all_sites(self, keyword: str,
def __search_all_sites(self, keywords: List[str],
mediainfo: Optional[MediaInfo] = None,
sites: List[int] = None,
page: Optional[int] = 0,
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
page: int = 0,
area: str = "title") -> Optional[List[TorrentInfo]]:
"""
多线程搜索多个站点
:param mediainfo: 识别的媒体信息
:param keyword: 搜索关键词
:param keywords: 搜索关键词列表
:param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点
:param page: 搜索页码
:param area: 搜索区域 title or imdbid
@@ -505,19 +289,23 @@ class SearchChain(ChainBase):
# 配置的索引站点
if not sites:
sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
sites = self.systemconfig.get(SystemConfigKey.IndexerSites) or []
for indexer in SitesHelper().get_indexers():
for indexer in self.siteshelper.get_indexers():
# 检查站点索引开关
if not sites or indexer.get("id") in sites:
# 站点流控
state, msg = self.siteshelper.check(indexer.get("domain"))
if state:
logger.warn(msg)
continue
indexer_sites.append(indexer)
if not indexer_sites:
logger.warn('未开启任何有效站点,无法搜索资源')
return []
# 开始进度
progress = ProgressHelper(ProgressKey.Search)
progress.start()
self.progress.start(ProgressKey.Search)
# 开始计时
start_time = datetime.now()
# 总数
@@ -525,8 +313,9 @@ class SearchChain(ChainBase):
# 完成数
finish_count = 0
# 更新进度
progress.update(value=0,
text=f"开始搜索,共 {total_num} 个站点 ...")
self.progress.update(value=0,
text=f"开始搜索,共 {total_num} 个站点 ...",
key=ProgressKey.Search)
# 结果集
results = []
# 多线程
@@ -536,122 +325,64 @@ class SearchChain(ChainBase):
if area == "imdbid":
# 搜索IMDBID
task = executor.submit(self.search_torrents, site=site,
keyword=mediainfo.imdb_id if mediainfo else None,
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,
keyword=keyword,
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"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...")
self.progress.update(value=finish_count / total_num * 100,
text=f"正在搜索{keywords or ''},已完成 {finish_count} / {total_num} 个站点 ...",
key=ProgressKey.Search)
# 计算耗时
end_time = datetime.now()
# 更新进度
progress.update(value=100,
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds}")
self.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}")
# 结束进度
progress.end()
self.progress.end(ProgressKey.Search)
# 返回
return results
async def __async_search_all_sites(self, keyword: str,
mediainfo: Optional[MediaInfo] = None,
sites: List[int] = None,
page: Optional[int] = 0,
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
def filter_torrents_by_rule(self,
torrents: List[TorrentInfo],
mediainfo: MediaInfo,
filter_rule: Dict[str, str] = None,
) -> List[TorrentInfo]:
"""
异步搜索多个站点
:param mediainfo: 识别的媒体信息
:param keyword: 搜索关键词
:param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点
:param page: 搜索页码
:param area: 搜索区域 title or imdbid
:reutrn: 资源列表
使用过滤规则过滤种子
:param torrents: 种子列表
:param filter_rule: 过滤规则
:param mediainfo: 媒体信息
"""
# 未开启的站点不搜索
indexer_sites = []
# 配置的索引站点
if not sites:
sites = SystemConfigOper().get(SystemConfigKey.IndexerSites) or []
if not filter_rule:
# 没有则取搜索默认过滤规则
filter_rule = self.systemconfig.get(SystemConfigKey.DefaultSearchFilterRules)
if not filter_rule:
return torrents
for indexer in await SitesHelper().async_get_indexers():
# 检查站点索引开关
if not sites or indexer.get("id") in sites:
indexer_sites.append(indexer)
if not indexer_sites:
logger.warn('未开启任何有效站点,无法搜索资源')
return []
# 开始进度
progress = ProgressHelper(ProgressKey.Search)
progress.start()
# 开始计时
start_time = datetime.now()
# 总数
total_num = len(indexer_sites)
# 完成数
finish_count = 0
# 更新进度
progress.update(value=0,
text=f"开始搜索,共 {total_num} 个站点 ...")
# 结果集
results = []
# 创建异步任务列表
tasks = []
for site in indexer_sites:
if area == "imdbid":
# 搜索IMDBID
task = self.async_search_torrents(site=site,
keyword=mediainfo.imdb_id if mediainfo else None,
mtype=mediainfo.type if mediainfo else None,
page=page)
else:
# 搜索标题
task = self.async_search_torrents(site=site,
keyword=keyword,
mtype=mediainfo.type if mediainfo else None,
page=page)
tasks.append(task)
# 使用asyncio.as_completed来处理并发任务
for future in asyncio.as_completed(tasks):
if global_vars.is_system_stopped:
break
finish_count += 1
result = await future
if result:
results.extend(result)
logger.info(f"站点搜索进度:{finish_count} / {total_num}")
progress.update(value=finish_count / total_num * 100,
text=f"正在搜索{keyword or ''},已完成 {finish_count} / {total_num} 个站点 ...")
# 计算耗时
end_time = datetime.now()
# 更新进度
progress.update(value=100,
text=f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds}")
logger.info(f"站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds}")
# 结束进度
progress.end()
# 返回
return results
# 使用默认过滤规则再次过滤
return list(filter(
lambda t: self.torrenthelper.filter_torrent(
torrent_info=t,
filter_rule=filter_rule,
mediainfo=mediainfo
),
torrents
))
@eventmanager.register(EventType.SiteDeleted)
def remove_site(self, event: Event):

View File

@@ -1,26 +1,30 @@
import base64
import re
from datetime import datetime
from typing import Optional, Tuple, Union, Dict
from typing import Tuple, Optional
from typing import Union
from urllib.parse import urljoin
from lxml import etree
from app.chain import ChainBase
from app.core.config import global_vars, settings
from app.core.event import Event, eventmanager
from app.core.config import settings
from app.core.event import eventmanager, Event, EventManager
from app.db.models.site import Site
from app.db.site_oper import SiteOper
from app.db.siteicon_oper import SiteIconOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.sitestatistic_oper import SiteStatisticOper
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 # noqa
from app.helper.sites import SitesHelper
from app.log import logger
from app.schemas import MessageChannel, Notification, SiteUserData
from app.schemas.types import EventType, NotificationType
from app.schemas import MessageChannel, Notification
from app.schemas.types import EventType
from app.utils.http import RequestUtils
from app.utils.site import SiteUtils
from app.utils.string import StringUtils
@@ -33,6 +37,15 @@ class SiteChain(ChainBase):
def __init__(self):
super().__init__()
self.siteoper = SiteOper()
self.siteiconoper = SiteIconOper()
self.siteshelper = SitesHelper()
self.rsshelper = RssHelper()
self.cookiehelper = CookieHelper()
self.message = MessageHelper()
self.cookiecloud = CookieCloudHelper()
self.systemconfig = SystemConfigOper()
self.sitestatistic = SiteStatisticOper()
# 特殊站点登录验证
self.special_site_test = {
@@ -43,72 +56,8 @@ class SiteChain(ChainBase):
"1ptba.com": self.__indexphp_test,
"star-space.net": self.__indexphp_test,
"yemapt.org": self.__yema_test,
"hddolby.com": self.__hddolby_test,
}
def refresh_userdata(self, site: dict = None) -> Optional[SiteUserData]:
"""
刷新站点的用户数据
:param site: 站点
:return: 用户数据
"""
userdata: SiteUserData = self.run_module("refresh_userdata", site=site)
if userdata:
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")
})
# 发送站点消息
if userdata.message_unread:
if userdata.message_unread_contents and len(userdata.message_unread_contents) > 0:
for head, date, content in userdata.message_unread_contents:
msg_title = f"【站点 {site.get('name')} 消息】"
msg_text = f"时间:{date}\n标题:{head}\n内容:\n{content}"
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=msg_title, text=msg_text, link=site.get("url")
))
else:
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"站点 {site.get('name')} 收到 "
f"{userdata.message_unread} 条新消息,请登陆查看",
link=site.get("url")
))
# 低分享率警告
if userdata.ratio and float(userdata.ratio) < 1 and not bool(
re.search(r"(贵宾|VIP?)", userdata.user_level or "", re.IGNORECASE)):
self.post_message(Notification(
mtype=NotificationType.SiteMessage,
title=f"【站点分享率低预警】",
text=f"站点 {site.get('name')} 分享率 {userdata.ratio},请注意!"
))
return userdata
def refresh_userdatas(self) -> Optional[Dict[str, SiteUserData]]:
"""
刷新所有站点的用户数据
"""
any_site_updated = False
result = {}
for site in SitesHelper().get_indexers():
if global_vars.is_system_stopped:
return None
if site.get("is_active"):
userdata = self.refresh_userdata(site)
if userdata:
any_site_updated = True
result[site.get("name")] = userdata
if any_site_updated:
eventmanager.send_event(EventType.SiteRefreshed, {
"site_id": "*"
})
return result
def is_special_site(self, domain: str) -> bool:
"""
判断是否特殊站点
@@ -129,14 +78,10 @@ class SiteChain(ChainBase):
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=site.url)
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
if res and res.status_code == 200:
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
if csrf_token:
token = csrf_token.group(1)
else:
return False, f"错误:{res.status_code} {res.reason}"
if not token:
return False, "无法获取Token"
# 调用查询用户信息接口
@@ -150,15 +95,11 @@ class SiteChain(ChainBase):
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=f"{site.url}api/user/getInfo")
if user_res is None:
return False, "无法打开网站!"
if user_res.status_code == 200:
if user_res and user_res.status_code == 200:
user_info = user_res.json()
if user_info and user_info.get("data"):
return True, "连接成功"
return False, "Cookie已失效"
else:
return False, f"错误:{user_res.status_code} {user_res.reason}"
return False, "Cookie已失效"
@staticmethod
def __mteam_test(site: Site) -> Tuple[bool, str]:
@@ -206,15 +147,11 @@ class SiteChain(ChainBase):
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=url)
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
if res and res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("success"):
return True, "连接成功"
return False, "Cookie已过期"
else:
return False, f"错误:{res.status_code} {res.reason}"
return False, "Cookie已过期"
def __indexphp_test(self, site: Site) -> Tuple[bool, str]:
"""
@@ -223,32 +160,6 @@ class SiteChain(ChainBase):
site.url = f"{site.url}index.php"
return self.__test(site)
@staticmethod
def __hddolby_test(site: Site) -> Tuple[bool, str]:
"""
判断站点是否已经登陆hddolby
"""
url = f"{site.url}api/v1/user/data"
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/plain, */*",
"x-api-key": site.apikey,
}
res = RequestUtils(
headers=headers,
proxies=settings.PROXY if site.proxy else None,
timeout=site.timeout or 15
).get_res(url=url)
if res is None:
return False, "无法打开网站!"
if res.status_code == 200:
user_info = res.json()
if user_info and user_info.get("status") == 0:
return True, "连接成功"
return False, "APIKEY已过期"
else:
return False, f"错误:{res.status_code} {res.reason}"
@staticmethod
def __parse_favicon(url: str, cookie: str, ua: str) -> Tuple[str, Optional[str]]:
"""
@@ -266,20 +177,16 @@ class SiteChain(ChainBase):
logger.error(f"获取站点页面失败:{url}")
return favicon_url, None
html = etree.HTML(html_text)
try:
if StringUtils.is_valid_html_element(html):
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
if fav_link:
favicon_url = urljoin(url, fav_link[0])
if html:
fav_link = html.xpath('//head/link[contains(@rel, "icon")]/@href')
if fav_link:
favicon_url = urljoin(url, fav_link[0])
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
logger.error(f"获取站点图标失败:{favicon_url}")
finally:
if html is not None:
del html
res = RequestUtils(cookies=cookie, timeout=15, ua=ua).get_res(url=favicon_url)
if res:
return favicon_url, base64.b64encode(res.content).decode()
else:
logger.error(f"获取站点图标失败:{favicon_url}")
return favicon_url, None
def sync_cookies(self, manual=False) -> Tuple[bool, str]:
@@ -293,36 +200,28 @@ 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 = CookieCloudHelper().download()
cookies, msg = self.cookiecloud.download()
if not cookies:
logger.error(f"CookieCloud同步失败{msg}")
if manual:
self.messagehelper.put(msg, title="CookieCloud同步失败", role="system")
self.message.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():
# 检查系统是否停止
if global_vars.is_system_stopped:
logger.info("系统正在停止中断CookieCloud同步")
return False, "系统正在停止,同步被中断"
# 索引器信息
indexer = siteshelper.get_indexer(domain)
indexer = self.siteshelper.get_indexer(domain)
# 数据库的站点信息
site_info = siteoper.get_by_domain(domain)
if site_info and site_info.is_active:
site_info = self.siteoper.get_by_domain(domain)
if site_info and site_info.is_active == 1:
# 站点已存在,检查站点连通性
status, msg = self.test(domain)
# 更新站点Cookie
@@ -331,22 +230,21 @@ class SiteChain(ChainBase):
# 更新站点rss地址
if not site_info.public and not site_info.rss:
# 自动生成rss地址
rss_url, errmsg = rsshelper.get_rss_link(
rss_url, errmsg = self.rsshelper.get_rss_link(
url=site_info.url,
cookie=cookie,
ua=site_info.ua or settings.USER_AGENT,
proxy=True if site_info.proxy else False,
timeout=site_info.timeout or 15
proxy=True if site_info.proxy else False
)
if rss_url:
logger.info(f"更新站点 {domain} RSS地址 ...")
siteoper.update_rss(domain=domain, rss=rss_url)
self.siteoper.update_rss(domain=domain, rss=rss_url)
else:
logger.warn(errmsg)
continue
# 更新站点Cookie
logger.info(f"更新站点 {domain} Cookie ...")
siteoper.update_cookie(domain=domain, cookies=cookie)
self.siteoper.update_cookie(domain=domain, cookies=cookie)
_update_count += 1
elif indexer:
if settings.COOKIECLOUD_BLACKLIST and any(
@@ -356,15 +254,13 @@ class SiteChain(ChainBase):
continue
# 新增站点
domain_url = __indexer_domain(inx=indexer, sub_domain=domain)
proxy = False
res = RequestUtils(cookies=cookie,
ua=settings.USER_AGENT
).get_res(url=domain_url)
if res and res.status_code in [200, 500, 403]:
content = res.text
if not indexer.get("public") and not SiteUtils.is_logged_in(content):
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
_fail_count += 1
if under_challenge(content):
if under_challenge(res.text):
logger.warn(f"站点 {indexer.get('name')} 被Cloudflare防护无法登录无法添加站点")
continue
logger.warn(
@@ -375,53 +271,31 @@ class SiteChain(ChainBase):
logger.warn(f"站点 {indexer.get('name')} 连接状态码:{res.status_code},无法添加站点")
continue
else:
if not settings.PROXY_HOST:
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
continue
else:
# 如果配置了代理,尝试通过代理重试
logger.info(f"站点 {indexer.get('name')} 初次连接失败,尝试通过代理重试...")
proxy = True
res = RequestUtils(cookies=cookie,
ua=settings.USER_AGENT,
proxies=settings.PROXY
).get_res(url=domain_url)
if res and res.status_code in [200, 500, 403]:
if not indexer.get("public") and not SiteUtils.is_logged_in(res.text):
logger.warn(f"站点 {indexer.get('name')} 登录失败,即使通过代理,无法添加站点")
_fail_count += 1
continue
logger.info(f"站点 {indexer.get('name')} 通过代理连接成功")
else:
logger.warn(f"站点 {indexer.get('name')} 通过代理连接失败,无法添加站点")
_fail_count += 1
continue
_fail_count += 1
logger.warn(f"站点 {indexer.get('name')} 连接失败,无法添加站点")
continue
# 获取rss地址
rss_url = None
if not indexer.get("public") and domain_url:
# 自动生成rss地址
rss_url, errmsg = rsshelper.get_rss_link(url=domain_url,
cookie=cookie,
ua=settings.USER_AGENT,
proxy=proxy)
rss_url, errmsg = self.rsshelper.get_rss_link(url=domain_url,
cookie=cookie,
ua=settings.USER_AGENT)
if errmsg:
logger.warn(errmsg)
# 插入数据库
logger.info(f"新增站点 {indexer.get('name')} ...")
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)
self.siteoper.add(name=indexer.get("name"),
url=domain_url,
domain=domain,
cookie=cookie,
rss=rss_url,
public=1 if indexer.get("public") else 0)
_add_count += 1
# 通知站点更新
if indexer:
eventmanager.send_event(EventType.SiteUpdated, {
EventManager().send_event(EventType.SiteUpdated, {
"domain": domain,
})
# 处理完成
@@ -429,7 +303,7 @@ class SiteChain(ChainBase):
if _fail_count > 0:
ret_msg += f"{_fail_count}个站点添加失败,下次同步时将重试,也可以手动添加"
if manual:
self.messagehelper.put(ret_msg, title="CookieCloud同步成功", role="system")
self.message.put(ret_msg, title="CookieCloud同步成功", role="system")
logger.info(f"CookieCloud同步成功{ret_msg}")
return True, ret_msg
@@ -448,31 +322,29 @@ class SiteChain(ChainBase):
if str(domain).startswith("http"):
domain = StringUtils.get_url_domain(domain)
# 站点信息
siteoper = SiteOper()
siteshelper = SitesHelper()
siteinfo = siteoper.get_by_domain(domain)
siteinfo = self.siteoper.get_by_domain(domain)
if not siteinfo:
logger.warn(f"未维护站点 {domain} 信息!")
return
# Cookie
cookie = siteinfo.cookie
# 索引器
indexer = siteshelper.get_indexer(domain)
indexer = self.siteshelper.get_indexer(domain)
if not indexer:
logger.warn(f"站点 {domain} 索引器不存在!")
return
# 查询站点图标
site_icon = siteoper.get_icon_by_domain(domain)
site_icon = self.siteiconoper.get_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:
siteoper.update_icon(name=indexer.get("name"),
domain=domain,
icon_url=icon_url,
icon_base64=icon_base64)
self.siteiconoper.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')} 图标失败")
@@ -492,32 +364,11 @@ class SiteChain(ChainBase):
# 获取主域名中间那段
domain_host = StringUtils.get_url_host(domain)
# 查询以"site.domain_host"开头的配置项,并清除
systemconfig = SystemConfigOper()
site_keys = systemconfig.all().keys()
site_keys = self.systemconfig.all().keys()
for key in site_keys:
if key.startswith(f"site.{domain_host}"):
logger.info(f"清理站点配置:{key}")
systemconfig.delete(key)
@eventmanager.register(EventType.SiteUpdated)
def cache_site_userdata(self, event: Event):
"""
缓存站点用户数据
"""
if not event:
return
event_data = event.event_data or {}
# 主域名
domain = event_data.get("domain")
if not domain:
return
if str(domain).startswith("http"):
domain = StringUtils.get_url_domain(domain)
indexer = SitesHelper().get_indexer(domain)
if not indexer:
return
# 刷新站点用户数据
self.refresh_userdata(site=indexer) or {}
self.systemconfig.delete(key)
def test(self, url: str) -> Tuple[bool, str]:
"""
@@ -527,8 +378,7 @@ class SiteChain(ChainBase):
"""
# 检查域名是否可用
domain = StringUtils.get_url_domain(url)
siteoper = SiteOper()
site_info = siteoper.get_by_domain(domain)
site_info = self.siteoper.get_by_domain(domain)
if not site_info:
return False, f"站点【{url}】不存在"
@@ -545,9 +395,9 @@ class SiteChain(ChainBase):
# 统计
seconds = (datetime.now() - start_time).seconds
if state:
siteoper.success(domain=domain, seconds=seconds)
self.sitestatistic.success(domain=domain, seconds=seconds)
else:
siteoper.fail(domain)
self.sitestatistic.fail(domain)
return state, message
except Exception as e:
return False, f"{str(e)}"
@@ -564,15 +414,13 @@ class SiteChain(ChainBase):
public = site_info.public
proxies = settings.PROXY if site_info.proxy else None
proxy_server = settings.PROXY_SERVER if site_info.proxy else None
timeout = site_info.timeout or 60
# 访问链接
if render:
page_source = PlaywrightHelper().get_page_source(url=site_url,
cookies=site_cookie,
ua=ua,
proxies=proxy_server,
timeout=timeout)
proxies=proxy_server)
if not public and not SiteUtils.is_logged_in(page_source):
if under_challenge(page_source):
return False, f"无法通过Cloudflare"
@@ -584,29 +432,27 @@ class SiteChain(ChainBase):
).get_res(url=site_url)
# 判断登录状态
if res and res.status_code in [200, 500, 403]:
content = res.text
if not public and not SiteUtils.is_logged_in(content):
if under_challenge(content):
if not public and not SiteUtils.is_logged_in(res.text):
if under_challenge(res.text):
msg = "站点被Cloudflare防护请打开站点浏览器仿真"
elif res.status_code == 200:
msg = "Cookie已失效"
else:
msg = f"错误{res.status_code} {res.reason}"
msg = f"状态码{res.status_code}"
return False, f"{msg}"
elif public and res.status_code != 200:
return False, f"错误{res.status_code} {res.reason}"
return False, f"状态码{res.status_code}"
elif res is not None:
return False, f"错误{res.status_code} {res.reason}"
return False, f"状态码{res.status_code}"
else:
return False, f"无法打开网站!"
return True, "连接成功"
def remote_list(self, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
def remote_list(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
查询所有站点,发送消息
"""
site_list = SiteOper().list()
site_list = self.siteoper.list()
if not site_list:
self.post_message(Notification(
channel=channel,
@@ -630,13 +476,10 @@ class SiteChain(ChainBase):
# 发送列表
self.post_message(Notification(
channel=channel,
source=source,
title=title, text="\n".join(messages), userid=userid,
link=settings.MP_DOMAIN('#/site'))
)
link=settings.MP_DOMAIN('#/site')))
def remote_disable(self, arg_str: str, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
def remote_disable(self, arg_str, channel: MessageChannel, userid: Union[str, int] = None):
"""
禁用站点
"""
@@ -646,8 +489,7 @@ class SiteChain(ChainBase):
if not arg_str.isdigit():
return
site_id = int(arg_str)
siteoper = SiteOper()
site = siteoper.get(site_id)
site = self.siteoper.get(site_id)
if not site:
self.post_message(Notification(
channel=channel,
@@ -655,42 +497,39 @@ class SiteChain(ChainBase):
userid=userid))
return
# 禁用站点
siteoper.update(site_id, {
self.siteoper.update(site_id, {
"is_active": False
})
# 重新发送消息
self.remote_list(channel=channel, userid=userid, source=source)
self.remote_list(channel, userid)
def remote_enable(self, arg_str: str, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
def remote_enable(self, arg_str, channel: MessageChannel, userid: Union[str, int] = None):
"""
启用站点
"""
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 = siteoper.get(site_id)
site = self.siteoper.get(site_id)
if not site:
self.post_message(Notification(
channel=channel,
title=f"站点编号 {site_id} 不存在!", userid=userid))
return
# 禁用站点
siteoper.update(site_id, {
self.siteoper.update(site_id, {
"is_active": True
})
# 重新发送消息
self.remote_list(channel=channel, userid=userid, source=source)
self.remote_list(channel, userid)
@staticmethod
def update_cookie(site_info: Site,
username: str, password: str, two_step_code: Optional[str] = None) -> Tuple[bool, str]:
def update_cookie(self, site_info: Site,
username: str, password: str, two_step_code: str = None) -> Tuple[bool, str]:
"""
根据用户名密码更新站点Cookie
:param site_info: 站点信息
@@ -700,27 +539,25 @@ class SiteChain(ChainBase):
:return: (是否成功, 错误信息)
"""
# 更新站点Cookie
result = CookieHelper().get_site_cookie_ua(
result = self.cookiehelper.get_site_cookie_ua(
url=site_info.url,
username=username,
password=password,
two_step_code=two_step_code,
proxies=settings.PROXY_SERVER if site_info.proxy else None,
timeout=site_info.timeout or 60
proxies=settings.PROXY_HOST if site_info.proxy else None
)
if result:
cookie, ua, msg = result
if not cookie:
return False, msg
SiteOper().update(site_info.id, {
self.siteoper.update(site_info.id, {
"cookie": cookie,
"ua": ua
})
return True, msg
return False, "未知错误"
def remote_cookie(self, arg_str: str, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
def remote_cookie(self, arg_str: str, channel: MessageChannel, userid: Union[str, int] = None):
"""
使用用户名密码更新站点Cookie
"""
@@ -729,7 +566,6 @@ class SiteChain(ChainBase):
if not arg_str:
self.post_message(Notification(
channel=channel,
source=source,
title=err_title, userid=userid))
return
arg_str = str(arg_str).strip()
@@ -741,29 +577,25 @@ class SiteChain(ChainBase):
elif len(args) != 3:
self.post_message(Notification(
channel=channel,
source=source,
title=err_title, userid=userid))
return
site_id = args[0]
if not site_id.isdigit():
self.post_message(Notification(
channel=channel,
source=source,
title=err_title, userid=userid))
return
# 站点ID
site_id = int(site_id)
# 站点信息
site_info = SiteOper().get(site_id)
site_info = self.siteoper.get(site_id)
if not site_info:
self.post_message(Notification(
channel=channel,
source=source,
title=f"站点编号 {site_id} 不存在!", userid=userid))
return
self.post_message(Notification(
channel=channel,
source=source,
title=f"开始更新【{site_info.name}】Cookie&UA ...", userid=userid))
# 用户名
username = args[1]
@@ -778,76 +610,11 @@ class SiteChain(ChainBase):
logger.error(msg)
self.post_message(Notification(
channel=channel,
source=source,
title=f"{site_info.name}】 Cookie&UA更新失败",
text=f"错误原因:{msg}",
userid=userid))
else:
self.post_message(Notification(
channel=channel,
source=source,
title=f"{site_info.name}】 Cookie&UA更新成功",
userid=userid))
def remote_refresh_userdatas(self, channel: MessageChannel,
userid: Union[str, int] = None, source: Optional[str] = None):
"""
刷新所有站点用户数据
"""
logger.info("收到命令,开始刷新站点数据 ...")
self.post_message(Notification(
channel=channel,
source=source,
title="开始刷新站点数据 ...",
userid=userid
))
# 刷新站点数据
site_datas = self.refresh_userdatas()
if site_datas:
# 发送消息
messages = {}
# 总上传
incUploads = 0
# 总下载
incDownloads = 0
# 今天日期
today_date = datetime.now().strftime("%Y-%m-%d")
for rand, site in enumerate(site_datas.keys()):
upload = int(site_datas[site].upload or 0)
download = int(site_datas[site].download or 0)
updated_date = site_datas[site].updated_day
if updated_date and updated_date != today_date:
updated_date = f"{updated_date}"
else:
updated_date = ""
if upload > 0 or download > 0:
incUploads += upload
incDownloads += download
messages[upload + (rand / 1000)] = (
f"{site}{updated_date}\n"
+ f"上传量:{StringUtils.str_filesize(upload)}\n"
+ f"下载量:{StringUtils.str_filesize(download)}\n"
+ "————————————"
)
if incDownloads or incUploads:
sorted_messages = [messages[key] for key in sorted(messages.keys(), reverse=True)]
sorted_messages.insert(0, f"【汇总】\n"
f"总上传:{StringUtils.str_filesize(incUploads)}\n"
f"总下载:{StringUtils.str_filesize(incDownloads)}\n"
f"————————————")
self.post_message(Notification(
channel=channel,
source=source,
title="【站点数据统计】",
text="\n".join(sorted_messages),
userid=userid
))
else:
self.post_message(Notification(
channel=channel,
source=source,
title="没有刷新到任何站点数据!",
userid=userid
))

View File

@@ -1,211 +0,0 @@
from pathlib import Path
from typing import Optional, Tuple, List, Dict
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.helper.directory import DirectoryHelper
from app.log import logger
class StorageChain(ChainBase):
"""
存储处理链
"""
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]]:
"""
生成二维码
"""
return self.run_module("generate_qrcode", storage=storage)
def check_login(self, storage: str, **kwargs) -> Optional[Tuple[dict, str]]:
"""
登录确认
"""
return self.run_module("check_login", storage=storage, **kwargs)
def list_files(self, fileitem: schemas.FileItem, recursion: bool = False) -> Optional[List[schemas.FileItem]]:
"""
查询当前目录下所有目录和文件
"""
return self.run_module("list_files", fileitem=fileitem, recursion=recursion)
def any_files(self, fileitem: schemas.FileItem, extensions: list = None) -> Optional[bool]:
"""
查询当前目录下是否存在指定扩展名任意文件
"""
return self.run_module("any_files", fileitem=fileitem, extensions=extensions)
def create_folder(self, fileitem: schemas.FileItem, name: str) -> Optional[schemas.FileItem]:
"""
创建目录
"""
return self.run_module("create_folder", fileitem=fileitem, name=name)
def download_file(self, fileitem: schemas.FileItem, path: Path = None) -> Optional[Path]:
"""
下载文件
:param fileitem: 文件项
:param path: 本地保存路径
"""
return self.run_module("download_file", fileitem=fileitem, path=path)
def upload_file(self, fileitem: schemas.FileItem, path: Path,
new_name: Optional[str] = None) -> Optional[schemas.FileItem]:
"""
上传文件
:param fileitem: 保存目录项
:param path: 本地文件路径
:param new_name: 新文件名
"""
return self.run_module("upload_file", fileitem=fileitem, path=path, new_name=new_name)
def delete_file(self, fileitem: schemas.FileItem) -> Optional[bool]:
"""
删除文件或目录
"""
return self.run_module("delete_file", fileitem=fileitem)
def rename_file(self, fileitem: schemas.FileItem, name: str) -> Optional[bool]:
"""
重命名文件或目录
"""
return self.run_module("rename_file", fileitem=fileitem, name=name)
def exists(self, fileitem: schemas.FileItem) -> Optional[bool]:
"""
判断文件或目录是否存在
"""
return True if self.get_item(fileitem) else False
def get_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
查询目录或文件
"""
return self.get_file_item(storage=fileitem.storage, path=Path(fileitem.path))
def get_file_item(self, storage: str, path: Path) -> Optional[schemas.FileItem]:
"""
根据路径获取文件项
"""
return self.run_module("get_file_item", storage=storage, path=path)
def get_parent_item(self, fileitem: schemas.FileItem) -> Optional[schemas.FileItem]:
"""
获取上级目录项
"""
return self.run_module("get_parent_item", fileitem=fileitem)
def snapshot_storage(self, storage: str, path: Path,
last_snapshot_time: float = None, max_depth: int = 5) -> Optional[Dict[str, Dict]]:
"""
快照存储
:param storage: 存储类型
:param path: 路径
:param last_snapshot_time: 上次快照时间,用于增量快照
:param max_depth: 最大递归深度,避免过深遍历
"""
return self.run_module("snapshot_storage", storage=storage, path=path,
last_snapshot_time=last_snapshot_time, max_depth=max_depth)
def storage_usage(self, storage: str) -> Optional[schemas.StorageUsage]:
"""
存储使用情况
"""
return self.run_module("storage_usage", storage=storage)
def support_transtype(self, storage: str) -> Optional[dict]:
"""
获取支持的整理方式
"""
return self.run_module("support_transtype", storage=storage)
def delete_media_file(self, fileitem: schemas.FileItem, delete_self: bool = True) -> bool:
"""
删除媒体文件,以及不含媒体文件的目录
"""
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
fileitem_path = Path(fileitem.path) if fileitem.path else Path("")
if len(fileitem_path.parts) <= 2:
logger.warn(f"{fileitem.storage}{fileitem.path} 根目录或一级目录不允许删除")
return False
if fileitem.type == "dir":
# 本身是目录
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 delete_self:
# 本身是文件,需要删除文件
logger.warn(f"正在删除文件【{fileitem.storage}{fileitem.path}")
if not self.delete_file(fileitem):
logger.warn(f"{fileitem.storage}{fileitem.path} 删除失败")
return False
# 检查和删除上级空目录
dir_item = fileitem if fileitem.type == "dir" else self.get_parent_item(fileitem)
if not dir_item:
logger.warn(f"{fileitem.storage}{fileitem.path} 上级目录不存在")
return False
# 查找操作文件项匹配的配置目录(资源目录、媒体库目录)
associated_dir = max(
(
Path(p)
for d in DirectoryHelper().get_dirs()
for p in (d.download_path, d.library_path)
if p and fileitem_path.is_relative_to(p)
),
key=lambda path: len(path.parts),
default=None,
)
while dir_item and len(Path(dir_item.path).parts) > 2:
# 目录是资源目录、媒体库目录的上级,则不处理
if associated_dir and associated_dir.is_relative_to(Path(dir_item.path)):
logger.debug(f"{dir_item.storage}{dir_item.path} 位于资源或媒体库目录结构中,不删除")
break
elif not associated_dir and self.list_files(dir_item, recursion=False):
logger.debug(f"{dir_item.storage}{dir_item.path} 不是空目录,不删除")
break
if self.any_files(dir_item, extensions=media_exts) is not False:
logger.debug(f"{dir_item.storage}{dir_item.path} 存在媒体文件,不删除")
break
# 删除空目录并继续处理父目录
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
dir_item = self.get_parent_item(dir_item)
return True

File diff suppressed because it is too large Load Diff

View File

@@ -1,162 +1,45 @@
import json
import re
import shutil
from pathlib import Path
from typing import Union, Optional
from typing import Union
from app.chain import ChainBase
from app.core.config import settings
from app.core.plugin import PluginManager
from app.helper.system import SystemHelper
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 version import FRONTEND_VERSION, APP_VERSION
class SystemChain(ChainBase):
class SystemChain(ChainBase, metaclass=Singleton):
"""
系统级处理链
"""
_restart_file = "__system_restart__"
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
def remote_clear_cache(self, channel: MessageChannel, userid: Union[int, str]):
"""
清理系统缓存
"""
self.clear_cache()
self.post_message(Notification(channel=channel, source=source,
self.post_message(Notification(channel=channel,
title=f"缓存清理完成!", userid=userid))
def restart(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
def restart(self, channel: MessageChannel, userid: Union[int, str]):
"""
重启系统
"""
from app.core.config import global_vars
if channel and userid:
self.post_message(Notification(channel=channel, source=source,
self.post_message(Notification(channel=channel,
title="系统正在重启,请耐心等候!", userid=userid))
# 保存重启信息
self.save_cache({
"channel": channel.value,
"userid": userid
}, self._restart_file)
# 主动备份一次插件
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} 个项目")
# 安装缺少的依赖
PluginManager.install_plugin_missing_dependencies()
# 删除备份目录
try:
shutil.rmtree(backup_dir)
logger.info(f"已删除插件备份目录: {backup_dir}")
except Exception as e:
logger.warning(f"删除备份目录失败: {str(e)}")
SystemUtils.restart()
def __get_version_message(self) -> str:
"""
@@ -176,11 +59,11 @@ class SystemChain(ChainBase):
title += f"当前前端版本:{front_local_version},远程版本:{front_release_version}"
return title
def version(self, channel: MessageChannel, userid: Union[int, str], source: Optional[str] = None):
def version(self, channel: MessageChannel, userid: Union[int, str]):
"""
查看当前版本、远程版本
"""
self.post_message(Notification(channel=channel, source=source,
self.post_message(Notification(channel=channel,
title=self.__get_version_message(),
userid=userid))
@@ -210,63 +93,60 @@ class SystemChain(ChainBase):
@staticmethod
def __get_server_release_version():
"""
获取后端V2最新版本
获取后端最新版本
"""
try:
# 获取所有发布的版本列表
response = RequestUtils(
proxies=settings.PROXY,
headers=settings.GITHUB_HEADERS
).get_res("https://api.github.com/repos/jxxghp/MoviePilot/releases")
if response:
releases = [release['tag_name'] for release in response.json()]
v2_releases = [tag for tag in releases if re.match(r"^v2\.", tag)]
if not v2_releases:
logger.warn("获取v2后端最新版本版本出错")
else:
# 找到最新的v2版本
latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s))))[-1]
logger.info(f"获取到后端最新版本:{latest_v2}")
return latest_v2
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
logger.error("无法获取后端版本信息请检查网络连接或GitHub API请求。")
return None
except Exception as err:
logger.error(f"获取后端最新版本失败:{str(err)}")
return None
return None
@staticmethod
def __get_front_release_version():
"""
获取前端V2最新版本
获取前端最新版本
"""
try:
# 获取所有发布的版本列表
response = RequestUtils(
proxies=settings.PROXY,
headers=settings.GITHUB_HEADERS
).get_res("https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases")
if response:
releases = [release['tag_name'] for release in response.json()]
v2_releases = [tag for tag in releases if re.match(r"^v2\.", tag)]
if not v2_releases:
logger.warn("获取v2前端最新版本版本出错")
else:
# 找到最新的v2版本
latest_v2 = sorted(v2_releases, key=lambda s: list(map(int, re.findall(r'\d+', s))))[-1]
logger.info(f"获取到前端最新版本:{latest_v2}")
return latest_v2
version_res = RequestUtils(proxies=settings.PROXY, headers=settings.GITHUB_HEADERS).get_res(
"https://api.github.com/repos/jxxghp/MoviePilot-Frontend/releases/latest")
if version_res:
ver_json = version_res.json()
version = f"{ver_json['tag_name']}"
return version
else:
logger.error("无法获取前端版本信息请检查网络连接或GitHub API请求。")
return None
except Exception as err:
logger.error(f"获取前端最新版本失败:{str(err)}")
return None
return None
@staticmethod
def get_server_local_version():
"""
查看当前版本
"""
return APP_VERSION
version_file = settings.ROOT_PATH / "version.py"
if version_file.exists():
try:
with open(version_file, 'rb') as f:
version = f.read()
pattern = r"'([^']*)'"
match = re.search(pattern, str(version))
if match:
version = match.group(1)
return version
else:
logger.warn("未找到版本号")
return None
except Exception as err:
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
@staticmethod
def get_frontend_version():
@@ -283,5 +163,7 @@ class SystemChain(ChainBase):
version = str(f.read()).strip()
return version
except Exception as err:
logger.debug(f"加载版本文件 {version_file} 出错:{str(err)}")
return FRONTEND_VERSION
logger.error(f"加载版本文件 {version_file} 出错:{str(err)}")
else:
logger.warn("未找到前端版本文件,请正确设置 FRONTEND_PATH")
return None

View File

@@ -1,52 +1,37 @@
import random
from typing import Optional, List
from cachetools import cached, TTLCache
from app import schemas
from app.chain import ChainBase
from app.core.config import settings
from app.core.context import MediaInfo
from app.schemas import MediaType
from app.utils.singleton import Singleton
class TmdbChain(ChainBase):
class TmdbChain(ChainBase, metaclass=Singleton):
"""
TheMovieDB处理链单例运行
"""
def tmdb_discover(self, mtype: MediaType,
sort_by: str,
with_genres: str,
with_original_language: str,
with_keywords: str,
with_watch_providers: str,
vote_average: float,
vote_count: int,
release_date: str,
page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
def tmdb_discover(self, mtype: MediaType, sort_by: str, with_genres: str,
with_original_language: str, page: int = 1) -> Optional[List[MediaInfo]]:
"""
:param mtype: 媒体类型
:param sort_by: 排序方式
:param with_genres: 类型
:param with_original_language: 语言
:param with_keywords: 关键字
:param with_watch_providers: 提供商
:param vote_average: 评分
:param vote_count: 评分人数
:param release_date: 上映日期
:param page: 页码
:return: 媒体信息列表
"""
return self.run_module("tmdb_discover", mtype=mtype,
sort_by=sort_by,
with_genres=with_genres,
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)
def tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
def tmdb_trending(self, page: int = 1) -> Optional[List[MediaInfo]]:
"""
TMDB流行趋势
:param page: 第几页
@@ -54,13 +39,6 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_trending", page=page)
def tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
"""
根据合集ID查询集合
:param collection_id: 合集ID
"""
return self.run_module("tmdb_collection", collection_id=collection_id)
def tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
"""
根据TMDBID查询themoviedb所有季信息
@@ -68,21 +46,13 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_seasons", tmdbid=tmdbid)
def tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
"""
根据剧集组ID查询themoviedb所有季集信息
:param group_id: 剧集组ID
"""
return self.run_module("tmdb_group_seasons", group_id=group_id)
def tmdb_episodes(self, tmdbid: int, season: int, episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
def tmdb_episodes(self, tmdbid: int, season: int) -> List[schemas.TmdbEpisode]:
"""
根据TMDBID查询某季的所有信信息
:param tmdbid: TMDBID
:param season: 季
:param episode_group: 剧集组
"""
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season, episode_group=episode_group)
return self.run_module("tmdb_episodes", tmdbid=tmdbid, season=season)
def movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
@@ -112,7 +82,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_tv_recommend", tmdbid=tmdbid)
def movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
def movie_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电影演职人员
:param tmdbid: TMDBID
@@ -120,7 +90,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_movie_credits", tmdbid=tmdbid, page=page)
def tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
def tv_credits(self, tmdbid: int, page: int = 1) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电视剧演职人员
:param tmdbid: TMDBID
@@ -135,7 +105,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_person_detail", person_id=person_id)
def person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
def person_credits(self, person_id: int, page: int = 1) -> Optional[List[MediaInfo]]:
"""
根据人物ID查询人物参演作品
:param person_id: 人物ID
@@ -143,6 +113,7 @@ class TmdbChain(ChainBase):
"""
return self.run_module("tmdb_person_credits", person_id=person_id, page=page)
@cached(cache=TTLCache(maxsize=1, ttl=3600))
def get_random_wallpager(self) -> Optional[str]:
"""
获取随机壁纸缓存1个小时
@@ -156,167 +127,12 @@ class TmdbChain(ChainBase):
return info.backdrop_path
return None
def get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:
@cached(cache=TTLCache(maxsize=1, ttl=3600))
def get_trending_wallpapers(self, num: int = 10) -> Optional[List[str]]:
"""
获取所有流行壁纸
"""
infos = self.tmdb_trending()
if infos:
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
return []
async def async_tmdb_discover(self, mtype: MediaType,
sort_by: str,
with_genres: str,
with_original_language: str,
with_keywords: str,
with_watch_providers: str,
vote_average: float,
vote_count: int,
release_date: str,
page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
"""
发现TMDB电影、剧集异步版本
:param mtype: 媒体类型
:param sort_by: 排序方式
:param with_genres: 类型
:param with_original_language: 语言
:param with_keywords: 关键字
:param with_watch_providers: 提供商
:param vote_average: 评分
:param vote_count: 评分人数
:param release_date: 上映日期
:param page: 页码
:return: 媒体信息列表
"""
return await self.async_run_module("async_tmdb_discover", mtype=mtype,
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)
async def async_tmdb_trending(self, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
"""
TMDB流行趋势异步版本
:param page: 第几页
:return: TMDB信息列表
"""
return await self.async_run_module("async_tmdb_trending", page=page)
async def async_tmdb_collection(self, collection_id: int) -> Optional[List[MediaInfo]]:
"""
根据合集ID查询集合异步版本
:param collection_id: 合集ID
"""
return await self.async_run_module("async_tmdb_collection", collection_id=collection_id)
async def async_tmdb_seasons(self, tmdbid: int) -> List[schemas.TmdbSeason]:
"""
根据TMDBID查询themoviedb所有季信息异步版本
:param tmdbid: TMDBID
"""
return await self.async_run_module("async_tmdb_seasons", tmdbid=tmdbid)
async def async_tmdb_group_seasons(self, group_id: str) -> List[schemas.TmdbSeason]:
"""
根据剧集组ID查询themoviedb所有季集信息异步版本
:param group_id: 剧集组ID
"""
return await self.async_run_module("async_tmdb_group_seasons", group_id=group_id)
async def async_tmdb_episodes(self, tmdbid: int, season: int,
episode_group: Optional[str] = None) -> List[schemas.TmdbEpisode]:
"""
根据TMDBID查询某季的所有信信息异步版本
:param tmdbid: TMDBID
:param season: 季
:param episode_group: 剧集组
"""
return await self.async_run_module("async_tmdb_episodes", tmdbid=tmdbid, season=season,
episode_group=episode_group)
async def async_movie_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询类似电影异步版本
:param tmdbid: TMDBID
"""
return await self.async_run_module("async_tmdb_movie_similar", tmdbid=tmdbid)
async def async_tv_similar(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询类似电视剧异步版本
:param tmdbid: TMDBID
"""
return await self.async_run_module("async_tmdb_tv_similar", tmdbid=tmdbid)
async def async_movie_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询推荐电影异步版本
:param tmdbid: TMDBID
"""
return await self.async_run_module("async_tmdb_movie_recommend", tmdbid=tmdbid)
async def async_tv_recommend(self, tmdbid: int) -> Optional[List[MediaInfo]]:
"""
根据TMDBID查询推荐电视剧异步版本
:param tmdbid: TMDBID
"""
return await self.async_run_module("async_tmdb_tv_recommend", tmdbid=tmdbid)
async def async_movie_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电影演职人员异步版本
:param tmdbid: TMDBID
:param page: 页码
"""
return await self.async_run_module("async_tmdb_movie_credits", tmdbid=tmdbid, page=page)
async def async_tv_credits(self, tmdbid: int, page: Optional[int] = 1) -> Optional[List[schemas.MediaPerson]]:
"""
根据TMDBID查询电视剧演职人员异步版本
:param tmdbid: TMDBID
:param page: 页码
"""
return await self.async_run_module("async_tmdb_tv_credits", tmdbid=tmdbid, page=page)
async def async_person_detail(self, person_id: int) -> Optional[schemas.MediaPerson]:
"""
根据TMDBID查询演职员详情异步版本
:param person_id: 人物ID
"""
return await self.async_run_module("async_tmdb_person_detail", person_id=person_id)
async def async_person_credits(self, person_id: int, page: Optional[int] = 1) -> Optional[List[MediaInfo]]:
"""
根据人物ID查询人物参演作品异步版本
:param person_id: 人物ID
:param page: 页码
"""
return await self.async_run_module("async_tmdb_person_credits", person_id=person_id, page=page)
async def async_get_random_wallpager(self) -> Optional[str]:
"""
获取随机壁纸异步版本缓存1个小时
"""
infos = await self.async_tmdb_trending()
if infos:
# 随机一个电影
while True:
info = random.choice(infos)
if info and info.backdrop_path:
return info.backdrop_path
return None
async def async_get_trending_wallpapers(self, num: Optional[int] = 10) -> List[str]:
"""
获取所有流行壁纸(异步版本)
"""
infos = await self.async_tmdb_trending()
if infos:
return [info.backdrop_path for info in infos if info and info.backdrop_path][:num]
return []

View File

@@ -1,24 +1,27 @@
import re
import traceback
from typing import Dict, List, Union, Optional
from typing import Dict, List, Union
from cachetools import cached, TTLCache
from app.chain import ChainBase
from app.chain.media import MediaChain
from app.core.config import settings, global_vars
from app.core.config import settings
from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.helper.rss import RssHelper
from app.helper.sites import SitesHelper # noqa
from app.helper.sites import SitesHelper
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):
class TorrentsChain(ChainBase, metaclass=Singleton):
"""
站点首页或RSS种子处理链服务于订阅、刷流等
"""
@@ -26,14 +29,14 @@ class TorrentsChain(ChainBase):
_spider_file = "__torrents_cache__"
_rss_file = "__rss_cache__"
@property
def cache_file(self) -> str:
"""
返回缓存文件列表
"""
if settings.SUBSCRIBE_MODE == 'spider':
return self._spider_file
return self._rss_file
def __init__(self):
super().__init__()
self.siteshelper = SitesHelper()
self.siteoper = SiteOper()
self.rsshelper = RssHelper()
self.systemconfig = SystemConfigOper()
self.mediachain = MediaChain()
self.torrenthelper = TorrentHelper()
def remote_refresh(self, channel: MessageChannel, userid: Union[str, int] = None):
"""
@@ -45,7 +48,7 @@ class TorrentsChain(ChainBase):
self.post_message(Notification(channel=channel,
title=f"种子刷新完成!", userid=userid))
def get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
def get_torrents(self, stype: str = None) -> Dict[str, List[Context]]:
"""
获取当前缓存的种子
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
@@ -56,34 +59,9 @@ class TorrentsChain(ChainBase):
# 读取缓存
if stype == 'spider':
torrents_cache = self.load_cache(self._spider_file) or {}
return self.load_cache(self._spider_file) or {}
else:
torrents_cache = self.load_cache(self._rss_file) or {}
# 兼容性处理为旧版本的Context对象添加失败次数字段
self._ensure_context_compatibility(torrents_cache)
return torrents_cache
async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
异步获取当前缓存的种子
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
"""
if not stype:
stype = settings.SUBSCRIBE_MODE
# 异步读取缓存
if stype == 'spider':
torrents_cache = await self.async_load_cache(self._spider_file) or {}
else:
torrents_cache = await self.async_load_cache(self._rss_file) or {}
# 兼容性处理为旧版本的Context对象添加失败次数字段
self._ensure_context_compatibility(torrents_cache)
return torrents_cache
return self.load_cache(self._rss_file) or {}
def clear_torrents(self):
"""
@@ -94,63 +72,35 @@ class TorrentsChain(ChainBase):
self.remove_cache(self._rss_file)
logger.info(f'种子缓存数据清理完成')
async def async_clear_torrents(self):
@cached(cache=TTLCache(maxsize=128, ttl=595))
def browse(self, domain: str) -> List[TorrentInfo]:
"""
异步清理种子缓存数据
"""
logger.info(f'开始异步清理种子缓存数据 ...')
await self.async_remove_cache(self._spider_file)
await self.async_remove_cache(self._rss_file)
logger.info(f'异步种子缓存数据清理完成')
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0) -> List[TorrentInfo]:
"""
浏览站点首页内容返回种子清单TTL缓存5分钟
浏览站点首页内容返回种子清单TTL缓存10分钟
:param domain: 站点域名
:param keyword: 搜索标题
:param cat: 搜索分类
:param page: 页码
"""
logger.info(f'开始获取站点 {domain} 最新种子 ...')
site = SitesHelper().get_indexer(domain)
site = self.siteshelper.get_indexer(domain)
if not site:
logger.error(f'站点 {domain} 不存在!')
return []
return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0) -> List[TorrentInfo]:
"""
异步浏览站点首页内容返回种子清单TTL缓存5分钟
:param domain: 站点域名
:param keyword: 搜索标题
:param cat: 搜索分类
:param page: 页码
"""
logger.info(f'开始获取站点 {domain} 最新种子 ...')
site = await SitesHelper().async_get_indexer(domain)
if not site:
logger.error(f'站点 {domain} 不存在!')
return []
return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
return self.refresh_torrents(site=site)
@cached(cache=TTLCache(maxsize=128, ttl=295))
def rss(self, domain: str) -> List[TorrentInfo]:
"""
获取站点RSS内容返回种子清单TTL缓存3分钟
获取站点RSS内容返回种子清单TTL缓存5分钟
:param domain: 站点域名
"""
logger.info(f'开始获取站点 {domain} RSS ...')
site = SitesHelper().get_indexer(domain)
site = self.siteshelper.get_indexer(domain)
if not site:
logger.error(f'站点 {domain} 不存在!')
return []
if not site.get("rss"):
logger.error(f'站点 {domain} 未配置RSS地址')
return []
# 解析RSS
rss_items = RssHelper().parse(site.get("rss"), True if site.get("proxy") else False,
timeout=int(site.get("timeout") or 30))
rss_items = self.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)
@@ -160,53 +110,39 @@ class TorrentsChain(ChainBase):
return []
# 组装种子
ret_torrents: List[TorrentInfo] = []
try:
for item in rss_items:
if not item.get("title"):
continue
torrentinfo = TorrentInfo(
site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua") or settings.USER_AGENT,
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
site_downloader=site.get("downloader"),
title=item.get("title"),
enclosure=item.get("enclosure"),
page_url=item.get("link"),
size=item.get("size"),
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
)
ret_torrents.append(torrentinfo)
finally:
rss_items.clear()
del rss_items
for item in rss_items:
if not item.get("title"):
continue
torrentinfo = TorrentInfo(
site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua") or settings.USER_AGENT,
site_proxy=site.get("proxy"),
site_order=site.get("pri"),
title=item.get("title"),
enclosure=item.get("enclosure"),
page_url=item.get("link"),
size=item.get("size"),
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
)
ret_torrents.append(torrentinfo)
return ret_torrents
def refresh(self, stype: Optional[str] = None, sites: List[int] = None) -> Dict[str, List[Context]]:
def refresh(self, stype: str = None, sites: List[int] = None) -> Dict[str, List[Context]]:
"""
刷新站点最新资源,识别并缓存起来
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
:param sites: 强制指定站点ID列表为空则读取设置的订阅站点
"""
def __is_no_cache_site(_domain: str) -> bool:
"""
判断站点是否不需要缓存
"""
for url_key in settings.NO_CACHE_SITE_KEY.split(','):
if url_key in _domain:
return True
return False
# 刷新类型
if not stype:
stype = settings.SUBSCRIBE_MODE
# 刷新站点
if not sites:
sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
sites = self.systemconfig.get(SystemConfigKey.RssSites) or []
# 读取缓存
torrents_cache = self.get_torrents()
@@ -214,14 +150,14 @@ class TorrentsChain(ChainBase):
# 缓存过滤掉无效种子
for _domain, _torrents in torrents_cache.items():
torrents_cache[_domain] = [_torrent for _torrent in _torrents
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
if not self.torrenthelper.is_invalid(_torrent.torrent_info.enclosure)]
# 所有站点索引
indexers = self.siteshelper.get_indexers()
# 需要刷新的站点domain
domains = []
# 遍历站点缓存资源
for indexer in SitesHelper().get_indexers():
if global_vars.is_system_stopped:
break
for indexer in indexers:
# 未开启的站点不刷新
if sites and indexer.get("id") not in sites:
continue
@@ -229,75 +165,55 @@ class TorrentsChain(ChainBase):
domains.append(domain)
if stype == "spider":
# 刷新首页种子
torrents: List[TorrentInfo] = []
# 读取第0页和第1页
for page in range(2):
page_torrents = self.browse(domain=domain, page=page)
if page_torrents:
torrents.extend(page_torrents)
else:
# 如果某一页没有数据,说明已经到最后一页,停止获取
break
torrents: List[TorrentInfo] = self.browse(domain=domain)
else:
# 刷新RSS种子
torrents: List[TorrentInfo] = self.rss(domain=domain)
# 按pubdate降序排列
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
# 取前N条
torrents = torrents[:settings.CONF.refresh]
torrents = torrents[:settings.CACHE_CONF.get('refresh')]
if torrents:
if __is_no_cache_site(domain):
# 不需要缓存的站点,直接处理
logger.info(f'{indexer.get("name")}{len(torrents)} 个种子 (不缓存)')
torrents_cache[domain] = []
else:
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
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 cached_signatures]
# 过滤出没有处理过的种子
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 torrents:
logger.info(f'{indexer.get("name")}{len(torrents)} 个新种子')
else:
logger.info(f'{indexer.get("name")} 没有新种子')
continue
try:
for torrent in torrents:
if global_vars.is_system_stopped:
break
logger.info(f'处理资源:{torrent.title} ...')
# 识别
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != meta.org_string:
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
# 使用站点种子分类,校正类型识别
if meta.type != MediaType.TV \
and torrent.category == MediaType.TV.value:
meta.type = MediaType.TV
# 识别媒体信息
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)
# 如果未识别到媒体信息设置初始失败次数为1
if not mediainfo or (not mediainfo.tmdb_id and not mediainfo.douban_id):
context.media_recognize_fail_count = 1
# 添加到缓存
if not torrents_cache.get(domain):
torrents_cache[domain] = [context]
else:
torrents_cache[domain].append(context)
# 如果超过了限制条数则移除掉前面的
if len(torrents_cache[domain]) > settings.CONF.torrents:
torrents_cache[domain] = torrents_cache[domain][-settings.CONF.torrents:]
finally:
torrents.clear()
del torrents
for torrent in torrents:
logger.info(f'处理资源:{torrent.title} ...')
# 识别
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if torrent.title != meta.org_string:
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
# 使用站点种子分类,校正类型识别
if meta.type != MediaType.TV \
and torrent.category == MediaType.TV.value:
meta.type = MediaType.TV
# 识别媒体信息
mediainfo: MediaInfo = self.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)
# 添加到缓存
if not torrents_cache.get(domain):
torrents_cache[domain] = [context]
else:
torrents_cache[domain].append(context)
# 如果超过了限制条数则移除掉前面的
if len(torrents_cache[domain]) > settings.CACHE_CONF.get('torrents'):
torrents_cache[domain] = torrents_cache[domain][-settings.CACHE_CONF.get('torrents'):]
# 回收资源
del torrents
else:
logger.info(f'{indexer.get("name")} 没有获取到种子')
@@ -310,24 +226,8 @@ class TorrentsChain(ChainBase):
# 去除不在站点范围内的缓存种子
if sites and torrents_cache:
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
return torrents_cache
@staticmethod
def _ensure_context_compatibility(torrents_cache: Dict[str, List[Context]]):
"""
确保Context对象的兼容性为旧版本添加缺失的字段
"""
for domain, contexts in torrents_cache.items():
for context in contexts:
# 如果Context对象没有media_recognize_fail_count字段添加默认值
if not hasattr(context, 'media_recognize_fail_count'):
context.media_recognize_fail_count = 0
# 如果媒体信息未识别,设置初始失败次数
if (not context.media_info or
(not context.media_info.tmdb_id and not context.media_info.douban_id)):
context.media_recognize_fail_count = 1
def __renew_rss_url(self, domain: str, site: dict):
"""
保留原配置生成新的rss地址
@@ -336,12 +236,11 @@ class TorrentsChain(ChainBase):
# RSS链接过期
logger.error(f"站点 {domain} RSS链接已过期正在尝试自动获取")
# 自动生成rss地址
rss_url, errmsg = RssHelper().get_rss_link(
rss_url, errmsg = self.rsshelper.get_rss_link(
url=site.get("url"),
cookie=site.get("cookie"),
ua=site.get("ua") or settings.USER_AGENT,
proxy=True if site.get("proxy") else False,
timeout=site.get("timeout"),
proxy=True if site.get("proxy") else False
)
if rss_url:
# 获取新的日期的passkey
@@ -351,7 +250,7 @@ class TorrentsChain(ChainBase):
# 获取过期rss除去passkey部分
new_rss = re.sub(r'&passkey=([a-zA-Z0-9]+)', f'&passkey={new_passkey}', site.get("rss"))
logger.info(f"更新站点 {domain} RSS地址 ...")
SiteOper().update_rss(domain=domain, rss=new_rss)
self.siteoper.update_rss(domain=domain, rss=new_rss)
else:
# 发送消息
self.post_message(

2040
app/chain/transfer.py Executable file → Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
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]

View File

@@ -1,235 +1,15 @@
import secrets
from typing import Optional, Tuple, Union
from typing import Optional
from app.chain import ChainBase
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from app.db.models.user import User
from app.db.user_oper import UserOper
from app.log import logger
from app.schemas import AuthCredentials, AuthInterceptCredentials
from app.schemas.types import ChainEventType
from app.utils.otp import OtpUtils
PASSWORD_INVALID_CREDENTIALS_MESSAGE = "用户名或密码或二次校验码不正确"
class UserChain(ChainBase):
"""
用户链,处理多种认证协议
"""
def user_authenticate(
self,
username: Optional[str] = None,
password: Optional[str] = None,
mfa_code: Optional[str] = None,
code: Optional[str] = None,
grant_type: Optional[str] = "password"
) -> Union[Tuple[bool, Optional[str]], Tuple[bool, Optional[User]]]:
def user_authenticate(self, name, password) -> Optional[str]:
"""
认证用户,根据不同的 grant_type 处理不同的认证流程
:param username: 用户名,适用于 "password" grant_type
:param password: 用户密码,适用于 "password" grant_type
:param mfa_code: 一次性密码,适用于 "password" grant_type
:param code: 授权码,适用于 "authorization_code" grant_type
:param grant_type: 认证类型,如 "password", "authorization_code", "client_credentials"
:return:
- 对于成功的认证,返回 (True, User)
- 对于失败的认证,返回 (False, "错误信息")
辅助完成用户认证
:param name: 用户名
:param password: 密码
:return: token
"""
credentials = AuthCredentials(
username=username,
password=password,
mfa_code=mfa_code,
code=code,
grant_type=grant_type
)
logger.debug(f"认证类型:{grant_type},开始准备对用户 {username} 进行身份校验")
if credentials.grant_type == "password":
# Password 认证
success, user_or_message = self.password_authenticate(credentials=credentials)
if success:
# 如果用户启用了二次验证码,则进一步验证
if not self._verify_mfa(user_or_message, credentials.mfa_code):
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
logger.info(f"用户 {username} 通过密码认证成功")
return True, user_or_message
else:
# 用户不存在或密码错误,考虑辅助认证
if settings.AUXILIARY_AUTH_ENABLE:
logger.warning("密码认证失败,尝试通过外部服务进行辅助认证 ...")
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
if aux_success:
# 辅助认证成功后再验证二次验证码
if not self._verify_mfa(aux_user_or_message, credentials.mfa_code):
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
return True, aux_user_or_message
else:
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
else:
logger.debug(f"辅助认证未启用,用户 {username} 认证失败")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
elif credentials.grant_type == "authorization_code":
# 处理其他认证类型的分支
if settings.AUXILIARY_AUTH_ENABLE:
aux_success, aux_user_or_message = self.auxiliary_authenticate(credentials=credentials)
if aux_success:
return True, aux_user_or_message
else:
return False, "认证失败"
else:
return False, "认证失败"
else:
logger.debug(f"辅助认证未启用,认证类型 {grant_type} 未实现")
return False, "不支持的认证类型"
@staticmethod
def password_authenticate(credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
"""
密码认证
:param credentials: 认证凭证,包含用户名、密码以及可选的 MFA 认证码
:return:
- 成功时返回 (True, User),其中 User 是认证通过的用户对象
- 失败时返回 (False, "错误信息")
"""
if not credentials or credentials.grant_type != "password":
logger.info("密码认证失败,认证类型不匹配")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
user = UserOper().get_by_name(name=credentials.username)
if not user:
logger.info(f"密码认证失败,用户 {credentials.username} 不存在")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
if not user.is_active:
logger.info(f"密码认证失败,用户 {credentials.username} 已被禁用")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
if not verify_password(credentials.password, str(user.hashed_password)):
logger.info(f"密码认证失败,用户 {credentials.username} 的密码验证不通过")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
return True, user
def auxiliary_authenticate(self, credentials: AuthCredentials) -> Tuple[bool, Union[User, str]]:
"""
辅助用户认证
:param credentials: 认证凭证,包含必要的认证信息
:return:
- 成功时返回 (True, User),其中 User 是认证通过的用户对象
- 失败时返回 (False, "错误信息")
"""
if not credentials:
return False, "认证凭证无效"
# 检查是否因为用户被禁用
useroper = UserOper()
if 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
logger.debug(f"认证类型:{credentials.grant_type},尝试通过系统模块进行辅助认证,用户: {credentials.username}")
result = self.run_module("user_authenticate", credentials=credentials)
if not result:
logger.debug(f"通过系统模块辅助认证失败,尝试触发 {ChainEventType.AuthVerification} 事件")
event = self.eventmanager.send_event(etype=ChainEventType.AuthVerification, data=credentials)
if not event or not event.event_data:
logger.error(f"认证类型:{credentials.grant_type},辅助认证失败,未返回有效数据")
return False, f"认证类型:{credentials.grant_type},辅助认证事件失败或无效"
credentials = event.event_data # 使用事件返回的认证数据
else:
logger.info(f"通过系统模块辅助认证成功,用户: {credentials.username}")
credentials = result # 使用模块认证返回的认证数据
# 处理认证成功的逻辑
success = self._process_auth_success(username=credentials.username, credentials=credentials)
if success:
logger.info(f"用户 {credentials.username} 辅助认证通过")
return True, useroper.get_by_name(credentials.username)
else:
logger.warning(f"用户 {credentials.username} 辅助认证未通过")
return False, PASSWORD_INVALID_CREDENTIALS_MESSAGE
@staticmethod
def _verify_mfa(user: User, mfa_code: Optional[str]) -> bool:
"""
验证 MFA二次验证码
:param user: 用户对象
:param mfa_code: 二次验证码
:return: 如果验证成功返回 True否则返回 False
"""
if not user.is_otp:
return True
if not mfa_code:
logger.info(f"用户 {user.name} 缺少 MFA 认证码")
return False
if not OtpUtils.check(str(user.otp_secret), mfa_code):
logger.info(f"用户 {user.name} 的 MFA 认证失败")
return False
return True
def _process_auth_success(self, username: str, credentials: AuthCredentials) -> bool:
"""
处理辅助认证成功的逻辑,返回用户对象或创建新用户
:param username: 用户名
:param credentials: 认证凭证,包含 token、channel、service 等信息
:return:
- 如果认证成功并且用户存在或已创建,返回 User 对象
- 如果认证被拦截或失败,返回 None
"""
if not username:
logger.info(f"未能获取到对应的用户信息,{credentials.grant_type} 认证不通过")
return False
token, channel, service = credentials.token, credentials.channel, credentials.service
if not all([token, channel, service]):
logger.info(f"用户 {username} 未通过 {credentials.grant_type} 认证,必要信息不足")
return False
# 触发认证通过的拦截事件
intercept_event = self.eventmanager.send_event(
etype=ChainEventType.AuthIntercept,
data=AuthInterceptCredentials(username=username, channel=channel, service=service,
token=token, status="completed")
)
if intercept_event and intercept_event.event_data:
intercept_data: AuthInterceptCredentials = intercept_event.event_data
if intercept_data.cancel:
logger.warning(
f"认证被拦截,用户:{username},渠道:{channel},服务:{service},拦截源:{intercept_data.source}")
return False
# 检查用户是否存在,如果不存在且当前为密码认证时则创建新用户
useroper = UserOper()
user = useroper.get_by_name(name=username)
if user:
# 如果用户存在,但是已经被禁用,则直接响应
if not user.is_active:
logger.info(f"辅助认证失败,用户 {username} 已被禁用")
return False
anonymized_token = f"{token[:len(token) // 2]}********"
logger.info(
f"认证类型:{credentials.grant_type},用户:{username},渠道:{channel}"
f"服务:{service} 认证成功token{anonymized_token}")
return True
else:
if credentials.grant_type == "password":
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:
logger.warning(
f"认证类型:{credentials.grant_type},用户:{username},渠道:{channel}"
f"服务:{service} 认证不通过,未能在本地找到对应的用户信息")
return False
return self.run_module("user_authenticate", name=name, password=password)

View File

@@ -2,9 +2,10 @@ from typing import Any
from app.chain import ChainBase
from app.schemas.types import EventType
from app.utils.singleton import Singleton
class WebhookChain(ChainBase):
class WebhookChain(ChainBase, metaclass=Singleton):
"""
Webhook处理链
"""

View File

@@ -1,275 +0,0 @@
import base64
import pickle
import threading
from collections import defaultdict, deque
from concurrent.futures import ThreadPoolExecutor
from time import sleep
from typing import List, Tuple, Optional
from pydantic.fields import Callable
from app.chain import ChainBase
from app.core.config import global_vars
from app.core.event import Event, eventmanager
from app.core.workflow import WorkFlowManager
from app.db.models import Workflow
from app.db.workflow_oper import WorkflowOper
from app.log import logger
from app.schemas import ActionContext, ActionFlow, Action, ActionExecution
from app.schemas.types import EventType
class WorkflowExecutor:
"""
工作流执行器
"""
def __init__(self, workflow: Workflow, step_callback: Callable = None):
"""
初始化工作流执行器
:param workflow: 工作流对象
:param step_callback: 步骤回调函数
"""
# 工作流数据
self.workflow = workflow
self.step_callback = step_callback
self.actions = {action['id']: Action(**action) for action in workflow.actions}
self.flows = [ActionFlow(**flow) for flow in workflow.flows]
self.total_actions = len(self.actions)
self.finished_actions = 0
self.success = True
self.errmsg = ""
# 工作流管理器
self.workflowmanager = WorkFlowManager()
# 线程安全队列
self.queue = deque()
# 锁用于保证线程安全
self.lock = threading.Lock()
# 线程池
self.executor = ThreadPoolExecutor()
# 跟踪运行中的任务数
self.running_tasks = 0
# 构建邻接表、入度表
self.adjacency = defaultdict(list)
self.indegree = defaultdict(int)
for flow in self.flows:
source = flow.source
target = flow.target
self.adjacency[source].append(target)
self.indegree[target] += 1
# 初始化所有节点的入度确保未被引用的节点入度为0
for action_id in self.actions:
if action_id not in self.indegree:
self.indegree[action_id] = 0
# 初始上下文
if workflow.current_action and workflow.context:
logger.info(f"工作流已执行动作:{workflow.current_action}")
# Base64解码
decoded_data = base64.b64decode(workflow.context["content"])
# 反序列化数据
self.context = pickle.loads(decoded_data)
else:
self.context = ActionContext()
# 恢复工作流
global_vars.workflow_resume(self.workflow.id)
# 初始化队列添加入度为0的节点
for action_id in self.actions:
if self.indegree[action_id] == 0:
self.queue.append(action_id)
def execute(self):
"""
执行工作流
"""
while True:
with self.lock:
# 退出条件:队列为空且无运行任务
if not self.queue and self.running_tasks == 0:
break
# 退出条件:出现了错误
if not self.success:
break
if not self.queue:
sleep(0.1)
continue
# 取出队首节点
node_id = self.queue.popleft()
# 标记任务开始
self.running_tasks += 1
# 已停机
if global_vars.is_workflow_stopped(self.workflow.id):
global_vars.workflow_resume(self.workflow.id)
break
# 已执行的跳过
if (self.workflow.current_action
and node_id in self.workflow.current_action.split(',')):
continue
# 提交任务到线程池
future = self.executor.submit(
self.execute_node,
self.workflow.id,
node_id,
self.context
)
future.add_done_callback(self.on_node_complete)
def execute_node(self, workflow_id: int, node_id: int,
context: ActionContext) -> Tuple[Action, bool, str, ActionContext]:
"""
执行单个节点操作返回修改后的上下文和节点ID
"""
action = self.actions[node_id]
state, message, result_ctx = self.workflowmanager.excute(workflow_id, action, context=context)
return action, state, message, result_ctx
def on_node_complete(self, future):
"""
节点完成回调:更新上下文、处理后继节点
"""
action, state, message, result_ctx = future.result()
try:
self.finished_actions += 1
# 更新当前进度
self.context.progress = round(self.finished_actions / self.total_actions) * 100
# 补充执行历史
self.context.execute_history.append(
ActionExecution(
action=action.name,
result=state,
message=message
)
)
# 节点执行失败
if not state:
self.success = False
self.errmsg = f"{action.name} 失败"
return
with self.lock:
# 更新主上下文
self.merge_context(result_ctx)
# 回调
if self.step_callback:
self.step_callback(action, self.context)
# 处理后继节点
successors = self.adjacency.get(action.id, [])
for succ_id in successors:
with self.lock:
self.indegree[succ_id] -= 1
if self.indegree[succ_id] == 0:
self.queue.append(succ_id)
finally:
# 标记任务完成
with self.lock:
self.running_tasks -= 1
def merge_context(self, context: ActionContext):
"""
合并上下文
"""
for key, value in context.dict().items():
if not getattr(self.context, key, None):
setattr(self.context, key, value)
class WorkflowChain(ChainBase):
"""
工作流链
"""
@eventmanager.register(EventType.WorkflowExecute)
def event_process(self, event: Event):
"""
事件触发工作流执行
"""
workflow_id = event.event_data.get('workflow_id')
if not workflow_id:
return
self.process(workflow_id, from_begin=False)
@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):
"""
保存上下文到数据库
"""
# 序列化数据
serialized_data = pickle.dumps(context)
# 使用Base64编码字节流
encoded_data = base64.b64encode(serialized_data).decode('utf-8')
workflowoper.step(workflow_id, action_id=action.id, context={
"content": encoded_data
})
# 重置工作流
if from_begin:
workflowoper.reset(workflow_id)
# 查询工作流数据
workflow = workflowoper.get(workflow_id)
if not workflow:
logger.warn(f"工作流 {workflow_id} 不存在")
return False, "工作流不存在"
if not workflow.actions:
logger.warn(f"工作流 {workflow.name} 无动作")
return False, "工作流无动作"
if not workflow.flows:
logger.warn(f"工作流 {workflow.name} 无流程")
return False, "工作流无流程"
logger.info(f"开始执行工作流 {workflow.name},共 {len(workflow.actions)} 个动作 ...")
workflowoper.start(workflow_id)
# 执行工作流
executor = WorkflowExecutor(workflow, step_callback=save_step)
executor.execute()
if not executor.success:
logger.info(f"工作流 {workflow.name} 执行失败:{executor.errmsg}")
workflowoper.fail(workflow_id, result=executor.errmsg)
return False, executor.errmsg
else:
logger.info(f"工作流 {workflow.name} 执行完成")
workflowoper.success(workflow_id)
return True, ""
@staticmethod
def get_workflows() -> List[Workflow]:
"""
获取工作流列表
"""
return WorkflowOper().list_enabled()
@staticmethod
def get_timer_workflows() -> List[Workflow]:
"""
获取定时触发的工作流列表
"""
return WorkflowOper().get_timer_triggered_workflows()
@staticmethod
def get_event_workflows() -> List[Workflow]:
"""
获取事件触发的工作流列表
"""
return WorkflowOper().get_event_triggered_workflows()

View File

@@ -1,7 +1,9 @@
import copy
import importlib
import threading
import traceback
from typing import Any, Union, Dict, Optional
from threading import Thread
from typing import Any, Union, Dict
from app.chain import ChainBase
from app.chain.download import DownloadChain
@@ -9,37 +11,53 @@ from app.chain.site import SiteChain
from app.chain.subscribe import SubscribeChain
from app.chain.system import SystemChain
from app.chain.transfer import TransferChain
from app.core.event import Event as ManagerEvent, eventmanager, Event
from app.core.config import settings
from app.core.event import Event as ManagerEvent, eventmanager, EventManager
from app.core.plugin import PluginManager
from app.helper.message import MessageHelper
from app.helper.thread import ThreadHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas import Notification, CommandRegisterEventData
from app.schemas.types import EventType, MessageChannel, ChainEventType
from app.schemas import Notification
from app.schemas.types import EventType, MessageChannel
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
from app.utils.structures import DictUtils
class CommandChain(ChainBase):
pass
class CommandChian(ChainBase):
"""
插件处理链
"""
def process(self, *args, **kwargs):
pass
class Command(metaclass=Singleton):
"""
全局命令管理,消费事件
"""
# 内建命令
_commands = {}
# 退出事件
_event = threading.Event()
def __init__(self):
# 事件管理器
self.eventmanager = EventManager()
# 插件管理器
super().__init__()
# 注册的命令集合
self._registered_commands = {}
# 所有命令集合
self._commands = {}
# 内建命令集合
self._preset_commands = {
self.pluginmanager = PluginManager()
# 处理链
self.chain = CommandChian()
# 定时服务管理
self.scheduler = Scheduler()
# 消息管理器
self.messagehelper = MessageHelper()
# 线程管理器
self.threader = ThreadHelper()
# 内置命令
self._commands = {
"/cookiecloud": {
"id": "cookiecloud",
"type": "scheduler",
@@ -57,11 +75,6 @@ class Command(metaclass=Singleton):
"description": "更新站点Cookie",
"data": {}
},
"/site_statistic": {
"func": SiteChain().remote_refresh_userdatas,
"description": "站点数据统计",
"data": {}
},
"/site_enable": {
"func": SiteChain().remote_enable,
"description": "启用站点",
@@ -142,148 +155,98 @@ class Command(metaclass=Singleton):
"data": {}
}
}
# 插件命令集合
self._plugin_commands = {}
# 其他命令集合
self._other_commands = {}
# 初始化锁
self._rlock = threading.RLock()
# 插件管理
self.pluginmanager = PluginManager()
# 定时服务管理
self.scheduler = Scheduler()
# 消息管理器
self.messagehelper = MessageHelper()
# 初始化命令
self.init_commands()
def init_commands(self, pid: Optional[str] = None) -> None:
"""
初始化菜单命令
"""
# 使用线程池提交后台任务,避免引起阻塞
ThreadHelper().submit(self.__init_commands_background, pid)
def __init_commands_background(self, pid: Optional[str] = None) -> None:
"""
后台初始化菜单命令
"""
try:
with self._rlock:
logger.debug("Acquired lock for initializing commands in background.")
self._plugin_commands = self.__build_plugin_commands(pid)
self._commands = {
**self._preset_commands,
**self._plugin_commands,
**self._other_commands
# 汇总插件命令
plugin_commands = self.pluginmanager.get_plugin_commands()
for command in plugin_commands:
self.register(
cmd=command.get('cmd'),
func=Command.send_plugin_event,
desc=command.get('desc'),
category=command.get('category'),
data={
'etype': command.get('event'),
'data': command.get('data')
}
)
# 广播注册命令菜单
if not settings.DEV:
self.chain.register_commands(commands=self.get_commands())
# 消息处理线程
self._thread = Thread(target=self.__run)
# 启动事件处理线程
self._thread.start()
# 重启msg
SystemChain().restart_finish()
# 强制触发注册
force_register = False
# 触发事件允许可以拦截和调整命令
event, initial_commands = self.__trigger_register_commands_event()
if event and event.event_data:
# 如果事件返回有效的 event_data使用事件中调整后的命令
event_data: CommandRegisterEventData = event.event_data
# 如果事件被取消,跳过命令注册
if event_data.cancel:
logger.debug(f"Command initialization canceled by event: {event_data.source}")
return
# 如果拦截源与插件标识一致时,这里认为需要强制触发注册
if pid is not None and pid == event_data.source:
force_register = True
initial_commands = event_data.commands or {}
logger.debug(f"Registering command count from event: {len(initial_commands)}")
else:
logger.debug(f"Registering initial command count: {len(initial_commands)}")
# initial_commands 必须是 self._commands 的子集
filtered_initial_commands = DictUtils.filter_keys_to_subset(initial_commands, self._commands)
# 如果 filtered_initial_commands 为空,则跳过注册
if not filtered_initial_commands and not force_register:
logger.debug("Filtered commands are empty, skipping registration.")
return
# 对比调整后的命令与当前命令
if filtered_initial_commands != self._registered_commands or force_register:
logger.debug("Command set has changed or force registration is enabled.")
self._registered_commands = filtered_initial_commands
CommandChain().register_commands(commands=filtered_initial_commands)
else:
logger.debug("Command set unchanged, skipping broadcast registration.")
except Exception as e:
logger.error(f"Error occurred during command initialization in background: {e}", exc_info=True)
def __trigger_register_commands_event(self) -> (Optional[Event], dict):
def __run(self):
"""
触发事件,允许调整命令数据
事件处理线程
"""
while not self._event.is_set():
event, handlers = self.eventmanager.get_event()
if event:
logger.info(f"处理事件:{event.event_type} - {handlers}")
for handler in handlers:
names = handler.__qualname__.split(".")
[class_name, method_name] = names
try:
if class_name in self.pluginmanager.get_plugin_ids():
# 插件事件
self.threader.submit(
self.pluginmanager.run_plugin_method,
class_name, method_name, copy.deepcopy(event)
)
def add_commands(source, command_type):
"""
添加命令集合
"""
for cmd, command in source.items():
if not command.get("show", True):
continue
else:
# 检查全局变量中是否存在
if class_name not in globals():
# 导入模块除了插件和Command本身只有chain能响应事件
try:
module = importlib.import_module(
f"app.chain.{class_name[:-5].lower()}"
)
class_obj = getattr(module, class_name)()
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
continue
command_data = {
"type": command_type,
"description": command.get("description"),
"category": command.get("category")
}
# 如果有 pid则添加到命令数据中
plugin_id = command.get("pid")
if plugin_id:
command_data["pid"] = plugin_id
commands[cmd] = command_data
else:
# 通过类名创建类实例
class_obj = globals()[class_name]()
# 检查类是否存在并调用方法
if hasattr(class_obj, method_name):
self.threader.submit(
getattr(class_obj, method_name),
copy.deepcopy(event)
)
except Exception as e:
logger.error(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
self.messagehelper.put(title=f"{event.event_type} 事件处理出错",
message=f"{class_name}.{method_name}{str(e)}",
role="system")
self.eventmanager.send_event(
EventType.SystemError,
{
"type": "event",
"event_type": event.event_type,
"event_handle": f"{class_name}.{method_name}",
"error": str(e),
"traceback": traceback.format_exc()
}
)
# 初始化命令字典
commands: Dict[str, dict] = {}
add_commands(self._preset_commands, "preset")
add_commands(self._plugin_commands, "plugin")
add_commands(self._other_commands, "other")
# 触发事件允许可以拦截和调整命令
event_data = CommandRegisterEventData(commands=commands, origin="CommandChain", service=None)
event = eventmanager.send_event(ChainEventType.CommandRegister, event_data)
return event, commands
def __build_plugin_commands(self, _: Optional[str] = None) -> Dict[str, dict]:
"""
构建插件命令
"""
# 为了保证命令顺序的一致性,目前这里没有直接使用 pid 获取单一插件命令,后续如果存在性能问题,可以考虑优化这里的逻辑
plugin_commands = {}
for command in self.pluginmanager.get_plugin_commands():
cmd = command.get("cmd")
if cmd:
plugin_commands[cmd] = {
"pid": command.get("pid"),
"func": self.send_plugin_event,
"description": command.get("desc"),
"category": command.get("category"),
"show": command.get("show", True),
"data": {
"etype": command.get("event"),
"data": command.get("data")
}
}
return plugin_commands
def __run_command(self, command: Dict[str, any], data_str: Optional[str] = "",
channel: MessageChannel = None, source: Optional[str] = None, userid: Union[str, int] = None):
def __run_command(self, command: Dict[str, any],
data_str: str = "",
channel: MessageChannel = None, userid: Union[str, int] = None):
"""
运行定时服务
"""
if command.get("type") == "scheduler":
# 定时服务
if userid:
CommandChain().post_message(
self.chain.post_message(
Notification(
channel=channel,
source=source,
title=f"开始执行 {command.get('description')} ...",
userid=userid
)
@@ -293,69 +256,75 @@ class Command(metaclass=Singleton):
self.scheduler.start(job_id=command.get("id"))
if userid:
CommandChain().post_message(
self.chain.post_message(
Notification(
channel=channel,
source=source,
title=f"{command.get('description')} 执行完成",
userid=userid
)
)
else:
# 命令
cmd_data = copy.deepcopy(command['data']) if command.get('data') else {}
cmd_data = command['data'] if command.get('data') else {}
args_num = ObjectUtils.arguments(command['func'])
if args_num > 0:
if cmd_data:
# 有内置参数直接使用内置参数
data = cmd_data.get("data") or {}
data['channel'] = channel
data['source'] = source
data['user'] = userid
if data_str:
data['arg_str'] = data_str
data['args'] = data_str
cmd_data['data'] = data
command['func'](**cmd_data)
elif args_num == 3:
# 没有输入参数,只输入渠道来源、用户ID和消息来源
command['func'](channel, userid, source)
elif args_num > 3:
elif args_num == 2:
# 没有输入参数,只输入渠道用户ID
command['func'](channel, userid)
elif args_num > 2:
# 多个输入参数用户输入、用户ID
command['func'](data_str, channel, userid, source)
command['func'](data_str, channel, userid)
else:
# 没有参数
command['func']()
def stop(self):
"""
停止事件处理线程
"""
logger.info("正在停止事件处理...")
self._event.set()
try:
self._thread.join()
logger.info("事件处理停止完成")
except Exception as e:
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
def get_commands(self):
"""
获取命令列表
"""
return self._commands
def register(self, cmd: str, func: Any, data: dict = None,
desc: str = None, category: str = None) -> None:
"""
注册命令
"""
self._commands[cmd] = {
"func": func,
"description": desc,
"category": category,
"data": data or {}
}
def get(self, cmd: str) -> Any:
"""
获取命令
"""
return self._commands.get(cmd, {})
def register(self, cmd: str, func: Any, data: Optional[dict] = None,
desc: Optional[str] = None, category: Optional[str] = None,
show: bool = True) -> None:
"""
注册单个命令
"""
# 单独调用的,统一注册到其他
self._other_commands[cmd] = {
"func": func,
"description": desc,
"category": category,
"data": data or {},
"show": show
}
def execute(self, cmd: str, data_str: Optional[str] = "",
channel: MessageChannel = None, source: Optional[str] = None,
userid: Union[str, int] = None) -> None:
def execute(self, cmd: str, data_str: str = "",
channel: MessageChannel = None, userid: Union[str, int] = None) -> None:
"""
执行命令
"""
@@ -369,7 +338,7 @@ class Command(metaclass=Singleton):
# 执行命令
self.__run_command(command, data_str=data_str,
channel=channel, source=source, userid=userid)
channel=channel, userid=userid)
if userid:
logger.info(f"用户 {userid} {command.get('description')} 执行完成")
@@ -386,7 +355,7 @@ class Command(metaclass=Singleton):
"""
发送插件命令
"""
eventmanager.send_event(etype, data)
EventManager().send_event(etype, data)
@eventmanager.register(EventType.CommandExcute)
def command_event(self, event: ManagerEvent) -> None:
@@ -400,21 +369,10 @@ class Command(metaclass=Singleton):
event_str = event.event_data.get('cmd')
# 消息渠道
event_channel = event.event_data.get('channel')
# 消息来源
event_source = event.event_data.get('source')
# 消息用户
event_user = event.event_data.get('user')
if event_str:
cmd = event_str.split()[0]
args = " ".join(event_str.split()[1:])
if self.get(cmd):
self.execute(cmd=cmd, data_str=args,
channel=event_channel, source=event_source, userid=event_user)
@eventmanager.register(EventType.ModuleReload)
def module_reload_event(self, _: ManagerEvent) -> None:
"""
注册模块重载事件
"""
# 发生模块重载时,重新注册命令
self.init_commands()
self.execute(cmd, args, event_channel, event_user)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import re
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Any, Tuple, Optional
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Any, Tuple
from app.core.config import settings
from app.core.meta import MetaBase
@@ -24,8 +23,6 @@ class TorrentInfo:
site_proxy: bool = False
# 站点优先级
site_order: int = 0
# 站点下载器
site_downloader: str = None
# 种子名称
title: str = None
# 种子副标题
@@ -37,7 +34,7 @@ class TorrentInfo:
# 详情页面
page_url: str = None
# 种子大小
size: float = 0.0
size: float = 0
# 做种者
seeders: int = 0
# 下载者
@@ -124,25 +121,11 @@ class TorrentInfo:
return ""
return StringUtils.diff_time_str(self.freedate)
def pub_minutes(self) -> float:
"""
返回发布时间距离当前时间的分钟数
"""
if not self.pubdate:
return 0
try:
pub_date = datetime.strptime(self.pubdate, "%Y-%m-%d %H:%M:%S")
now_datetime = datetime.now()
return (now_datetime - pub_date).total_seconds() // 60
except Exception as e:
print(f"种子发布时间获取失败: {e}")
return 0
def to_dict(self):
"""
返回字典
"""
dicts = vars(self).copy()
dicts = asdict(self)
dicts["volume_factor"] = self.volume_factor
dicts["freedate_diff"] = self.freedate_diff
return dicts
@@ -158,10 +141,6 @@ class MediaInfo:
title: str = None
# 英文标题
en_title: str = None
# 香港标题
hk_title: str = None
# 台湾标题
tw_title: str = None
# 新加坡标题
sg_title: str = None
# 年份
@@ -178,8 +157,6 @@ class MediaInfo:
douban_id: str = None
# Bangumi ID
bangumi_id: int = None
# 合集ID
collection_id: int = None
# 媒体原语种
original_language: str = None
# 媒体原发行标题
@@ -193,7 +170,7 @@ class MediaInfo:
# LOGO
logo_path: str = None
# 评分
vote_average: float = None
vote_average: float = 0
# 描述
overview: str = None
# 风格ID
@@ -237,9 +214,9 @@ class MediaInfo:
# 流媒体平台
networks: list = field(default_factory=list)
# 集数
number_of_episodes: int = None
number_of_episodes: int = 0
# 季数
number_of_seasons: int = None
number_of_seasons: int = 0
# 原产国
origin_country: list = field(default_factory=list)
# 原名
@@ -255,19 +232,13 @@ class MediaInfo:
# 标签
tagline: str = None
# 评价数量
vote_count: int = None
vote_count: int = 0
# 流行度
popularity: int = None
popularity: int = 0
# 时长
runtime: int = None
# 下一集
next_episode_to_air: dict = field(default_factory=dict)
# 内容分级
content_rating: str = None
# 全部剧集组
episode_groups: List[dict] = field(default_factory=list)
# 剧集组
episode_group: str = None
def __post_init__(self):
# 设置媒体信息
@@ -405,8 +376,6 @@ class MediaInfo:
if info.get("external_ids"):
self.tvdb_id = info.get("external_ids", {}).get("tvdb_id")
self.imdb_id = info.get("external_ids", {}).get("imdb_id")
# 合集ID
self.collection_id = info.get('collection_id')
# 评分
self.vote_average = round(float(info.get('vote_average')), 1) if info.get('vote_average') else 0
# 描述
@@ -417,10 +386,6 @@ class MediaInfo:
self.original_language = info.get('original_language')
# 英文标题
self.en_title = info.get('en_title')
# 香港标题
self.hk_title = info.get('hk_title')
# 台湾标题
self.tw_title = info.get('tw_title')
# 新加坡标题
self.sg_title = info.get('sg_title')
if self.type == MediaType.MOVIE:
@@ -458,10 +423,6 @@ class MediaInfo:
air_date = seainfo.get("air_date")
if air_date:
self.season_years[season] = air_date[:4]
# 剧集组
if info.get("episode_groups"):
self.episode_groups = info.pop("episode_groups").get("results") or []
# 海报
if info.get('poster_path'):
self.poster_path = f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{info.get('poster_path')}"
@@ -474,16 +435,7 @@ class MediaInfo:
self.names = info.get('names') or []
# 剩余属性赋值
for key, value in info.items():
if not value:
continue
if not hasattr(self, key):
continue
current_value = getattr(self, key)
if current_value:
continue
if current_value is None:
setattr(self, key, value)
elif type(current_value) is type(value):
if hasattr(self, key) and not getattr(self, key):
setattr(self, key, value)
def set_douban_info(self, info: dict):
@@ -615,16 +567,7 @@ class MediaInfo:
self.production_countries = [{"id": country, "name": country} for country in info.get("countries") or []]
# 剩余属性赋值
for key, value in info.items():
if not value:
continue
if not hasattr(self, key):
continue
current_value = getattr(self, key)
if current_value:
continue
if current_value is None:
setattr(self, key, value)
elif type(current_value) is type(value):
setattr(self, key, value)
def set_bangumi_info(self, info: dict):
@@ -740,7 +683,7 @@ class MediaInfo:
return self.backdrop_path.replace("original", "w500")
return default or ""
def get_message_image(self, default: Optional[bool] = None):
def get_message_image(self, default: bool = None):
"""
返回消息图片地址
"""
@@ -748,7 +691,7 @@ class MediaInfo:
return self.backdrop_path.replace("original", "w500")
return self.get_poster_image(default=default)
def get_poster_image(self, default: Optional[bool] = None):
def get_poster_image(self, default: bool = None):
"""
返回海报图片地址
"""
@@ -756,7 +699,7 @@ class MediaInfo:
return self.poster_path.replace("original", "w500")
return default or ""
def get_overview_string(self, max_len: Optional[int] = 140):
def get_overview_string(self, max_len: int = 140):
"""
返回带限定长度的简介信息
:param max_len: 内容长度
@@ -772,7 +715,7 @@ class MediaInfo:
"""
返回字典
"""
dicts = vars(self).copy()
dicts = asdict(self)
dicts["type"] = self.type.value if self.type else None
dicts["detail_link"] = self.detail_link
dicts["title_year"] = self.title_year
@@ -799,7 +742,6 @@ class MediaInfo:
self.spoken_languages = []
self.networks = []
self.next_episode_to_air = {}
self.episode_groups = []
@dataclass
@@ -814,8 +756,6 @@ class Context:
media_info: MediaInfo = None
# 种子信息
torrent_info: TorrentInfo = None
# 媒体识别失败次数
media_recognize_fail_count: int = 0
def to_dict(self):
"""
@@ -824,6 +764,5 @@ class Context:
return {
"meta_info": self.meta_info.to_dict() if self.meta_info else None,
"torrent_info": self.torrent_info.to_dict() if self.torrent_info else None,
"media_info": self.media_info.to_dict() if self.media_info else None,
"media_recognize_fail_count": self.media_recognize_fail_count
"media_info": self.media_info.to_dict() if self.media_info else None
}

View File

@@ -1,738 +1,123 @@
import importlib
import inspect
import random
import threading
import time
import traceback
import uuid
from queue import Empty, PriorityQueue
from typing import Callable, Dict, List, Optional, Tuple, Union, Any
from queue import Queue, Empty
from typing import Dict, Any
from fastapi.concurrency import run_in_threadpool
from app.helper.thread import ThreadHelper
from app.log import logger
from app.schemas import ChainEventData
from app.schemas.types import ChainEventType, EventType
from app.utils.limit import ExponentialBackoffRateLimiter
from app.utils.singleton import Singleton
DEFAULT_EVENT_PRIORITY = 10 # 事件的默认优先级
MIN_EVENT_CONSUMER_THREADS = 1 # 最小事件消费者线程数
INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 1 # 事件队列空闲时的初始超时时间(秒)
MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS = 5 # 事件队列空闲时的最大超时时间(秒)
class Event:
"""
事件类,封装事件的基本信息
"""
def __init__(self, event_type: Union[EventType, ChainEventType],
event_data: Optional[Union[Dict, ChainEventData]] = None,
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
"""
:param event_type: 事件的类型,支持 EventType 或 ChainEventType
:param event_data: 可选,事件携带的数据,默认为空字典
:param priority: 可选,事件的优先级,默认为 10
"""
self.event_id = str(uuid.uuid4()) # 事件ID
self.event_type = event_type # 事件类型
self.event_data = event_data or {} # 事件数据
self.priority = priority # 事件优先级
def __repr__(self) -> str:
"""
重写 __repr__ 方法用于返回事件的详细信息包括事件类型、事件ID和优先级
"""
event_kind = Event.get_event_kind(self.event_type)
return f"<{event_kind}: {self.event_type.value}, ID: {self.event_id}, Priority: {self.priority}>"
def __lt__(self, other):
"""
定义事件对象的比较规则,基于优先级比较
优先级小的事件会被认为“更小”,优先级高的事件将被认为“更大”
"""
return self.priority < other.priority
@staticmethod
def get_event_kind(event_type: Union[EventType, ChainEventType]) -> str:
"""
根据事件类型判断事件是广播事件还是链式事件
:param event_type: 事件类型,支持 EventType 或 ChainEventType
:return: 返回 Broadcast Event 或 Chain Event
"""
return "Broadcast Event" if isinstance(event_type, EventType) else "Chain Event"
from app.schemas.types import EventType
class EventManager(metaclass=Singleton):
"""
EventManager 负责管理和调度广播事件和链式事件,包括订阅、发送和处理事件
事件管理器
"""
def __init__(self):
self.__executor = ThreadHelper() # 动态线程池,用于消费事件
self.__consumer_threads = [] # 用于保存启动的事件消费者线程
self.__event_queue = PriorityQueue() # 优先级队列
self.__broadcast_subscribers: Dict[EventType, Dict[str, Callable]] = {} # 广播事件的订阅者
self.__chain_subscribers: Dict[ChainEventType, Dict[str, tuple[int, Callable]]] = {} # 链式事件的订阅者
self.__disabled_handlers = set() # 禁用的事件处理器集合
self.__disabled_classes = set() # 禁用的事件处理器类集合
self.__lock = threading.Lock() # 线程锁
self.__event = threading.Event() # 退出事件
# 事件队列
self._eventQueue = Queue()
# 事件响应函数字典
self._handlers: Dict[str, Dict[str, Any]] = {}
# 已禁用的事件响应
self._disabled_handlers = []
def start(self):
def get_event(self):
"""
开始广播事件处理线程
获取事件
"""
# 启动消费者线程用于处理广播事件
self.__event.set()
for _ in range(MIN_EVENT_CONSUMER_THREADS):
thread = threading.Thread(target=self.__broadcast_consumer_loop, daemon=True)
thread.start()
self.__consumer_threads.append(thread) # 将线程对象保存到列表中
def stop(self):
"""
停止广播事件处理线程
"""
logger.info("正在停止事件处理...")
self.__event.clear() # 停止广播事件处理
try:
# 通过遍历保存的线程来等待它们完成
for consumer_thread in self.__consumer_threads:
consumer_thread.join()
logger.info("事件处理停止完成")
except Exception as e:
logger.error(f"停止事件处理线程出错:{str(e)} - {traceback.format_exc()}")
event = self._eventQueue.get(block=True, timeout=1)
handlers = self._handlers.get(event.event_type) or {}
if handlers:
# 去除掉被禁用的事件响应
handlerList = [handler for handler in handlers.values()
if handler.__qualname__.split(".")[0] not in self._disabled_handlers]
return event, handlerList
return event, []
except Empty:
return None, []
def check(self, etype: Union[EventType, ChainEventType]) -> bool:
def check(self, etype: EventType):
"""
检查是否有启用的事件处理器可以响应某个事件类型
:param etype: 事件类型 (EventType 或 ChainEventType)
:return: 返回是否存在可用的处理器
检查事件是否存在响应,去除掉被禁用的事件响应
"""
if isinstance(etype, ChainEventType):
handlers = self.__chain_subscribers.get(etype, {})
return any(
self.__is_handler_enabled(handler)
for _, handler in handlers.values()
)
else:
handlers = self.__broadcast_subscribers.get(etype, {})
return any(
self.__is_handler_enabled(handler)
for handler in handlers.values()
)
def send_event(self, etype: Union[EventType, ChainEventType], data: Optional[Union[Dict, ChainEventData]] = None,
priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
"""
发送事件,根据事件类型决定是广播事件还是链式事件
:param etype: 事件类型 (EventType 或 ChainEventType)
:param data: 可选,事件数据
:param priority: 广播事件的优先级,默认为 10
:return: 如果是链式事件,返回处理后的事件数据;否则返回 None
"""
event = Event(etype, data, priority)
if isinstance(etype, EventType):
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
async def async_send_event(self, etype: Union[EventType, ChainEventType],
data: Optional[Union[Dict, ChainEventData]] = None,
priority: Optional[int] = DEFAULT_EVENT_PRIORITY) -> Optional[Event]:
"""
异步发送事件,根据事件类型决定是广播事件还是链式事件
:param etype: 事件类型 (EventType 或 ChainEventType)
:param data: 可选,事件数据
:param priority: 广播事件的优先级,默认为 10
:return: 如果是链式事件,返回处理后的事件数据;否则返回 None
"""
event = Event(etype, data, priority)
if isinstance(etype, EventType):
return self.__trigger_broadcast_event(event)
elif isinstance(etype, ChainEventType):
return await self.__trigger_chain_event_async(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):
"""
注册事件处理器,将处理器添加到对应的事件订阅列表中
:param event_type: 事件类型 (EventType 或 ChainEventType)
:param handler: 处理器
:param priority: 可选,链式事件的优先级,默认为 10广播事件不需要优先级
"""
with self.__lock:
handler_identifier = self.__get_handler_identifier(handler)
if isinstance(event_type, ChainEventType):
# 链式事件,按优先级排序
if event_type not in self.__chain_subscribers:
self.__chain_subscribers[event_type] = {}
handlers = self.__chain_subscribers[event_type]
if handler_identifier in handlers:
handlers.pop(handler_identifier)
else:
logger.debug(
f"Subscribed to chain event: {event_type.value}, "
f"Priority: {priority} - {handler_identifier}")
handlers[handler_identifier] = (priority, handler)
# 根据优先级排序
self.__chain_subscribers[event_type] = dict(
sorted(self.__chain_subscribers[event_type].items(), key=lambda x: x[1][0])
)
else:
# 广播事件
if event_type not in self.__broadcast_subscribers:
self.__broadcast_subscribers[event_type] = {}
handlers = self.__broadcast_subscribers[event_type]
if handler_identifier in handlers:
handlers.pop(handler_identifier)
else:
logger.debug(f"Subscribed to broadcast event: {event_type.value} - {handler_identifier}")
handlers[handler_identifier] = handler
def remove_event_listener(self, event_type: Union[EventType, ChainEventType], handler: Callable):
"""
移除事件处理器,将处理器从对应事件的订阅列表中删除
:param event_type: 事件类型 (EventType 或 ChainEventType)
:param handler: 要移除的处理器
"""
with self.__lock:
handler_identifier = self.__get_handler_identifier(handler)
if isinstance(event_type, ChainEventType) and event_type in self.__chain_subscribers:
self.__chain_subscribers[event_type].pop(handler_identifier, None)
logger.debug(f"Unsubscribed from chain event: {event_type.value} - {handler_identifier}")
elif event_type in self.__broadcast_subscribers:
self.__broadcast_subscribers[event_type].pop(handler_identifier, None)
logger.debug(f"Unsubscribed from broadcast event: {event_type.value} - {handler_identifier}")
def disable_event_handler(self, target: Union[Callable, type]):
"""
禁用指定的事件处理器或事件处理器类
:param target: 处理器函数或类
"""
identifier = self.__get_handler_identifier(target)
if identifier in self.__disabled_handlers or identifier in self.__disabled_classes:
return
if isinstance(target, type):
self.__disabled_classes.add(identifier)
logger.debug(f"Disabled event handler class - {identifier}")
else:
self.__disabled_handlers.add(identifier)
logger.debug(f"Disabled event handler - {identifier}")
def enable_event_handler(self, target: Union[Callable, type]):
"""
启用指定的事件处理器或事件处理器类
:param target: 处理器函数或类
"""
identifier = self.__get_handler_identifier(target)
if isinstance(target, type):
self.__disabled_classes.discard(identifier)
logger.debug(f"Enabled event handler class - {identifier}")
else:
self.__disabled_handlers.discard(identifier)
logger.debug(f"Enabled event handler - {identifier}")
def visualize_handlers(self) -> List[Dict]:
"""
可视化所有事件处理器,包括是否被禁用的状态
:return: 处理器列表,包含事件类型、处理器标识符、优先级(如果有)和状态
"""
def parse_handler_data(data):
"""
解析处理器数据,判断是否包含优先级
:param data: 订阅者数据,可能是元组或单一值
:return: (priority, handler),若没有优先级则返回 (None, handler)
"""
if isinstance(data, tuple) and len(data) == 2:
return data
return None, data
handler_info = []
# 统一处理广播事件和链式事件
for event_type, subscribers in {**self.__broadcast_subscribers, **self.__chain_subscribers}.items():
for handler_identifier, handler_data in subscribers.items():
# 解析优先级和处理器
priority, handler = parse_handler_data(handler_data)
# 检查处理器的启用状态
status = "enabled" if self.__is_handler_enabled(handler) else "disabled"
# 构建处理器信息字典
handler_dict = {
"event_type": event_type.value,
"handler_identifier": handler_identifier,
"status": status
}
if priority is not None:
handler_dict["priority"] = priority
handler_info.append(handler_dict)
return handler_info
@classmethod
def __get_handler_identifier(cls, target: Union[Callable, type]) -> Optional[str]:
"""
获取处理器或处理器类的唯一标识符,包括模块名和类名/方法名
:param target: 处理器函数或类
:return: 唯一标识符
"""
# 统一使用 inspect.getmodule 来获取模块名
module = inspect.getmodule(target)
module_name = module.__name__ if module else "unknown_module"
# 使用 __qualname__ 获取目标的限定名
qualname = target.__qualname__
return f"{module_name}.{qualname}"
@classmethod
def __get_class_from_callable(cls, handler: Callable) -> Optional[str]:
"""
获取可调用对象所属类的唯一标识符
:param handler: 可调用对象(函数、方法等)
:return: 类的唯一标识符
"""
# 对于绑定方法,通过 __self__.__class__ 获取类
if inspect.ismethod(handler) and hasattr(handler, "__self__"):
return cls.__get_handler_identifier(handler.__self__.__class__)
# 对于类实例(实现了 __call__ 方法)
if not inspect.isfunction(handler) and hasattr(handler, "__call__"):
handler_cls = handler.__class__ # noqa
return cls.__get_handler_identifier(handler_cls)
# 对于未绑定方法、静态方法、类方法,使用 __qualname__ 提取类信息
qualname_parts = handler.__qualname__.split(".")
if len(qualname_parts) > 1:
class_name = ".".join(qualname_parts[:-1])
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:
"""
检查处理器是否已启用(没有被禁用)
:param handler: 处理器函数
:return: 如果处理器启用则返回 True否则返回 False
"""
# 获取处理器的唯一标识符
handler_id = self.__get_handler_identifier(handler)
# 获取处理器所属类的唯一标识符
class_id = self.__get_class_from_callable(handler)
# 检查处理器或类是否被禁用,只要其中之一被禁用则返回 False
if handler_id in self.__disabled_handlers or (class_id is not None and class_id in self.__disabled_classes):
if etype.value not in self._handlers:
return False
handlers = self._handlers.get(etype.value)
return any([handler for handler in handlers.values()
if handler.__qualname__.split(".")[0] not in self._disabled_handlers])
return True
def __trigger_chain_event(self, event: Event) -> Optional[Event]:
def add_event_listener(self, etype: EventType, handler: type):
"""
触发链式事件,按顺序调用订阅的处理器,并记录处理耗时
注册事件处理
"""
logger.debug(f"Triggering synchronous chain event: {event}")
dispatch = self.__dispatch_chain_event(event)
return event if dispatch else None
async def __trigger_chain_event_async(self, event: Event) -> Optional[Event]:
"""
异步触发链式事件,按顺序调用订阅的处理器,并记录处理耗时
"""
logger.debug(f"Triggering asynchronous chain event: {event}")
dispatch = await self.__dispatch_chain_event_async(event)
return event if dispatch else None
def __trigger_broadcast_event(self, event: Event):
"""
触发广播事件,将事件插入到优先级队列中
:param event: 要处理的事件对象
"""
logger.debug(f"Triggering broadcast event: {event}")
self.__event_queue.put((event.priority, event))
def __dispatch_chain_event(self, event: Event) -> bool:
"""
同步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间
:param event: 要调度的事件对象
"""
handlers = self.__chain_subscribers.get(event.event_type, {})
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in enabled_handlers.items():
start_time = time.time()
self.__safe_invoke_handler(handler, event)
logger.debug(
f"{self.__get_handler_identifier(handler)} (Priority: {priority}), "
f"completed in {time.time() - start_time:.3f}s for event: {event}"
)
self.__log_event_lifecycle(event, "Completed")
return True
async def __dispatch_chain_event_async(self, event: Event) -> bool:
"""
异步方式调度链式事件,按优先级顺序逐个调用事件处理器,并记录每个处理器的处理时间
:param event: 要调度的事件对象
"""
handlers = self.__chain_subscribers.get(event.event_type, {})
if not handlers:
logger.debug(f"No handlers found for chain event: {event}")
return False
# 过滤出启用的处理器
enabled_handlers = {handler_id: (priority, handler) for handler_id, (priority, handler) in handlers.items()
if self.__is_handler_enabled(handler)}
if not enabled_handlers:
logger.debug(f"No enabled handlers found for chain event: {event}. Skipping execution.")
return False
self.__log_event_lifecycle(event, "Started")
for handler_id, (priority, handler) in enabled_handlers.items():
start_time = time.time()
await self.__safe_invoke_handler_async(handler, event)
logger.debug(
f"{self.__get_handler_identifier(handler)} (Priority: {priority}), "
f"completed in {time.time() - start_time:.3f}s for event: {event}"
)
self.__log_event_lifecycle(event, "Completed")
return True
def __dispatch_broadcast_event(self, event: Event):
"""
异步方式调度广播事件,通过线程池逐个调用事件处理器
:param event: 要调度的事件对象
"""
handlers = self.__broadcast_subscribers.get(event.event_type, {})
if not handlers:
logger.debug(f"No handlers found for broadcast event: {event}")
return
# 为每个处理器提供独立的事件实例,防止某个处理器对 event_data 的修改影响其他处理器
for handler_id, handler in handlers.items():
# 仅浅拷贝顶层字典,避免不必要的深拷贝开销;这样可以隔离键级别的替换/赋值
if isinstance(event.event_data, dict):
event_data_copy = event.event_data.copy()
else:
event_data_copy = event.event_data
isolated_event = Event(event_type=event.event_type,
event_data=event_data_copy,
priority=event.priority)
self.__executor.submit(self.__safe_invoke_handler, handler, isolated_event)
def __safe_invoke_handler(self, handler: Callable, event: Event):
"""
调用处理器,处理链式或广播事件
:param handler: 处理器
:param event: 事件对象
"""
if not self.__is_handler_enabled(handler):
logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution")
return
self.__invoke_handler_by_type_sync(handler, event)
async def __safe_invoke_handler_async(self, handler: Callable, event: Event):
"""
异步调用处理器,处理链式事件
:param handler: 处理器
:param event: 事件对象
"""
if not self.__is_handler_enabled(handler):
logger.debug(f"Handler {self.__get_handler_identifier(handler)} is disabled. Skipping execution")
return
await self.__invoke_handler_by_type_async(handler, event)
def __invoke_handler_by_type_sync(self, handler: Callable, event: Event):
"""
同步方式根据处理器类型调用相应的方法
:param handler: 处理器
:param event: 要处理的事件对象
"""
class_name, method_name = self.__parse_handler_names(handler)
from app.core.plugin import PluginManager
from app.core.module import ModuleManager
plugin_manager = PluginManager()
module_manager = ModuleManager()
if class_name in plugin_manager.get_plugin_ids():
# 插件处理器
plugin = plugin_manager.running_plugins.get(class_name)
if not plugin:
return
method = getattr(plugin, method_name, None)
if not method:
return
try:
method(event)
except Exception as e:
self.__handle_event_error(event=event, module_name=plugin.name,
class_name=class_name, method_name=method_name, e=e)
elif class_name in module_manager.get_module_ids():
# 模块处理器
module = module_manager.get_running_module(class_name)
if not module:
return
method = getattr(module, method_name, None)
if not method:
return
try:
method(event)
except Exception as e:
self.__handle_event_error(event=event, module_name=module.get_name(),
class_name=class_name, method_name=method_name, e=e)
else:
# 全局处理器
class_obj = self.__get_class_instance(class_name)
if not class_obj or not hasattr(class_obj, method_name):
return
method = getattr(class_obj, method_name, None)
if not method:
return
try:
method(event)
except Exception as e:
self.__handle_event_error(event=event, module_name=class_name,
class_name=class_name, method_name=method_name, e=e)
async def __invoke_handler_by_type_async(self, handler: Callable, event: Event):
"""
异步方式根据处理器类型调用相应的方法
:param handler: 处理器
:param event: 要处理的事件对象
"""
class_name, method_name = self.__parse_handler_names(handler)
from app.core.plugin import PluginManager
from app.core.module import ModuleManager
plugin_manager = PluginManager()
module_manager = ModuleManager()
if class_name in plugin_manager.get_plugin_ids():
await self.__invoke_plugin_method_async(plugin_manager, class_name, method_name, event)
elif class_name in module_manager.get_module_ids():
await self.__invoke_module_method_async(module_manager, class_name, method_name, event)
else:
await self.__invoke_global_method_async(class_name, method_name, event)
@staticmethod
def __parse_handler_names(handler: Callable) -> Tuple[str, str]:
"""
解析处理器的类名和方法名
:param handler: 处理器
:return: (class_name, method_name)
"""
names = handler.__qualname__.split(".")
return names[0], names[1]
async def __invoke_plugin_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):
"""
异步调用插件方法
"""
plugin = handler.running_plugins.get(class_name)
if not plugin:
return
method = getattr(plugin, method_name, None)
if not method:
return
try:
if inspect.iscoroutinefunction(method):
await method(event)
else:
# 插件同步函数在异步环境中运行,避免阻塞
await run_in_threadpool(method, event)
except Exception as e:
self.__handle_event_error(event=event, handler=handler, e=e, module_name=plugin.name)
handlers = self._handlers[etype.value]
except KeyError:
handlers = {}
self._handlers[etype.value] = handlers
if handler.__qualname__ in handlers:
handlers.pop(handler.__qualname__)
else:
logger.debug(f"Event Registed{etype.value} - {handler.__qualname__}")
handlers[handler.__qualname__] = handler
async def __invoke_module_method_async(self, handler: Any, class_name: str, method_name: str, event: Event):
def disable_events_hander(self, class_name: str):
"""
异步调用模块方法
标记对应类事件处理为不可用
"""
module = handler.get_running_module(class_name)
if not module:
if class_name not in self._disabled_handlers:
self._disabled_handlers.append(class_name)
logger.debug(f"Event Disabled{class_name}")
def enable_events_hander(self, class_name: str):
"""
标记对应类事件处理为可用
"""
if class_name in self._disabled_handlers:
self._disabled_handlers.remove(class_name)
logger.debug(f"Event Enabled{class_name}")
def send_event(self, etype: EventType, data: dict = None):
"""
发送事件
"""
if etype not in EventType:
return
method = getattr(module, method_name, None)
if not method:
return
try:
if inspect.iscoroutinefunction(method):
await method(event)
else:
method(event)
except Exception as e:
self.__handle_event_error(event=event, module_name=module.get_name(),
class_name=class_name, method_name=method_name, e=e)
event = Event(etype.value)
event.event_data = data or {}
logger.debug(f"发送事件:{etype.value} - {event.event_data}")
self._eventQueue.put(event)
async def __invoke_global_method_async(self, class_name: str, method_name: str, event: Event):
def register(self, etype: [EventType, list]):
"""
异步调用全局对象方法
"""
class_obj = self.__get_class_instance(class_name)
if not class_obj:
return
method = getattr(class_obj, method_name, None)
if not method:
return
try:
if inspect.iscoroutinefunction(method):
await method(event)
else:
method(event)
except Exception as e:
self.__handle_event_error(event=event, module_name=class_name,
class_name=class_name, method_name=method_name, e=e)
@staticmethod
def __get_class_instance(class_name: str):
"""
根据类名获取类实例,首先检查全局变量中是否存在该类,如果不存在则尝试动态导入模块。
:param class_name: 类的名称
:return: 类的实例
"""
# 检查类是否在全局变量中
if class_name in globals():
try:
class_obj = globals()[class_name]()
return class_obj
except Exception as e:
logger.error(f"事件处理出错:创建全局类实例出错:{str(e)} - {traceback.format_exc()}")
return None
# 如果类不在全局变量中,尝试动态导入模块并创建实例
try:
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"):
# 特殊处理 Async 类
if class_name.startswith("Async"):
module_name = f"app.helper.{class_name[5:-6].lower()}"
else:
module_name = f"app.helper.{class_name[:-6].lower()}"
module = importlib.import_module(module_name)
else:
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.debug(f"事件处理出错:{str(e)} - {traceback.format_exc()}")
return None
def __broadcast_consumer_loop(self):
"""
持续从队列中提取事件的后台广播消费者线程
"""
jitter_factor = 0.1
rate_limiter = ExponentialBackoffRateLimiter(base_wait=INITIAL_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS,
max_wait=MAX_EVENT_QUEUE_IDLE_TIMEOUT_SECONDS,
backoff_factor=2.0,
source="BroadcastConsumer",
enable_logging=False)
while self.__event.is_set():
try:
priority, event = self.__event_queue.get(timeout=rate_limiter.current_wait)
rate_limiter.reset()
self.__dispatch_broadcast_event(event)
except Empty:
rate_limiter.current_wait = rate_limiter.current_wait * random.uniform(1, 1 + jitter_factor)
rate_limiter.trigger_limit()
@staticmethod
def __log_event_lifecycle(event: Event, stage: str):
"""
记录事件的生命周期日志
"""
logger.debug(f"{stage} - {event}")
def __handle_event_error(self, event: Event, module_name: str,
class_name: str, method_name: str, e: Exception):
"""
全局错误处理器,用于处理事件处理中的异常
"""
logger.error(f"{module_name} 事件处理出错:{str(e)} - {traceback.format_exc()}")
# 发送系统错误通知
from app.helper.message import MessageHelper
MessageHelper().put(title=f"{module_name} 处理事件 {event.event_type} 时出错",
message=f"{class_name}.{method_name}{str(e)}",
role="system")
self.send_event(
EventType.SystemError,
{
"type": "event",
"event_type": event.event_type,
"event_handle": f"{class_name}.{method_name}",
"error": str(e),
"traceback": traceback.format_exc()
}
)
def register(self, etype: Union[EventType, ChainEventType, List[Union[EventType, ChainEventType]], type],
priority: Optional[int] = DEFAULT_EVENT_PRIORITY):
"""
事件注册装饰器,用于将函数注册为事件的处理器
:param etype:
- 单个事件类型成员 (如 EventType.MetadataScrape, ChainEventType.PluginAction)
- 事件类型类 (EventType, ChainEventType)
- 或事件类型成员的列表
:param priority: 可选,链式事件的优先级,默认为 DEFAULT_EVENT_PRIORITY
事件注册
:param etype: 事件类型
"""
def decorator(f: Callable):
# 将输入的事件类型统一转换为列表格式
def decorator(f):
if isinstance(etype, list):
# 传入的已经是列表,直接使用
event_list = etype
for et in etype:
self.add_event_listener(et, f)
elif type(etype) == type(EventType):
for et in etype.__members__.values():
self.add_event_listener(et, f)
else:
# 不是列表则包裹成单一元素的列表
event_list = [etype]
# 遍历列表,处理每个事件类型
for event in event_list:
if isinstance(event, (EventType, ChainEventType)):
self.add_event_listener(event, f, priority)
elif isinstance(event, type) and issubclass(event, (EventType, ChainEventType)):
# 如果是 EventType 或 ChainEventType 类,提取该类中的所有成员
for et in event.__members__.values():
self.add_event_listener(et, f, priority)
else:
raise ValueError(f"无效的事件类型: {event}")
self.add_event_listener(etype, f)
return f
return decorator
# 全局实例定义
class Event(object):
"""
事件对象
"""
def __init__(self, event_type=None):
# 事件类型
self.event_type = event_type
# 字典用于保存具体的事件数据
self.event_data = {}
# 实例引用,用于注册事件
eventmanager = EventManager()

View File

@@ -9,6 +9,8 @@ class CustomizationMatcher(metaclass=Singleton):
"""
识别自定义占位符
"""
customization = None
custom_separator = None
def __init__(self):
self.systemconfig = SystemConfigOper()

View File

@@ -81,6 +81,7 @@ class MetaAnime(MetaBase):
_, self.cn_name, _, _, _, _ = StringUtils.get_keyword(self.cn_name)
if self.cn_name:
self.cn_name = re.sub(r'%s' % self._name_nostring_re, '', self.cn_name, flags=re.IGNORECASE).strip()
self.cn_name = zhconv.convert(self.cn_name, "zh-hans")
if self.en_name:
self.en_name = re.sub(r'%s' % self._name_nostring_re, '', self.en_name, flags=re.IGNORECASE).strip().title()
self._name = StringUtils.str_title(self.en_name)

View File

@@ -1,13 +1,13 @@
import traceback
from dataclasses import dataclass
from dataclasses import dataclass, asdict
from typing import Union, Optional, List, Self
import cn2an
import regex as re
from app.log import logger
from app.schemas.types import MediaType
from app.utils.string import StringUtils
from app.schemas.types import MediaType
@dataclass
@@ -55,8 +55,6 @@ class MetaBase(object):
resource_team: Optional[str] = None
# 识别的自定义占位符
customization: Optional[str] = None
# 识别的流媒体平台
web_source: Optional[str] = None
# 视频编码
video_encode: Optional[str] = None
# 音频编码
@@ -71,7 +69,7 @@ class MetaBase(object):
_subtitle_flag = False
_title_episodel_re = r"Episode\s+(\d{1,4})"
_subtitle_season_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十S\-]+)\s*季(?!\s*[全共])"
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季"
_subtitle_season_all_re = r"[全共]\s*([0-9一二三四五六七八九十]+)\s*季|([0-9一二三四五六七八九十]+)\s*季\s*全"
_subtitle_episode_re = r"(?<![全共]\s*)[第\s]+([0-9一二三四五六七八九十百零EP]+)\s*[集话話期幕](?!\s*[全共])"
_subtitle_episode_between_re = r"[第]*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]?\s*-\s*第*\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
_subtitle_episode_all_re = r"([0-9一二三四五六七八九十百零]+)\s*集\s*全|[全共]\s*([0-9一二三四五六七八九十百零]+)\s*[集话話期幕]"
@@ -249,7 +247,7 @@ class MetaBase(object):
self.type = MediaType.TV
self._subtitle_flag = True
return
# x集全/全x集
# x集全
episode_all_str = re.search(r'%s' % self._subtitle_episode_all_re, title_text, re.IGNORECASE)
if episode_all_str:
episode_all = episode_all_str.group(1)
@@ -261,6 +259,8 @@ class MetaBase(object):
except Exception as err:
logger.debug(f'识别集失败:{str(err)} - {traceback.format_exc()}')
return
self.begin_episode = None
self.end_episode = None
self.type = MediaType.TV
self._subtitle_flag = True
return
@@ -584,21 +584,14 @@ 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):
"""
转为字典
"""
dicts = vars(self).copy()
dicts = asdict(self)
dicts["type"] = self.type.value if self.type else None
dicts["season_episode"] = self.season_episode
dicts["edition"] = self.edition
dicts["name"] = self.name
dicts["episode_list"] = self.episode_list
return dicts

View File

@@ -10,7 +10,6 @@ from app.core.meta.releasegroup import ReleaseGroupsMatcher
from app.schemas.types import MediaType
from app.utils.string import StringUtils
from app.utils.tokens import Tokens
from app.core.meta.streamingplatform import StreamingPlatforms
class MetaVideo(MetaBase):
@@ -31,8 +30,8 @@ class MetaVideo(MetaBase):
_episode_re = r"EP?(\d{2,4})$|^EP?(\d{1,4})$|^S\d{1,2}EP?(\d{1,4})$|S\d{2}EP?(\d{2,4})"
_part_re = r"(^PART[0-9ABI]{0,2}$|^CD[0-9]{0,2}$|^DVD[0-9]{0,2}$|^DISK[0-9]{0,2}$|^DISC[0-9]{0,2}$)"
_roman_numerals = r"^(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"
_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$|^HLG$|^HDR10(\+|Plus)$|^EDR$|^HQ$"
_source_re = r"^BLURAY$|^HDTV$|^UHDTV$|^HDDVD$|^WEBRIP$|^DVDRIP$|^BDRIP$|^BLU$|^WEB$|^BD$|^HDRip$"
_effect_re = r"^REMUX$|^UHD$|^SDR$|^HDR\d*$|^DOLBY$|^DOVI$|^DV$|^3D$|^REPACK$"
_resources_type_re = r"%s|%s" % (_source_re, _effect_re)
_name_no_begin_re = r"^[\[【].+?[\]】]"
_name_no_chinese_re = r".*版|.*字幕"
@@ -51,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"^(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?$|^AV[3S]A$"
_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?$"
def __init__(self, title: str, subtitle: str = None, isfile: bool = False):
"""
@@ -67,7 +66,6 @@ class MetaVideo(MetaBase):
original_title = title
self._source = ""
self._effect = []
self._index = 0
# 判断是否纯数字命名
if isfile \
and title.isdigit() \
@@ -95,12 +93,9 @@ class MetaVideo(MetaBase):
# 拆分tokens
tokens = Tokens(title)
self.tokens = tokens
# 实例化StreamingPlatforms对象
streaming_platforms = StreamingPlatforms()
# 解析名称、年份、季、集、资源类型、分辨率等
token = tokens.get_next()
while token:
self._index += 1 # 更新当前处理的token索引
# Part
self.__init_part(token)
# 标题
@@ -121,9 +116,6 @@ class MetaVideo(MetaBase):
# 资源类型
if self._continue_flag:
self.__init_resource_type(token)
# 流媒体平台
if self._continue_flag:
self.__init_web_source(token, streaming_platforms)
# 视频编码
if self._continue_flag:
self.__init_video_encode(token)
@@ -180,7 +172,7 @@ class MetaVideo(MetaBase):
return None
@staticmethod
def __is_pinyin(name_str: Optional[str]) -> bool:
def __is_pinyin(name_str: str) -> bool:
"""
判断是否拼音
"""
@@ -191,7 +183,7 @@ class MetaVideo(MetaBase):
return False
return True
def __fix_name(self, name: Optional[str]):
def __fix_name(self, name: str):
"""
去掉名字中不需要的干扰字符
"""
@@ -200,7 +192,7 @@ class MetaVideo(MetaBase):
name = re.sub(r'%s' % self._name_nostring_re, '', name,
flags=re.IGNORECASE).strip()
name = re.sub(r'\s+', ' ', name)
if name.isdecimal() \
if name.isdigit() \
and int(name) < 1800 \
and not self.year \
and not self.begin_season \
@@ -215,7 +207,7 @@ class MetaVideo(MetaBase):
name = None
return name
def __init_name(self, token: Optional[str]):
def __init_name(self, token: str):
"""
识别名称
"""
@@ -532,37 +524,6 @@ class MetaVideo(MetaBase):
"""
if not self.name:
return
if token.upper() == "DL" \
and self._last_token_type == "source" \
and self._last_token == "WEB":
self._source = "WEB-DL"
self._continue_flag = False
return
elif token.upper() == "RAY" \
and self._last_token_type == "source" \
and self._last_token == "BLU":
# UHD BluRay组合
if self._source == "UHD":
self._source = "UHD BluRay"
else:
self._source = "BluRay"
self._continue_flag = False
return
elif token.upper() == "WEBDL":
self._source = "WEB-DL"
self._continue_flag = False
return
# UHD REMUX组合
if token.upper() == "REMUX" \
and self._source == "BluRay":
self._source = "BluRay REMUX"
self._continue_flag = False
return
elif token.upper() == "BLURAY" \
and self._source == "UHD":
self._source = "UHD BluRay"
self._continue_flag = False
return
source_res = re.search(r"(%s)" % self._source_re, token, re.IGNORECASE)
if source_res:
self._last_token_type = "source"
@@ -572,6 +533,22 @@ class MetaVideo(MetaBase):
self._source = source_res.group(1)
self._last_token = self._source.upper()
return
elif token.upper() == "DL" \
and self._last_token_type == "source" \
and self._last_token == "WEB":
self._source = "WEB-DL"
self._continue_flag = False
return
elif token.upper() == "RAY" \
and self._last_token_type == "source" \
and self._last_token == "BLU":
self._source = "BluRay"
self._continue_flag = False
return
elif token.upper() == "WEBDL":
self._source = "WEB-DL"
self._continue_flag = False
return
effect_res = re.search(r"(%s)" % self._effect_re, token, re.IGNORECASE)
if effect_res:
self._last_token_type = "effect"
@@ -582,57 +559,6 @@ class MetaVideo(MetaBase):
self._effect.append(effect)
self._last_token = effect.upper()
def __init_web_source(self, token: str, streaming_platforms: StreamingPlatforms):
"""
识别流媒体平台
"""
if not self.name:
return
platform_name = None
query_range = 1
prev_token = None
prev_idx = self._index - 2
if 0 <= prev_idx < len(self.tokens.tokens):
prev_token = self.tokens.tokens[prev_idx]
next_token = self.tokens.peek()
if streaming_platforms.is_streaming_platform(token):
platform_name = streaming_platforms.get_streaming_platform_name(token)
else:
for adjacent_token, is_next in [(prev_token, False), (next_token, True)]:
if not adjacent_token or platform_name:
continue
for separator in [" ", "-"]:
if is_next:
combined_token = f"{token}{separator}{adjacent_token}"
else:
combined_token = f"{adjacent_token}{separator}{token}"
if streaming_platforms.is_streaming_platform(combined_token):
platform_name = streaming_platforms.get_streaming_platform_name(combined_token)
query_range = 2
if is_next:
self.tokens.get_next()
break
if not platform_name:
return
web_tokens = ["WEB", "DL", "WEBDL", "WEBRIP"]
match_start_idx = self._index - query_range
match_end_idx = self._index - 1
start_index = max(0, match_start_idx - query_range)
end_index = min(len(self.tokens.tokens), match_end_idx + 1 + query_range)
tokens_to_check = self.tokens.tokens[start_index:end_index]
if any(tok and tok.upper() in web_tokens for tok in tokens_to_check):
self.web_source = platform_name
self._continue_flag = False
def __init_video_encode(self, token: str):
"""
识别视频编码
@@ -651,12 +577,7 @@ class MetaVideo(MetaBase):
self._stop_name_flag = True
self._last_token_type = "videoencode"
if not self.video_encode:
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.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"

View File

@@ -9,37 +9,38 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
"""
识别制作组、字幕组
"""
__release_groups: str = None
# 内置组
RELEASE_GROUPS: dict = {
"0ff": ['FF(?:(?:A|WE)B|CD|E(?:DU|B)|TV)'],
"1pt": [],
"52pt": [],
"audiences": ['Audies', 'AD(?:Audio|E(?:book|)|Music|Web)'],
"audiences": ['Audies', 'AD(?:Audio|E(?:|book)|Music|Web)'],
"azusa": [],
"beitai": ['BeiTai'],
"btschool": ['Bts(?:CHOOL|HD|PAD|TV)', 'Zone'],
"carpt": ['CarPT'],
"chdbits": ['CHD(?:Bits|PAD|(?:|HK)TV|WEB|)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],
"chdbits": ['CHD(?:|Bits|PAD|(?:|HK)TV|WEB)', 'StBOX', 'OneHD', 'Lee', 'xiaopie'],
"discfan": [],
"dragonhd": [],
"eastgame": ['(?:(?:iNT|(?:HALFC|Mini(?:S|H|FH)D))-|)TLF'],
"filelist": [],
"gainbound": ['(?:DG|GBWE)B'],
"hares": ['Hares(?:(?:M|T)V|Web|)'],
"hares": ['Hares(?:|(?:M|T)V|Web)'],
"hd4fans": [],
"hdarea": ['HDA(?:pad|rea|TV)', 'EPiC'],
"hdatmos": [],
"hdbd": [],
"hdchina": ['HDC(?:hina|TV|)', 'k9611', 'tudou', 'iHD'],
"hdchina": ['HDC(?:|hina|TV)', 'k9611', 'tudou', 'iHD'],
"hddolby": ['D(?:ream|BTV)', '(?:HD|QHstudI)o'],
"hdfans": ['beAst(?:TV|)'],
"hdhome": ['HDH(?:ome|Pad|TV|WEB|)'],
"hdpt": ['HDPT(?:Web|)'],
"hdsky": ['HDS(?:ky|TV|Pad|WEB|)', 'AQLJ'],
"hdfans": ['beAst(?:|TV)'],
"hdhome": ['HDH(?:|ome|Pad|TV|WEB)'],
"hdpt": ['HDPT(?:|Web)'],
"hdsky": ['HDS(?:|ky|TV|Pad|WEB)', 'AQLJ'],
"hdtime": [],
"HDU": [],
"hdvideo": [],
"hdzone": ['HDZ(?:one|)'],
"hdzone": ['HDZ(?:|one)'],
"hhanclub": ['HHWEB'],
"hitpt": [],
"htpt": ['HTPT'],
@@ -47,39 +48,37 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
"joyhd": [],
"keepfrds": ['FRDS', 'Yumi', 'cXcY'],
"lemonhd": ['L(?:eague(?:(?:C|H)D|(?:M|T)V|NF|WEB)|HD)', 'i18n', 'CiNT'],
"mteam": ['MTeam(?:TV|)', 'MPAD', 'MWeb'],
"mteam": ['MTeam(?:|TV)', 'MPAD'],
"nanyangpt": [],
"nicept": [],
"oshen": [],
"ourbits": ['Our(?:Bits|TV)', 'FLTTH', 'Ao', 'PbK', 'MGs', 'iLove(?:HD|TV)'],
"piggo": ['PiGo(?:NF|(?:H|WE)B)'],
"ptchina": [],
"pterclub": ['PTer(?:DIY|Game|(?:M|T)V|WEB|)'],
"pthome": ['PTH(?:Audio|eBook|music|ome|tv|WEB|)'],
"pterclub": ['PTer(?:|DIY|Game|(?:M|T)V|WEB)'],
"pthome": ['PTH(?:|Audio|eBook|music|ome|tv|WEB)'],
"ptmsg": [],
"ptsbao": ['PTsbao', 'OPS', 'F(?:Fans(?:AIeNcE|BD|D(?:VD|IY)|TV|WEB)|HDMv)', 'SGXT'],
"pttime": [],
"putao": ['PuTao'],
"soulvoice": [],
"springsunday": ['CMCT(?:V|)'],
"sharkpt": ['Shark(?:WEB|DIY|TV|MV|)'],
"springsunday": ['CMCT(?:|V)'],
"sharkpt": ['Shark(?:|WEB|DIY|TV|MV)'],
"tccf": [],
"tjupt": ['TJUPT'],
"totheglory": ['TTG', 'WiKi', 'NGB', 'DoA', '(?:ARi|ExRE)N'],
"U2": [],
"ultrahd": [],
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:yG|)',
"others": ['B(?:MDru|eyondHD|TN)', 'C(?:fandora|trlhd|MRG)', 'DON', 'EVO', 'FLUX', 'HONE(?:|yG)',
'N(?:oGroup|T(?:b|G))', 'PandaMoon', 'SMURF', 'T(?:EPES|aengoo|rollHD )'],
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组', '极影字幕社',
'悠哈璃羽字幕社',
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组'],
"forge": ['FROG(?:E|Web|)'],
"ubits": ['UB(?:its|WEB|TV)'],
"anime": ['ANi', 'HYSUB', 'KTXP', 'LoliHouse', 'MCE', 'Nekomoe kissaten', 'SweetSub', 'MingY',
'(?:Lilith|NC)-Raws', '织梦字幕组', '枫叶字幕组', '猎户手抄部', '喵萌奶茶屋', '漫猫字幕社',
'霜庭云花Sub', '北宇治字幕组', '氢气烤肉架', '云歌字幕组', '萌樱字幕组','极影字幕社','悠哈璃羽字幕社',
'❀拨雪寻春❀', '沸羊羊(?:制作|字幕组)', '(?:桜|樱)都字幕组',]
}
def __init__(self):
self.systemconfig = SystemConfigOper()
release_groups = []
for site_groups in self.RELEASE_GROUPS.values():
for release_group in site_groups:
@@ -96,20 +95,17 @@ class ReleaseGroupsMatcher(metaclass=Singleton):
return ""
if not groups:
# 自定义组
custom_release_groups = SystemConfigOper().get(SystemConfigKey.CustomReleaseGroups)
if isinstance(custom_release_groups, list):
custom_release_groups = list(filter(None, custom_release_groups))
custom_release_groups = self.systemconfig.get(SystemConfigKey.CustomReleaseGroups)
if custom_release_groups:
custom_release_groups_str = '|'.join(custom_release_groups)
groups = f"{self.__release_groups}|{custom_release_groups_str}"
else:
groups = self.__release_groups
title = f"{title} "
groups_re = re.compile(r"(?<=[-@\[£【&])(?:(?:%s))(?=[@.\s\S\]\[】&])" % groups, re.I)
groups_re = re.compile(r"(?<=[-@\[£【&])(?:%s)(?=[@.\s\]\[】&])" % groups, re.I)
# 处理一个制作组识别多次的情况,保留顺序
unique_groups = []
for item in re.findall(groups_re, title):
item_str = item[0] if isinstance(item, tuple) else item
if item_str not in unique_groups:
unique_groups.append(item_str)
if item not in unique_groups:
unique_groups.append(item)
return "@".join(unique_groups)

View File

@@ -1,314 +0,0 @@
from typing import Optional, List, Tuple
from app.utils.singleton import Singleton
class StreamingPlatforms(metaclass=Singleton):
"""
流媒体平台简称与全称。
"""
STREAMING_PLATFORMS: List[Tuple[str, str]] = [
("AMZN", "Amazon"),
("NF", "Netflix"),
("ATVP", "Apple TV+"),
("iT", "iTunes"),
("DSNP", "Disney+"),
("HS", "Hotstar"),
("APPS", "Disney+ MENA"),
("PMTP", "Paramount+"),
("HMAX", "Max"),
("", "Max"),
("HULU", "Hulu Networks"),
("MA", "Movies Anywhere"),
("BCORE", "Bravia Core"),
("MS", "Microsoft Store"),
("SHO", "Showtime"),
("STAN", "Stan"),
("PCOK", "Peacock"),
("SKST", "SkyShowtime"),
("NOW", "Now"),
("FXTL", "Foxtel Now"),
("BNGE", "Binge"),
("CRKL", "Crackle"),
("RKTN", "Rakuten TV"),
("ALL4", "Channel 4"),
("AS", "Adult Swim"),
("BRTB", "Brtb TV"),
("CNLP", "Canal+"),
("CRIT", "Criterion Channel"),
("DSCP", "Discovery+"),
("FOOD", "Food Network"),
("MUBI", "Mubi"),
("PLAY", "Google Play"),
("YT", "YouTube"),
("", "friDay"),
("", "KKTV"),
("", "ofiii"),
("", "LiTV"),
("", "MyVideo"),
("Hami", "Hami Video"),
("HamiVideo", "Hami Video"),
("MW", "meWATCH"),
("CATCHPLAY", "CATCHPLAY+"),
("CPP", "CATCHPLAY+"),
("LINETV", "LINE TV"),
("VIU", "Viu"),
("IQ", ""),
("", "WeTV"),
("ABMA", "Abema"),
("ADN", ""),
("AT-X", ""),
("Baha", ""),
("BG", "B-Global"),
("CR", "Crunchyroll"),
("", "DMM"),
("FOD", ""),
("FUNi", "Funimation"),
("HIDI", "HIDIVE"),
("UNXT", "U-NEXT"),
("FAA", "Filmarchiv Austria"),
("CC", "Comedy Central"),
("iP", "BBC iPlayer"),
("9NOW", "9Now"),
("ABC", ""),
("", "AMC"),
("", "ZEE5"),
("", "WAVO"),
("SHAHID", "Shahid"),
("Flixole", "FlixOlé"),
("TOU", "Ici TOU.TV"),
("ROKU", "Roku"),
("KNPY", "Kanopy"),
("SNXT", "Sun NXT"),
("CUR", "Curiosity Stream"),
("MY5", "Channel 5"),
("AHA", "aha"),
("WOWP", "WOW Presents Plus"),
("JC", "JioCinema"),
("", "Dekkoo"),
("FILMZIE", "Filmzie"),
("HoiChoi", "Hoichoi"),
("VIKI", "Rakuten Viki"),
("SF", "SF Anytime"),
("PLEX", "Plex"),
("SHDR", "Shudder"),
("CRAV", "Crave"),
("CPE", "Cineplex Entertainment"),
("JF HC", ""),
("JF", ""),
("JFFP", ""),
("VIAP", "Viaplay"),
("TUBI", "TubiTV"),
("", "PBS"),
("PBSK", "PBS KIDS"),
("LGP", "Lionsgate Play"),
("", "CTV"),
("", "Cineverse"),
("LN", "Love Nature"),
("MP", "Movistar Plus+"),
("RUNTIME", "Runtime"),
("STZ", "STARZ"),
("FUBO", "fuboTV"),
("TENK", "Tënk"),
("KNOW", "Knowledge Network"),
("TVO", "tvo"),
("", "OVID"),
("CBC", "CBC Gem"),
("FANDOR", "fandor"),
("CW", "The CW"),
("KNPY", "Kanopy"),
("FREE", "Freeform"),
("AE", "A&E"),
("LIFE", "Lifetime"),
("WWEN", "WWE Network"),
("CMAX", "Cinemax"),
("HLMK", "Hallmark"),
("BYU", "BYUtv"),
("", "ViX"),
("VICE", "Viceland"),
("", "TVING"),
("USAN", "USA Network"),
("FOX", ""),
("", "TCM"),
("BRAV", "BravoTV"),
("", "TNT"),
("", "ZDF"),
("", "IndieFlix"),
("", "TLC"),
("", "HGTV"),
("ANPL", "Animal Planet"),
("TRVL", "Travel Channel"),
("", "VH1"),
("SAINA", "Saina Play"),
("SP", "Saina Play"),
("OXGN", "Oxygen"),
("PSN", "PlayStation Network"),
("PMNT", "Paramount Network"),
("FAWESOME", "Fawesome"),
("KLASSIKI", "Klassiki"),
("STRP", "Star+"),
("NATG", "National Geographic"),
("REVEEL", "Reveel"),
("FYI", "FYI Network"),
("WatchiT", "WATCH IT"),
("ITVX", "ITV"),
("GAIA", "Gaia"),
("", "FlixLatino"),
("CNNP", "CNN+"),
("TROMA", "Troma"),
("IVI", "Ivi"),
("9NOW", "9Now"),
("A3P", "Atresplayer"),
("7PLUS", "7plus"),
("", "SBS"),
("TEN", "10Play"),
("AUBC", ""),
("DSNY", "Disney Networks"),
("OSN", "OSN+"),
("SVT", "Sveriges Television"),
("LACINETEK", "LaCinetek"),
("", "Maxdome"),
("RTL", "RTL+"),
("ARTE", "Arte"),
("JOYN", "Joyn"),
("TV2", "TV 2"),
("3SAT", "3sat"),
("FILMINGO", "filmingo"),
("", "WOW"),
("OKKO", "Okko"),
("", "Go3"),
("ARGP", "Argo"),
("VOYO", "Voyo"),
("VMAX", "vivamax"),
("FILMIN", "Filmin"),
("", "Mitele"),
("MY5", "Channel 5"),
("", "ARD"),
("BK", "Bentkey"),
("BOOM", "Boomerang"),
("", "CBS"),
("CLBI", "Club illico"),
("CMOR", "C More"),
("CMT", ""),
("", "CNBC"),
("COOK", "Cooking Channel"),
("CWS", "CW Seed"),
("DCU", "DC Universe"),
("DDY", "Digiturk Dilediğin Yerde"),
("DEST", "Destination America"),
("DISC", "Discovery Channel"),
("DW", "DailyWire+"),
("DLWP", "DailyWire+"),
("DPLY", "dplay"),
("DRPO", "Dropout"),
("EPIX", "EPIX MGM+"),
("ESQ", "Esquire"),
("ETV", "E!"),
("FBWatch", "Facebook Watch"),
("FPT", "FPT Play"),
("FTV", "France.tv"),
("GLOB", "GloboSat Play"),
("GLBO", "Globoplay"),
("GO90", "go90"),
("HIST", "History Channel"),
("HPLAY", "Hungama Play"),
("KS", "Kaleidescape"),
("", "MBC"),
("MMAX", "ManoramaMAX"),
("MNBC", "MSNBC"),
("MTOD", "Motor Trend OnDemand"),
("NBC", ""),
("NBLA", "Nebula"),
("NICK", "Nickelodeon"),
("ODK", "OnDemandKorea"),
("POGO", "PokerGO"),
("PUHU", "puhutv"),
("QIBI", "Quibi"),
("RTE", "RTÉ"),
("SESO", "Seeso"),
("SPIK", "Spike"),
("SS", "Simply South"),
("SYFY", "SyFy"),
("TIMV", "TIMvision"),
("TK", "Tentkotta"),
("", "TV4"),
("TVL", "TV Land"),
("", "TVNZ"),
("", "UKTV"),
("VLCT", "Discovery Velocity"),
("VMEO", "Vimeo"),
("VRV", "VRV Defunct"),
("WTCH", "Watcha"),
("", "NowPlayer"),
("HuluJP", "Hulu Networks"),
("Gaga", "GagaOOLala"),
("MyTVS", "MyTVSuper"),
("", "BBC"),
("CC", "Comedy Central"),
("NowE", "Now E"),
("WAVVE", "Wavve"),
("SE", ""),
("", "BritBox"),
("AOD", "Anime on Demand"),
("AF", ""),
("BCH", "Bandai Channel"),
("VMJ", "VideoMarket"),
("LFTL", "Laftel"),
("WAKA", "Wakanim"),
("WAKANIM", "Wakanim"),
("AO", "AnimeOnegai"),
("", "Lemino"),
("VIDIO", "Vidio"),
("TVER", "TVer"),
("", "MBS"),
("LFTLNET", "Laftel"),
("JONU", "Jonu Play"),
("PlutoTV", "Pluto TV"),
("AbemaTV", "Abema"),
("", "dTV"),
("NYMEY", "Nymey"),
("SMNS", "SAMANSA"),
("CTHP", "CATCHPLAY+"),
("HBOGO", "HBO GO"),
("HBO", "HBO"),
("FPTP", "FPT Play"),
("", "LOCIPO"),
("DANT", "DANET"),
("OV", "OceanVeil"),
]
def __init__(self):
"""初始化流媒体平台匹配器"""
self._lookup_cache = {}
self._build_cache()
def _build_cache(self) -> None:
"""
构建查询缓存。
"""
self._lookup_cache.clear()
for short_name, full_name in self.STREAMING_PLATFORMS:
canonical_name = full_name or short_name
if not canonical_name:
continue
aliases = {short_name, full_name}
for alias in aliases:
if alias:
self._lookup_cache[alias.upper()] = canonical_name
def get_streaming_platform_name(self, platform_code: str) -> Optional[str]:
"""
根据流媒体平台简称或全称获取标准名称。
"""
if platform_code is None:
return None
return self._lookup_cache.get(platform_code.upper())
def is_streaming_platform(self, name: str) -> bool:
"""
判断给定的字符串是否为已知的流媒体平台代码或名称。
"""
if name is None:
return False
return name.upper() in self._lookup_cache

View File

@@ -14,7 +14,7 @@ class WordsMatcher(metaclass=Singleton):
def __init__(self):
self.systemconfig = SystemConfigOper()
def prepare(self, title: str, custom_words: List[str] = None) -> Tuple[str, List[str]]:
def prepare(self, title: str) -> Tuple[str, List[str]]:
"""
预处理标题,支持三种格式
1屏蔽词
@@ -23,7 +23,7 @@ class WordsMatcher(metaclass=Singleton):
"""
appley_words = []
# 读取自定义识别词
words: List[str] = custom_words or self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
words: List[str] = self.systemconfig.get(SystemConfigKey.CustomIdentifiers) or []
for word in words:
if not word or word.startswith("#"):
continue

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Tuple, List, Optional
from typing import Tuple
import regex as re
@@ -10,18 +10,17 @@ from app.log import logger
from app.schemas.types import MediaType
def MetaInfo(title: str, subtitle: Optional[str] = None, custom_words: List[str] = None) -> MetaBase:
def MetaInfo(title: str, subtitle: str = None) -> MetaBase:
"""
根据标题和副标题识别元数据
:param title: 标题、种子名、文件名
:param subtitle: 副标题、描述
:param custom_words: 自定义识别词列表
:return: MetaAnime、MetaVideo
"""
# 原标题
org_title = title
# 预处理标题
title, apply_words = WordsMatcher().prepare(title, custom_words=custom_words)
title, apply_words = WordsMatcher().prepare(title)
# 获取标题中媒体信息
title, metainfo = find_metainfo(title)
# 判断是否处理文件
@@ -92,8 +91,7 @@ def is_anime(name: str) -> bool:
return True
if re.search(r'\s+-\s+[\dv]{1,4}\s+', name, re.IGNORECASE):
return True
if re.search(r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}",
name,
if re.search(r"S\d{2}\s*-\s*S\d{2}|S\d{2}|\s+S\d{1,2}|EP?\d{2,4}\s*-\s*EP?\d{2,4}|EP?\d{2,4}|\s+EP?\d{1,4}", name,
re.IGNORECASE):
return False
if re.search(r'\[[+0-9XVPI-]+]\s*\[', name, re.IGNORECASE):
@@ -120,69 +118,44 @@ 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 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":
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:
match mtype[0]:
case "movie":
metainfo['type'] = MediaType.MOVIE
elif mtype[0] == "tv":
case "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()
case _:
pass
# 查找季信息
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 metainfo.get('begin_season') and metainfo.get('end_season'):
if metainfo['begin_season'] > metainfo['end_season']:

View File

@@ -1,12 +1,9 @@
import traceback
from typing import Generator, Optional, Tuple, Any, Union, List
from typing import Generator, Optional, Tuple, Any
from app.core.config import settings
from app.core.event import eventmanager
from app.helper.module import ModuleHelper
from app.log import logger
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType
from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton
@@ -16,14 +13,12 @@ class ModuleManager(metaclass=Singleton):
模块管理器
"""
# 模块类型集合
SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]
# 模块列表
_modules: dict = {}
# 运行态模块列表
_running_modules: dict = {}
def __init__(self):
# 模块列表
self._modules: dict = {}
# 运行态模块列表
self._running_modules: dict = {}
self.load_modules()
def load_modules(self):
@@ -64,7 +59,7 @@ class ModuleManager(metaclass=Singleton):
logger.info(f"Moudle Stoped{module_id}")
except Exception as err:
logger.error(f"Stop Moudle Error{module_id}{str(err)} - {traceback.format_exc()}", exc_info=True)
logger.info("所有模块停止完成")
logger.info("模块停止完成")
def reload(self):
"""
@@ -72,21 +67,17 @@ class ModuleManager(metaclass=Singleton):
"""
self.stop()
self.load_modules()
eventmanager.send_event(etype=EventType.ModuleReload, data={})
def test(self, modleid: str) -> Tuple[bool, str]:
"""
测试模块
"""
if modleid not in self._running_modules:
return False, ""
return False, "模块未加载,请检查参数设置"
module = self._running_modules[modleid]
if hasattr(module, "test") \
and ObjectUtils.check_method(getattr(module, "test")):
result = module.test()
if not result:
return False, ""
return result
return module.test()
return True, "模块不支持测试"
@staticmethod
@@ -121,34 +112,12 @@ class ModuleManager(metaclass=Singleton):
获取实现了同一方法的模块列表
"""
if not self._running_modules:
return
return []
for _, module in self._running_modules.items():
if hasattr(module, method) \
and ObjectUtils.check_method(getattr(module, method)):
yield module
def get_running_type_modules(self, module_type: ModuleType) -> Generator:
"""
获取指定类型的模块列表
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_type') \
and module.get_type() == module_type:
yield module
def get_running_subtype_module(self, module_subtype: SubType) -> Generator:
"""
获取指定子类型的模块
"""
if not self._running_modules:
return
for _, module in self._running_modules.items():
if hasattr(module, 'get_subtype') \
and module.get_subtype() == module_subtype:
yield module
def get_module(self, module_id: str) -> Any:
"""
根据模块id获取模块
@@ -164,9 +133,3 @@ class ModuleManager(metaclass=Singleton):
获取模块列表
"""
return self._modules
def get_module_ids(self) -> List[str]:
"""
获取模块id列表
"""
return list(self._modules.keys())

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +1,59 @@
import base64
import datetime
import hashlib
import hmac
import json
import os
import traceback
from datetime import timedelta
from typing import Any, Union, Annotated, Optional
from datetime import datetime, timedelta
from typing import Any, Union, Optional, Annotated
import jwt
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from cryptography.fernet import Fernet
from fastapi import HTTPException, status, Security, Request, Response
from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, APIKeyQuery, APIKeyCookie
from fastapi import HTTPException, status, Depends, Header
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from app import schemas
from app.core.config import settings
from cryptography.fernet import Fernet
from app.log import logger
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
# OAuth2PasswordBearer 用于 JWT Token 认证
oauth2_scheme = OAuth2PasswordBearer(
# Token认证
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
# RESOURCE TOKEN 通过 Cookie 认证
resource_token_cookie = APIKeyCookie(name=settings.PROJECT_NAME, auto_error=False, scheme_name="resource_token_cookie")
# API TOKEN 通过 QUERY 认证
api_token_query = APIKeyQuery(name="token", auto_error=False, scheme_name="api_token_query")
# API KEY 通过 Header 认证
api_key_header = APIKeyHeader(name="X-API-KEY", auto_error=False, scheme_name="api_key_header")
# API KEY 通过 QUERY 认证
api_key_query = APIKeyQuery(name="apikey", auto_error=False, scheme_name="api_key_query")
def create_access_token(
userid: Union[str, Any],
username: str,
super_user: Optional[bool] = False,
expires_delta: Optional[timedelta] = None,
level: Optional[int] = 1,
purpose: Optional[str] = "authentication"
userid: Union[str, Any], username: str, super_user: bool = False,
expires_delta: timedelta = None, level: int = 1
) -> str:
"""
创建 JWT 访问令牌,包含用户 ID、用户名、是否为超级用户以及权限等级
:param userid: 用户的唯一标识符,通常是字符串或整数
:param username: 用户名,用于标识用户的账户名
:param super_user: 是否为超级用户,默认值为 False
:param expires_delta: 令牌的有效期时长,如果不提供则根据用途使用默认过期时间
:param level: 用户的权限级别,默认为 1
:param purpose: 令牌的用途,"authentication""resource"
:return: 编码后的 JWT 令牌字符串
:raises ValueError: 如果 expires_delta 为负数
"""
if purpose == "resource":
default_expire = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
secret_key = settings.RESOURCE_SECRET_KEY
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
default_expire = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
secret_key = settings.SECRET_KEY
if expires_delta is not None:
if expires_delta.total_seconds() <= 0:
raise ValueError("过期时间必须为正数")
expire = datetime.datetime.now(datetime.UTC) + expires_delta
else:
expire = datetime.datetime.now(datetime.UTC) + default_expire
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
"exp": expire,
"iat": datetime.datetime.now(datetime.UTC),
"sub": str(userid),
"username": username,
"super_user": super_user,
"level": level,
"purpose": purpose
"level": level
}
encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM)
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def __set_or_refresh_resource_token_cookie(request: Request, response: Response, payload: schemas.TokenPayload):
"""
设置资源令牌 Cookie
:param request: 包含请求相关的上下文数据
:param response: 用于在服务器响应时设置 Cookie
:param payload: 已通过身份验证的 TokenPayload 对象
"""
resource_token = request.cookies.get(settings.PROJECT_NAME)
if resource_token:
# 检查令牌剩余时间
try:
decoded_token = jwt.decode(resource_token, settings.RESOURCE_SECRET_KEY, algorithms=[ALGORITHM])
exp = decoded_token.get("exp")
if exp:
remaining_time = datetime.datetime.fromtimestamp(exp, tz=datetime.UTC) - datetime.datetime.now(datetime.UTC)
# 根据剩余时长提前刷新令牌
if remaining_time < timedelta(seconds=(settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS / 3)):
raise jwt.ExpiredSignatureError
except jwt.PyJWTError:
logger.debug(f"Token error occurred. refreshing token")
except Exception as e:
logger.debug(f"Unexpected error occurred while decoding token: {e}")
else:
# 如果令牌有效且没有即将过期,则不需要刷新
return
# 创建新的资源访问令牌
resource_token_expires = timedelta(seconds=settings.RESOURCE_ACCESS_TOKEN_EXPIRE_SECONDS)
resource_token = create_access_token(
userid=payload.sub,
username=payload.username,
super_user=payload.super_user,
expires_delta=resource_token_expires,
level=payload.level,
purpose="resource"
)
# 设置会话级别的 HttpOnly Cookie
response.set_cookie(
key=settings.PROJECT_NAME,
value=resource_token,
httponly=True,
secure=request.url.scheme == "https", # 根据当前请求的协议设置 secure 属性
samesite="lax" # 不同浏览器对 "Strict" 的处理可能不同,设置 SameSite 为 "Lax",以平衡安全性和兼容性
)
def __verify_token(token: str, purpose: Optional[str] = "authentication") -> schemas.TokenPayload:
"""
使用 JWT Token 进行身份认证并解析 Token 的内容
:param token: JWT 令牌
:param purpose: 期望的令牌用途,默认为 "authentication"
:return: 包含用户身份信息的 Token 负载数据
:raises HTTPException: 如果令牌无效或用途不匹配
"""
def verify_token(token: str = Depends(reusable_oauth2)) -> schemas.TokenPayload:
try:
if purpose == "resource":
secret_key = settings.RESOURCE_SECRET_KEY
else:
secret_key = settings.SECRET_KEY
if not token:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"{purpose} token not found"
)
payload = jwt.decode(
token, secret_key, algorithms=[ALGORITHM]
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
token_payload = schemas.TokenPayload(**payload)
if token_payload.purpose != purpose:
raise jwt.InvalidTokenError("令牌用途不匹配")
return schemas.TokenPayload(**payload)
except (jwt.DecodeError, jwt.InvalidTokenError, jwt.ImmatureSignatureError):
raise HTTPException(
@@ -173,98 +62,54 @@ def __verify_token(token: str, purpose: Optional[str] = "authentication") -> sch
)
def verify_token(
request: Request,
response: Response,
token: Annotated[str, Security(oauth2_scheme)]
) -> schemas.TokenPayload:
def __get_token(token: str = None) -> str:
"""
验证 JWT 令牌并自动处理 resource_token 写入
:param request: 请求对象,用于访问 Cookie 和请求信息
:param response: 响应对象,用于设置 Cookie
:param token: 从 Authorization 头部获取的 JWT 令牌
:return: 解析后的 TokenPayload
:raises HTTPException: 如果令牌无效或用途不匹配
从请求URL中获取token
"""
# 验证并解析 JWT 认证令牌
payload = __verify_token(token=token, purpose="authentication")
# 如果没有 resource_token生成并写入到 Cookie
__set_or_refresh_resource_token_cookie(request, response, payload)
return payload
return token
def verify_resource_token(
resource_token: Annotated[str, Security(resource_token_cookie)]
) -> schemas.TokenPayload:
def __get_apikey(apikey: str = None, x_api_key: Annotated[str | None, Header()] = None) -> str:
"""
验证资源访问令牌(从 Cookie 中获取)
:param resource_token: 从 Cookie 中获取的资源访问令牌
:return: 解析后的 TokenPayload
:raises HTTPException: 如果资源访问令牌无效
从请求URL中获取apikey
"""
# 验证并解析资源访问令牌
return __verify_token(token=resource_token, purpose="resource")
return apikey or x_api_key
def __get_api_token(
token_query: Annotated[str | None, Security(api_token_query)] = None
) -> str:
def verify_apitoken(token: str = Depends(__get_token)) -> str:
"""
从 URL 查询参数中获取 API Token
:param token_query: 从 URL 中的 `token` 查询参数获取 API Token
:return: 返回获取到的 API Token若无则返回 None
通过依赖项使用token进行身份认证
"""
return token_query
def __get_api_key(
key_query: Annotated[str | None, Security(api_key_query)] = None,
key_header: Annotated[str | None, Security(api_key_header)] = None
) -> str:
"""
从 URL 查询参数或请求头部获取 API Key优先使用 URL 参数
:param key_query: URL 中的 `apikey` 查询参数
:param key_header: 请求头中的 `X-API-KEY` 参数
:return: 返回从 URL 或请求头中获取的 API Key若无则返回 None
"""
return key_query or key_header
def __verify_key(key: str, expected_key: str, key_type: str) -> str:
"""
通用的 API Key 或 Token 验证函数
:param key: 从请求中获取的 API Key 或 Token
:param expected_key: 系统配置中的期望值,用于验证的 API Key 或 Token
:param key_type: 键的类型(例如 "API_KEY""API_TOKEN"),用于错误消息
:return: 返回校验通过的 API Key 或 Token
:raises HTTPException: 如果校验不通过,抛出 401 错误
"""
if key != expected_key:
if token != settings.API_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"{key_type} 校验不通过"
detail="token校验不通过"
)
return key
return token
def verify_apitoken(token: Annotated[str, Security(__get_api_token)]) -> str:
def verify_apikey(apikey: str = Depends(__get_apikey)) -> str:
"""
使用 API Token 进行身份认证
:param token: API Token从 URL 查询参数中获取 token=xxx
:return: 返回校验通过的 API Token
通过依赖项使用apikey进行身份认证
"""
return __verify_key(token, settings.API_TOKEN, "token")
if apikey != settings.API_TOKEN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="apikey校验不通过"
)
return apikey
def verify_apikey(apikey: Annotated[str, Security(__get_api_key)]) -> str:
def verify_uri_token(token: str = Depends(__get_token)) -> str:
"""
使用 API Key 进行身份认证
:param apikey: API Key从 URL 查询参数中获取 apikey=xxx
:return: 返回校验通过的 API Key
通过依赖项使用token进行身份认证
"""
return __verify_key(apikey, settings.API_TOKEN, "apikey")
if not verify_token(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token校验不通过"
)
return token
def verify_password(plain_password: str, hashed_password: str) -> bool:
@@ -287,7 +132,7 @@ def decrypt(data: bytes, key: bytes) -> Optional[bytes]:
return None
def encrypt_message(message: str, key: bytes) -> str:
def encrypt_message(message: str, key: bytes):
"""
使用给定的key对消息进行加密并返回加密后的字符串
"""
@@ -296,14 +141,14 @@ def encrypt_message(message: str, key: bytes) -> str:
return encrypted_message.decode()
def hash_sha256(message: str) -> str:
def hash_sha256(message):
"""
对字符串做hash运算
"""
return hashlib.sha256(message.encode()).hexdigest()
def aes_decrypt(data: str, key: str) -> str:
def aes_decrypt(data, key):
"""
AES解密
"""
@@ -323,7 +168,7 @@ def aes_decrypt(data: str, key: str) -> str:
return result.decode('utf-8')
def aes_encrypt(data: str, key: str) -> str:
def aes_encrypt(data, key):
"""
AES加密
"""
@@ -339,7 +184,7 @@ def aes_encrypt(data: str, key: str) -> str:
return base64.b64encode(cipher.iv + result).decode('utf-8')
def nexusphp_encrypt(data_str: str, key: bytes) -> str:
def nexusphp_encrypt(data_str: str, key):
"""
NexusPHP加密
"""

View File

@@ -1,300 +0,0 @@
import threading
from time import sleep
from typing import Dict, Any, Optional
from typing import List, Tuple
from app.core.config import global_vars
from app.core.event import eventmanager, Event
from app.db.models import Workflow
from app.db.workflow_oper import WorkflowOper
from app.helper.module import ModuleHelper
from app.log import logger
from app.schemas import ActionContext, Action
from app.schemas.types import EventType
from app.utils.singleton import Singleton
class WorkFlowManager(metaclass=Singleton):
"""
工作流管理器
"""
def __init__(self):
# 所有动作定义
self._lock = threading.Lock()
self._actions: Dict[str, Any] = {}
self._event_workflows: Dict[str, List[int]] = {}
self.init()
def init(self):
"""
初始化
"""
def filter_func(obj: Any):
"""
过滤函数,确保只加载新定义的类
"""
if not isinstance(obj, type):
return False
if not hasattr(obj, 'execute') or not hasattr(obj, "name"):
return False
if obj.__name__ == "BaseAction":
return False
return obj.__module__.startswith("app.actions")
# 加载所有动作
self._actions = {}
actions = ModuleHelper.load(
"app.actions",
filter_func=lambda _, obj: filter_func(obj)
)
for action in actions:
logger.debug(f"加载动作: {action.__name__}")
try:
self._actions[action.__name__] = action
except Exception as err:
logger.error(f"加载动作失败: {action.__name__} - {err}")
# 加载工作流事件触发器
self.load_workflow_events()
def stop(self):
"""
停止
"""
self._actions = {}
self._event_workflows = {}
def excute(self, workflow_id: int, action: Action,
context: ActionContext = None) -> Tuple[bool, str, ActionContext]:
"""
执行工作流动作
"""
if not context:
context = ActionContext()
if action.type in self._actions:
# 实例化之前,清理掉类对象的数据
# 实例化
action_obj = self._actions[action.type](action.id)
# 执行
logger.info(f"执行动作: {action.id} - {action.name}")
try:
result_context = action_obj.execute(workflow_id, action.data, context)
except Exception as err:
logger.error(f"{action.name} 执行失败: {err}")
return False, f"{err}", context
loop = action.data.get("loop")
loop_interval = action.data.get("loop_interval")
if loop and loop_interval:
while not action_obj.done:
if global_vars.is_workflow_stopped(workflow_id):
break
# 等待
logger.info(f"{action.name} 等待 {loop_interval} 秒后继续执行 ...")
sleep(loop_interval)
# 执行
logger.info(f"继续执行动作: {action.id} - {action.name}")
result_context = action_obj.execute(workflow_id, action.data, result_context)
if action_obj.success:
logger.info(f"{action.name} 执行成功")
else:
logger.error(f"{action.name} 执行失败!")
return action_obj.success, action_obj.message, result_context
else:
logger.error(f"未找到动作: {action.type} - {action.name}")
return False, " ", context
def list_actions(self) -> List[dict]:
"""
获取所有动作
"""
return [
{
"type": key,
"name": action.name,
"description": action.description,
"data": {
"label": action.name,
**action.data
}
} for key, action in self._actions.items()
]
def update_workflow_event(self, workflow: Workflow):
"""
更新工作流事件触发器
"""
# 确保先移除旧的事件监听器
self.remove_workflow_event(workflow_id=workflow.id, event_type_str=workflow.event_type)
# 如果工作流是事件触发类型且未被禁用
if workflow.trigger_type == "event" and workflow.state != 'P':
# 注册事件触发器
self.register_workflow_event(workflow.id, workflow.event_type)
def load_workflow_events(self, workflow_id: Optional[int] = None):
"""
加载工作流触发事件
"""
workflows = []
if workflow_id:
workflow = WorkflowOper().get(workflow_id)
if workflow:
workflows = [workflow]
else:
workflows = WorkflowOper().get_event_triggered_workflows()
try:
for workflow in workflows:
self.update_workflow_event(workflow)
except Exception as e:
logger.error(f"加载事件触发工作流失败: {e}")
def register_workflow_event(self, workflow_id: int, event_type_str: str):
"""
注册工作流事件触发器
"""
try:
event_type = EventType(event_type_str)
except ValueError:
logger.error(f"无效的事件类型: {event_type_str}")
return
if event_type in EventType:
# 确保先移除旧的事件监听器
self.remove_workflow_event(workflow_id, event_type.value)
with self._lock:
# 添加新的事件监听器
eventmanager.add_event_listener(event_type, self._handle_event)
# 记录工作流事件触发器
if event_type.value not in self._event_workflows:
self._event_workflows[event_type.value] = []
self._event_workflows[event_type.value].append(workflow_id)
logger.info(f"已注册工作流 {workflow_id} 事件触发器: {event_type.value}")
def remove_workflow_event(self, workflow_id: int, event_type_str: str):
"""
移除工作流事件触发器
"""
try:
event_type = EventType(event_type_str)
except ValueError:
logger.error(f"无效的事件类型: {event_type_str}")
return
if event_type in EventType:
with self._lock:
eventmanager.remove_event_listener(event_type, self._handle_event)
if event_type.value in self._event_workflows:
if workflow_id in self._event_workflows[event_type.value]:
self._event_workflows[event_type.value].remove(workflow_id)
if not self._event_workflows[event_type.value]:
del self._event_workflows[event_type.value]
logger.info(f"已移除工作流 {workflow_id} 事件触发器")
def _handle_event(self, event: Event):
"""
处理事件,触发相应的工作流
"""
try:
event_type_str = str(event.event_type.value)
with self._lock:
if event_type_str not in self._event_workflows:
return
workflow_ids = self._event_workflows[event_type_str].copy()
for workflow_id in workflow_ids:
self._trigger_workflow(workflow_id, event)
except Exception as e:
logger.error(f"处理工作流事件失败: {e}")
def _trigger_workflow(self, workflow_id: int, event: Event):
"""
触发工作流执行
"""
try:
# 检查工作流是否存在且启用
workflow = WorkflowOper().get(workflow_id)
if not workflow or workflow.state == 'P':
return
# 检查事件条件
if not self._check_event_conditions(workflow, event):
logger.debug(f"工作流 {workflow.name} 事件条件不匹配,跳过执行")
return
# 检查工作流是否正在运行
if workflow.state == 'R':
logger.warning(f"工作流 {workflow.name} 正在运行中,跳过重复触发")
return
logger.info(f"事件 {event.event_type.value} 触发工作流: {workflow.name}")
# 发送工作流执行事件以启动工作流
eventmanager.send_event(EventType.WorkflowExecute, {
"workflow_id": workflow_id,
})
except Exception as e:
logger.error(f"触发工作流 {workflow_id} 失败: {e}")
def _check_event_conditions(self, workflow, event: Event) -> bool:
"""
检查事件是否满足工作流的触发条件
"""
if not workflow.event_conditions:
return True
conditions = workflow.event_conditions
event_data = event.event_data or {}
# 检查字段匹配条件
for field, expected_value in conditions.items():
if field not in event_data:
return False
actual_value = event_data[field]
# 支持多种条件匹配方式
if isinstance(expected_value, dict):
# 复杂条件匹配
if not self._check_complex_condition(actual_value, expected_value):
return False
else:
# 简单值匹配
if actual_value != expected_value:
return False
return True
@staticmethod
def _check_complex_condition(actual_value: any, condition: dict) -> bool:
"""
检查复杂条件匹配
支持的操作符equals, not_equals, contains, not_contains, in, not_in, regex
"""
for operator, expected_value in condition.items():
if operator == "equals":
if actual_value != expected_value:
return False
elif operator == "not_equals":
if actual_value == expected_value:
return False
elif operator == "contains":
if expected_value not in str(actual_value):
return False
elif operator == "not_contains":
if expected_value in str(actual_value):
return False
elif operator == "in":
if actual_value not in expected_value:
return False
elif operator == "not_in":
if actual_value in expected_value:
return False
elif operator == "regex":
import re
if not re.search(expected_value, str(actual_value)):
return False
return True
def get_event_workflows(self) -> dict:
"""
获取所有事件触发的工作流
"""
with self._lock:
return self._event_workflows.copy()

View File

@@ -1,191 +1,27 @@
import asyncio
from typing import Any, Generator, List, Optional, Self, Tuple, AsyncGenerator, Union
from typing import Any, Self, List
from typing import Tuple, Optional, Generator
from sqlalchemy import NullPool, QueuePool, and_, create_engine, inspect, text, select, delete, Column, Integer, \
Sequence, Identity
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, as_declarative, declared_attr, scoped_session, sessionmaker
from sqlalchemy import create_engine, QueuePool
from sqlalchemy import inspect
from sqlalchemy.orm import declared_attr
from sqlalchemy.orm import sessionmaker, Session, scoped_session, as_declarative
from app.core.config import settings
def get_id_column():
"""
根据数据库类型返回合适的ID列定义
"""
if settings.DB_TYPE.lower() == "postgresql":
# PostgreSQL使用SERIAL类型让数据库自动处理序列
return Column(Integer, Identity(start=1, cycle=True), primary_key=True, index=True)
else:
# SQLite使用Sequence
return Column(Integer, Sequence('id'), primary_key=True, index=True)
def _get_database_engine(is_async: bool = False):
"""
获取数据库连接参数并设置WAL模式
:param is_async: 是否创建异步引擎True - 异步引擎, False - 同步引擎
:return: 返回对应的数据库引擎
"""
# 根据数据库类型选择连接方式
if settings.DB_TYPE.lower() == "postgresql":
return _get_postgresql_engine(is_async)
else:
return _get_sqlite_engine(is_async)
def _get_sqlite_engine(is_async: bool = False):
"""
获取SQLite数据库引擎
"""
# 连接参数
_connect_args = {
"timeout": settings.DB_TIMEOUT,
}
# 启用 WAL 模式时的额外配置
if settings.DB_WAL_ENABLE:
_connect_args["check_same_thread"] = False
# 创建同步引擎
if not is_async:
# 根据池类型设置 poolclass 和相关参数
_pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
# 数据库参数
_db_kwargs = {
"url": f"sqlite:///{settings.CONFIG_PATH}/user.db",
"pool_pre_ping": settings.DB_POOL_PRE_PING,
"echo": settings.DB_ECHO,
"poolclass": _pool_class,
"pool_recycle": settings.DB_POOL_RECYCLE,
"connect_args": _connect_args
}
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
if _pool_class == QueuePool:
_db_kwargs.update({
"pool_size": settings.DB_SQLITE_POOL_SIZE,
"pool_timeout": settings.DB_POOL_TIMEOUT,
"max_overflow": settings.DB_SQLITE_MAX_OVERFLOW
})
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
with engine.connect() as connection:
current_mode = connection.execute(text(f"PRAGMA journal_mode={_journal_mode};")).scalar()
print(f"SQLite database journal mode set to: {current_mode}")
return engine
else:
# 数据库参数,只能使用 NullPool
_db_kwargs = {
"url": f"sqlite+aiosqlite:///{settings.CONFIG_PATH}/user.db",
"pool_pre_ping": settings.DB_POOL_PRE_PING,
"echo": settings.DB_ECHO,
"poolclass": NullPool,
"pool_recycle": settings.DB_POOL_RECYCLE,
"connect_args": _connect_args
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
# 设置WAL模式
_journal_mode = "WAL" if settings.DB_WAL_ENABLE else "DELETE"
async def set_async_wal_mode():
"""
设置异步引擎的WAL模式
"""
async with async_engine.connect() as _connection:
result = await _connection.execute(text(f"PRAGMA journal_mode={_journal_mode};"))
_current_mode = result.scalar()
print(f"Async SQLite database journal mode set to: {_current_mode}")
try:
asyncio.run(set_async_wal_mode())
except Exception as e:
print(f"Failed to set async SQLite WAL mode: {e}")
return async_engine
def _get_postgresql_engine(is_async: bool = False):
"""
获取PostgreSQL数据库引擎
"""
# 构建PostgreSQL连接URL
if settings.DB_POSTGRESQL_PASSWORD:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
else:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
# PostgreSQL连接参数
_connect_args = {}
# 创建同步引擎
if not is_async:
# 根据池类型设置 poolclass 和相关参数
_pool_class = NullPool if settings.DB_POOL_TYPE == "NullPool" else QueuePool
# 数据库参数
_db_kwargs = {
"url": db_url,
"pool_pre_ping": settings.DB_POOL_PRE_PING,
"echo": settings.DB_ECHO,
"poolclass": _pool_class,
"pool_recycle": settings.DB_POOL_RECYCLE,
"connect_args": _connect_args
}
# 当使用 QueuePool 时,添加 QueuePool 特有的参数
if _pool_class == QueuePool:
_db_kwargs.update({
"pool_size": settings.DB_POSTGRESQL_POOL_SIZE,
"pool_timeout": settings.DB_POOL_TIMEOUT,
"max_overflow": settings.DB_POSTGRESQL_MAX_OVERFLOW
})
# 创建数据库引擎
engine = create_engine(**_db_kwargs)
print(f"PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}")
return engine
else:
# 构建异步PostgreSQL连接URL
async_db_url = f"postgresql+asyncpg://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
# 数据库参数,只能使用 NullPool
_db_kwargs = {
"url": async_db_url,
"pool_pre_ping": settings.DB_POOL_PRE_PING,
"echo": settings.DB_ECHO,
"poolclass": NullPool,
"pool_recycle": settings.DB_POOL_RECYCLE,
"connect_args": _connect_args
}
# 创建异步数据库引擎
async_engine = create_async_engine(**_db_kwargs)
print(f"Async PostgreSQL database connected to {settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}")
return async_engine
# 同步数据库引擎
Engine = _get_database_engine(is_async=False)
# 异步数据库引擎
AsyncEngine = _get_database_engine(is_async=True)
# 同步会话工厂
# 数据库引擎
Engine = create_engine(f"sqlite:///{settings.CONFIG_PATH}/user.db",
pool_pre_ping=True,
echo=False,
poolclass=QueuePool,
pool_size=1024,
pool_recycle=3600,
pool_timeout=180,
max_overflow=10,
connect_args={"timeout": 60})
# 会话工厂
SessionFactory = sessionmaker(bind=Engine)
# 异步会话工厂
AsyncSessionFactory = async_sessionmaker(bind=AsyncEngine, class_=AsyncSession)
# 同步多线程全局使用的数据库会话
# 多线程全局使用的数据库会话
ScopedSession = scoped_session(SessionFactory)
@@ -203,32 +39,7 @@ def get_db() -> Generator:
db.close()
async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
"""
获取异步数据库会话用于WEB请求
:return: AsyncSession
"""
async with AsyncSessionFactory() as session:
try:
yield session
finally:
await session.close()
async def close_database():
"""
关闭所有数据库连接并清理资源
"""
try:
# 释放同步连接池
Engine.dispose() # noqa
# 释放异步连接池
await AsyncEngine.dispose()
except Exception as err:
print(f"Error while disposing database connections: {err}")
def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
def get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
"""
从参数中获取数据库Session对象
"""
@@ -246,25 +57,7 @@ def _get_args_db(args: tuple, kwargs: dict) -> Optional[Session]:
return db
def _get_args_async_db(args: tuple, kwargs: dict) -> Optional[AsyncSession]:
"""
从参数中获取异步数据库AsyncSession对象
"""
db = None
if args:
for arg in args:
if isinstance(arg, AsyncSession):
db = arg
break
if kwargs:
for key, value in kwargs.items():
if isinstance(value, AsyncSession):
db = value
break
return db
def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
def update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict]:
"""
更新参数中的数据库Session对象关键字传参时更新db的值否则更新第1或第2个参数
"""
@@ -278,20 +71,6 @@ def _update_args_db(args: tuple, kwargs: dict, db: Session) -> Tuple[tuple, dict
return args, kwargs
def _update_args_async_db(args: tuple, kwargs: dict, db: AsyncSession) -> Tuple[tuple, dict]:
"""
更新参数中的异步数据库AsyncSession对象关键字传参时更新db的值否则更新第1或第2个参数
"""
if kwargs and 'db' in kwargs:
kwargs['db'] = db
elif args:
if args[0] is None:
args = (db, *args[1:])
else:
args = (args[0], db, *args[2:])
return args, kwargs
def db_update(func):
"""
数据库更新类操作装饰器第一个参数必须是数据库会话或存在db参数
@@ -301,14 +80,14 @@ def db_update(func):
# 是否关闭数据库会话
_close_db = False
# 从参数中获取数据库会话
db = _get_args_db(args, kwargs)
db = get_args_db(args, kwargs)
if not db:
# 如果没有获取到数据库会话,创建一个
db = ScopedSession()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的数据库会话
args, kwargs = _update_args_db(args, kwargs, db)
args, kwargs = update_args_db(args, kwargs, db)
try:
# 执行函数
result = func(*args, **kwargs)
@@ -327,41 +106,6 @@ def db_update(func):
return wrapper
def async_db_update(func):
"""
异步数据库更新类操作装饰器第一个参数必须是异步数据库会话或存在db参数
"""
async def wrapper(*args, **kwargs):
# 是否关闭数据库会话
_close_db = False
# 从参数中获取异步数据库会话
db = _get_args_async_db(args, kwargs)
if not db:
# 如果没有获取到异步数据库会话,创建一个
db = AsyncSessionFactory()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的异步数据库会话
args, kwargs = _update_args_async_db(args, kwargs, db)
try:
# 执行函数
result = await func(*args, **kwargs)
# 提交事务
await db.commit()
except Exception as err:
# 回滚事务
await db.rollback()
raise err
finally:
# 关闭数据库会话
if _close_db:
await db.close()
return result
return wrapper
def db_query(func):
"""
数据库查询操作装饰器第一个参数必须是数据库会话或存在db参数
@@ -372,14 +116,14 @@ def db_query(func):
# 是否关闭数据库会话
_close_db = False
# 从参数中获取数据库会话
db = _get_args_db(args, kwargs)
db = get_args_db(args, kwargs)
if not db:
# 如果没有获取到数据库会话,创建一个
db = ScopedSession()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的数据库会话
args, kwargs = _update_args_db(args, kwargs, db)
args, kwargs = update_args_db(args, kwargs, db)
try:
# 执行函数
result = func(*args, **kwargs)
@@ -394,38 +138,6 @@ def db_query(func):
return wrapper
def async_db_query(func):
"""
异步数据库查询操作装饰器第一个参数必须是异步数据库会话或存在db参数
注意db.query列表数据时需要转换为list返回
"""
async def wrapper(*args, **kwargs):
# 是否关闭数据库会话
_close_db = False
# 从参数中获取异步数据库会话
db = _get_args_async_db(args, kwargs)
if not db:
# 如果没有获取到异步数据库会话,创建一个
db = AsyncSessionFactory()
# 标记需要关闭数据库会话
_close_db = True
# 更新参数中的异步数据库会话
args, kwargs = _update_args_async_db(args, kwargs, db)
try:
# 执行函数
result = await func(*args, **kwargs)
except Exception as err:
raise err
finally:
# 关闭数据库会话
if _close_db:
await db.close()
return result
return wrapper
@as_declarative()
class Base:
id: Any
@@ -435,22 +147,10 @@ class Base:
def create(self, db: Session):
db.add(self)
@async_db_update
async def async_create(self, db: AsyncSession):
db.add(self)
await db.flush()
return self
@classmethod
@db_query
def get(cls, db: Session, rid: int) -> Self:
return db.query(cls).filter(and_(cls.id == rid)).first()
@classmethod
@async_db_query
async def async_get(cls, db: AsyncSession, rid: int) -> Self:
result = await db.execute(select(cls).where(and_(cls.id == rid)))
return result.scalars().first()
return db.query(cls).filter(cls.id == rid).first()
@db_update
def update(self, db: Session, payload: dict):
@@ -460,50 +160,24 @@ class Base:
if inspect(self).detached:
db.add(self)
@async_db_update
async def async_update(self, db: AsyncSession, payload: dict):
payload = {k: v for k, v in payload.items() if v is not None}
for key, value in payload.items():
setattr(self, key, value)
if inspect(self).detached:
db.add(self)
@classmethod
@db_update
def delete(cls, db: Session, rid):
db.query(cls).filter(and_(cls.id == rid)).delete()
@classmethod
@async_db_update
async def async_delete(cls, db: AsyncSession, rid):
result = await db.execute(select(cls).where(and_(cls.id == rid)))
user = result.scalars().first()
if user:
await db.delete(user)
db.query(cls).filter(cls.id == rid).delete()
@classmethod
@db_update
def truncate(cls, db: Session):
db.query(cls).delete()
@classmethod
@async_db_update
async def async_truncate(cls, db: AsyncSession):
await db.execute(delete(cls))
@classmethod
@db_query
def list(cls, db: Session) -> List[Self]:
return db.query(cls).all()
@classmethod
@async_db_query
async def async_list(cls, db: AsyncSession) -> Sequence[Self]:
result = await db.execute(select(cls))
return result.scalars().all()
result = db.query(cls).all()
return list(result)
def to_dict(self):
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns} # noqa
return {c.name: getattr(self, c.name, None) for c in self.__table__.columns}
@declared_attr
def __tablename__(self) -> str:
@@ -514,6 +188,7 @@ class DbOper:
"""
数据库操作基类
"""
_db: Session = None
def __init__(self, db: Union[Session, AsyncSession] = None):
def __init__(self, db: Session = None):
self._db = db

View File

@@ -1,4 +1,4 @@
from typing import List, Optional
from typing import List
from app.db import DbOper
from app.db.models.downloadhistory import DownloadHistory, DownloadFiles
@@ -23,14 +23,6 @@ class DownloadHistoryOper(DbOper):
"""
return DownloadHistory.get_by_hash(self._db, download_hash)
def get_by_mediaid(self, tmdbid: int, doubanid: str) -> List[DownloadHistory]:
"""
按媒体ID查询下载记录
:param tmdbid: tmdbid
:param doubanid: doubanid
"""
return DownloadHistory.get_by_mediaid(self._db, tmdbid=tmdbid, doubanid=doubanid)
def add(self, **kwargs):
"""
新增下载历史
@@ -51,7 +43,7 @@ class DownloadHistoryOper(DbOper):
"""
DownloadFiles.truncate(self._db)
def get_files_by_hash(self, download_hash: str, state: Optional[int] = None) -> List[DownloadFiles]:
def get_files_by_hash(self, download_hash: str, state: int = None) -> List[DownloadFiles]:
"""
按Hash查询下载文件记录
:param download_hash: 数据key
@@ -97,7 +89,7 @@ class DownloadHistoryOper(DbOper):
return fileinfo.download_hash
return ""
def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[DownloadHistory]:
def list_by_page(self, page: int = 1, count: int = 30) -> List[DownloadHistory]:
"""
分页查询下载历史
"""
@@ -109,11 +101,10 @@ class DownloadHistoryOper(DbOper):
"""
DownloadHistory.truncate(self._db)
def get_last_by(self, mtype=None, title: Optional[str] = None, year: Optional[str] = None,
season: Optional[str] = None, episode: Optional[str] = None, tmdbid=None) -> List[DownloadHistory]:
def get_last_by(self, mtype=None, title: str = None, year: str = None,
season: str = None, episode: str = None, tmdbid=None) -> List[DownloadHistory]:
"""
按类型、标题、年份、季集查询下载记录
tmdbid + mtype 或 title + year
"""
return DownloadHistory.get_last_by(db=self._db,
mtype=mtype,
@@ -123,7 +114,7 @@ class DownloadHistoryOper(DbOper):
episode=episode,
tmdbid=tmdbid)
def list_by_user_date(self, date: str, username: Optional[str] = None) -> List[DownloadHistory]:
def list_by_user_date(self, date: str, username: str = None) -> List[DownloadHistory]:
"""
查询某用户某时间之前的下载历史
"""
@@ -131,7 +122,7 @@ class DownloadHistoryOper(DbOper):
date=date,
username=username)
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: Optional[str] = None) -> List[DownloadHistory]:
def list_by_date(self, date: str, type: str, tmdbid: str, seasons: str = None) -> List[DownloadHistory]:
"""
查询某时间之后的下载历史
"""
@@ -141,7 +132,7 @@ class DownloadHistoryOper(DbOper):
tmdbid=tmdbid,
seasons=seasons)
def list_by_type(self, mtype: str, days: Optional[int] = 7) -> List[DownloadHistory]:
def list_by_type(self, mtype: str, days: int = 7) -> List[DownloadHistory]:
"""
获取指定类型的下载历史
"""

View File

@@ -1,8 +1,13 @@
import random
import string
from alembic.command import upgrade
from alembic.config import Config
from app.core.config import settings
from app.db import Engine, Base
from app.core.security import get_password_hash
from app.db import Engine, SessionFactory, Base
from app.db.models import *
from app.log import logger
@@ -11,29 +16,40 @@ def init_db():
初始化数据库
"""
# 全量建表
Base.metadata.create_all(bind=Engine) # noqa
Base.metadata.create_all(bind=Engine)
def init_super_user():
"""
初始化超级管理员
"""
# 初始化超级管理员
with SessionFactory() as db:
_user = User.get_by_name(db=db, name=settings.SUPERUSER)
if not _user:
# 定义包含数字、大小写字母的字符集合
characters = string.ascii_letters + string.digits
# 生成随机密码
random_password = ''.join(random.choice(characters) for _ in range(16))
logger.info(f"【超级管理员初始密码】{random_password} 请登录系统后在设定中修改。 注:该密码只会显示一次,请注意保存。")
_user = User(
name=settings.SUPERUSER,
hashed_password=get_password_hash(random_password),
is_superuser=True,
)
_user.create(db)
def update_db():
"""
更新数据库
"""
db_location = settings.CONFIG_PATH / 'user.db'
script_location = settings.ROOT_PATH / 'database'
try:
alembic_cfg = Config()
alembic_cfg.set_main_option('script_location', str(script_location))
# 根据数据库类型设置不同的URL
if settings.DB_TYPE.lower() == "postgresql":
if settings.DB_POSTGRESQL_PASSWORD:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}:{settings.DB_POSTGRESQL_PASSWORD}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
else:
db_url = f"postgresql://{settings.DB_POSTGRESQL_USERNAME}@{settings.DB_POSTGRESQL_HOST}:{settings.DB_POSTGRESQL_PORT}/{settings.DB_POSTGRESQL_DATABASE}"
else:
db_location = settings.CONFIG_PATH / 'user.db'
db_url = f"sqlite:///{db_location}"
alembic_cfg.set_main_option('sqlalchemy.url', db_url)
alembic_cfg.set_main_option('sqlalchemy.url', f"sqlite:///{db_location}")
upgrade(alembic_cfg, 'head')
except Exception as e:
logger.error(f'数据库更新失败:{str(e)}')

View File

@@ -1,3 +1,4 @@
import json
from typing import Optional
from sqlalchemy.orm import Session
@@ -18,8 +19,6 @@ class MediaServerOper(DbOper):
"""
新增媒体服务器数据
"""
# MediaServerItem中没有的属性剔除
kwargs = {k: v for k, v in kwargs.items() if hasattr(MediaServerItem, k)}
item = MediaServerItem(**kwargs)
if not item.get_by_itemid(self._db, kwargs.get("item_id")):
item.create(self._db)
@@ -53,33 +52,7 @@ class MediaServerOper(DbOper):
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = item.seasoninfo or {}
if kwargs.get("season") not in seasoninfo.keys():
return None
return item
async def async_exists(self, **kwargs) -> Optional[MediaServerItem]:
"""
异步判断媒体服务器数据是否存在
"""
if kwargs.get("tmdbid"):
# 优先按TMDBID查
item = await MediaServerItem.async_exist_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"),
mtype=kwargs.get("mtype"))
elif kwargs.get("title"):
# 按标题、类型、年份查
item = await MediaServerItem.async_exists_by_title(self._db, title=kwargs.get("title"),
mtype=kwargs.get("mtype"), year=kwargs.get("year"))
else:
return None
if not item:
return None
if kwargs.get("season"):
# 判断季是否存在
if not item.seasoninfo:
return None
seasoninfo = item.seasoninfo or {}
seasoninfo = json.loads(item.seasoninfo) or {}
if kwargs.get("season") not in seasoninfo.keys():
return None
return item
@@ -92,12 +65,3 @@ class MediaServerOper(DbOper):
if not item:
return None
return str(item.item_id)
async def async_get_item_id(self, **kwargs) -> Optional[str]:
"""
异步获取媒体服务器数据ID
"""
item = await self.async_exists(**kwargs)
if not item:
return None
return str(item.item_id)

View File

@@ -1,3 +1,4 @@
import json
import time
from typing import Optional, Union
@@ -18,20 +19,18 @@ class MessageOper(DbOper):
def add(self,
channel: MessageChannel = None,
source: Optional[str] = None,
mtype: NotificationType = None,
title: Optional[str] = None,
text: Optional[str] = None,
image: Optional[str] = None,
link: Optional[str] = None,
userid: Optional[str] = None,
action: Optional[int] = 1,
title: str = None,
text: str = None,
image: str = None,
link: str = None,
userid: str = None,
action: int = 1,
note: Union[list, dict] = None,
**kwargs):
"""
新增消息
新增媒体服务器数据
:param channel: 消息渠道
:param source: 来源
:param mtype: 消息类型
:param title: 标题
:param text: 文本内容
@@ -43,7 +42,6 @@ class MessageOper(DbOper):
"""
kwargs.update({
"channel": channel.value if channel else '',
"source": source,
"mtype": mtype.value if mtype else '',
"title": title,
"text": text,
@@ -52,53 +50,11 @@ class MessageOper(DbOper):
"userid": userid,
"action": action,
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note or {}
"note": json.dumps(note) if note else ''
})
# 从kwargs中去掉Message中没有的字段
for k in list(kwargs.keys()):
if k not in Message.__table__.columns.keys(): # noqa
kwargs.pop(k)
Message(**kwargs).create(self._db)
async def async_add(self,
channel: MessageChannel = None,
source: Optional[str] = None,
mtype: NotificationType = None,
title: Optional[str] = None,
text: Optional[str] = None,
image: Optional[str] = None,
link: Optional[str] = None,
userid: Optional[str] = None,
action: Optional[int] = 1,
note: Union[list, dict] = None,
**kwargs):
"""
异步新增消息
"""
kwargs.update({
"channel": channel.value if channel else '',
"source": source,
"mtype": mtype.value if mtype else '',
"title": title,
"text": text,
"image": image,
"link": link,
"userid": userid,
"action": action,
"reg_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
"note": note or {}
})
# 从kwargs中去掉Message中没有的字段
for k in list(kwargs.keys()):
if k not in Message.__table__.columns.keys(): # noqa
kwargs.pop(k)
await Message(**kwargs).async_create(self._db)
def list_by_page(self, page: Optional[int] = 1, count: Optional[int] = 30) -> Optional[str]:
def list_by_page(self, page: int = 1, count: int = 30) -> Optional[str]:
"""
获取媒体服务器数据ID
"""

View File

@@ -8,4 +8,3 @@ from .systemconfig import SystemConfig
from .transferhistory import TransferHistory
from .user import User
from .userconfig import UserConfig
from .workflow import Workflow

Some files were not shown because too many files have changed in this diff Show More