完成基础开发
This commit is contained in:
11
backend/.env.example
Normal file
11
backend/.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Flask 配置
|
||||
FLASK_ENV=development
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# 应用配置
|
||||
APP_HOST=0.0.0.0
|
||||
APP_PORT=5000
|
||||
DEBUG=True
|
||||
|
||||
# 客户端配置文件路径
|
||||
CLIENTS_CONFIG_PATH=data/clients.json
|
35
backend/Dockerfile
Normal file
35
backend/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
# 后端 Dockerfile
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装 uv
|
||||
RUN pip install uv
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt pyproject.toml ./
|
||||
|
||||
# 安装 Python 依赖
|
||||
RUN uv pip install --system -r requirements.txt
|
||||
|
||||
# 复制应用代码
|
||||
COPY . .
|
||||
|
||||
# 创建数据目录
|
||||
RUN mkdir -p data
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONPATH=/app
|
||||
ENV FLASK_APP=app.py
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8888
|
||||
|
||||
# 启动命令
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8888", "--workers", "4", "--timeout", "120", "app:create_app()"]
|
1
backend/api/__init__.py
Normal file
1
backend/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# API 蓝图模块
|
115
backend/api/clients.py
Normal file
115
backend/api/clients.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""
|
||||
客户端管理 API 蓝图
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from services.client_service import ClientService
|
||||
|
||||
clients_bp = Blueprint('clients', __name__, url_prefix='/api/clients')
|
||||
client_service = ClientService()
|
||||
|
||||
|
||||
@clients_bp.route('', methods=['GET'])
|
||||
def get_clients():
|
||||
"""获取所有客户端列表"""
|
||||
try:
|
||||
clients = client_service.get_clients_with_connection()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': clients
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@clients_bp.route('', methods=['POST'])
|
||||
def add_client():
|
||||
"""添加新客户端"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
|
||||
# 验证必需字段
|
||||
required_fields = ['name', 'host', 'port']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'缺少必需字段: {field}'
|
||||
}), 400
|
||||
|
||||
client = client_service.add_client(data)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': client
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@clients_bp.route('/<client_id>', methods=['PUT'])
|
||||
def update_client(client_id):
|
||||
"""更新客户端配置"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
client = client_service.update_client(client_id, data)
|
||||
|
||||
if client:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': client
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '客户端不存在'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@clients_bp.route('/<client_id>', methods=['DELETE'])
|
||||
def delete_client(client_id):
|
||||
"""删除客户端"""
|
||||
try:
|
||||
success = client_service.delete_client(client_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '客户端删除成功'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '客户端不存在'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@clients_bp.route('/<client_id>/test', methods=['POST'])
|
||||
def test_client_connection(client_id):
|
||||
"""测试客户端连接"""
|
||||
try:
|
||||
result = client_service.test_client_connection(client_id)
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
254
backend/api/torrents.py
Normal file
254
backend/api/torrents.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""
|
||||
种子管理 API 蓝图
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import tempfile
|
||||
from services.torrent_service import TorrentService
|
||||
|
||||
torrents_bp = Blueprint('torrents', __name__, url_prefix='/api/torrents')
|
||||
torrent_service = TorrentService()
|
||||
|
||||
|
||||
@torrents_bp.route('', methods=['GET'])
|
||||
def get_torrents():
|
||||
"""获取种子列表"""
|
||||
try:
|
||||
# 获取查询参数
|
||||
client_ids = request.args.getlist('client_ids')
|
||||
|
||||
data = torrent_service.get_all_torrents(client_ids if client_ids else None)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@torrents_bp.route('/maindata', methods=['GET'])
|
||||
def get_maindata():
|
||||
"""获取主要数据(聚合接口)"""
|
||||
try:
|
||||
# 获取查询参数
|
||||
client_ids = request.args.getlist('client_ids')
|
||||
|
||||
data = torrent_service.get_all_torrents(client_ids if client_ids else None)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@torrents_bp.route('/pause', methods=['POST'])
|
||||
def pause_torrents():
|
||||
"""暂停种子"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
torrent_hashes = data.get('hashes', [])
|
||||
client_id = data.get('client_id')
|
||||
|
||||
if not torrent_hashes:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请提供要暂停的种子哈希值'
|
||||
}), 400
|
||||
|
||||
result = torrent_service.pause_torrents(torrent_hashes, client_id)
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@torrents_bp.route('/resume', methods=['POST'])
|
||||
def resume_torrents():
|
||||
"""恢复种子"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
torrent_hashes = data.get('hashes', [])
|
||||
client_id = data.get('client_id')
|
||||
|
||||
if not torrent_hashes:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请提供要恢复的种子哈希值'
|
||||
}), 400
|
||||
|
||||
result = torrent_service.resume_torrents(torrent_hashes, client_id)
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@torrents_bp.route('/delete', methods=['POST'])
|
||||
def delete_torrents():
|
||||
"""删除种子"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
torrent_hashes = data.get('hashes', [])
|
||||
client_id = data.get('client_id')
|
||||
delete_files = data.get('delete_files', False)
|
||||
|
||||
if not torrent_hashes:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请提供要删除的种子哈希值'
|
||||
}), 400
|
||||
|
||||
result = torrent_service.delete_torrents(torrent_hashes, delete_files, client_id)
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@torrents_bp.route('/<torrent_hash>/details', methods=['GET'])
|
||||
def get_torrent_details(torrent_hash):
|
||||
"""获取种子详细信息"""
|
||||
try:
|
||||
client_id = request.args.get('client_id')
|
||||
|
||||
if not client_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请提供客户端ID'
|
||||
}), 400
|
||||
|
||||
details = torrent_service.get_torrent_details(torrent_hash, client_id)
|
||||
|
||||
if details:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': details
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '种子不存在或无法获取详细信息'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@torrents_bp.route('/add', methods=['POST'])
|
||||
def add_torrent():
|
||||
"""添加种子"""
|
||||
try:
|
||||
# 获取客户端ID
|
||||
client_id = request.form.get('client_id') or request.json.get('client_id') if request.is_json else None
|
||||
|
||||
if not client_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请指定客户端ID'
|
||||
}), 400
|
||||
|
||||
# 检查添加方式
|
||||
if 'torrent_file' in request.files:
|
||||
# 种子文件上传
|
||||
return _add_torrent_file(client_id)
|
||||
elif request.is_json:
|
||||
data = request.get_json()
|
||||
if 'magnet_link' in data:
|
||||
# 磁力链接
|
||||
return _add_magnet_link(client_id, data['magnet_link'], data.get('options', {}))
|
||||
elif 'torrent_url' in data:
|
||||
# 种子URL
|
||||
return _add_torrent_url(client_id, data['torrent_url'], data.get('options', {}))
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请提供种子文件、磁力链接或种子URL'
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
def _add_torrent_file(client_id):
|
||||
"""添加种子文件"""
|
||||
file = request.files['torrent_file']
|
||||
if file.filename == '':
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请选择种子文件'
|
||||
}), 400
|
||||
|
||||
if not file.filename.lower().endswith('.torrent'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '请上传.torrent文件'
|
||||
}), 400
|
||||
|
||||
# 获取可选参数
|
||||
options = {
|
||||
'category': request.form.get('category', ''),
|
||||
'tags': request.form.get('tags', ''),
|
||||
'save_path': request.form.get('save_path', ''),
|
||||
'paused': request.form.get('paused', 'false').lower() == 'true'
|
||||
}
|
||||
|
||||
# 保存临时文件
|
||||
filename = secure_filename(file.filename)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.torrent') as temp_file:
|
||||
file.save(temp_file.name)
|
||||
|
||||
try:
|
||||
result = torrent_service.add_torrent_file(client_id, temp_file.name, options)
|
||||
return jsonify(result)
|
||||
finally:
|
||||
# 清理临时文件
|
||||
os.unlink(temp_file.name)
|
||||
|
||||
|
||||
def _add_magnet_link(client_id, magnet_link, options):
|
||||
"""添加磁力链接"""
|
||||
if not magnet_link.startswith('magnet:'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '无效的磁力链接'
|
||||
}), 400
|
||||
|
||||
result = torrent_service.add_magnet_link(client_id, magnet_link, options)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
def _add_torrent_url(client_id, torrent_url, options):
|
||||
"""添加种子URL"""
|
||||
if not torrent_url.startswith(('http://', 'https://')):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '无效的种子URL'
|
||||
}), 400
|
||||
|
||||
result = torrent_service.add_torrent_url(client_id, torrent_url, options)
|
||||
return jsonify(result)
|
67
backend/app.py
Normal file
67
backend/app.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""
|
||||
Flask 应用入口文件
|
||||
多客户端 qBittorrent 集中管理平台后端
|
||||
"""
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from config import Config
|
||||
from api.clients import clients_bp
|
||||
from api.torrents import torrents_bp
|
||||
|
||||
|
||||
def create_app():
|
||||
"""创建 Flask 应用实例"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# 加载配置
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 配置 CORS
|
||||
CORS(app, origins=Config.CORS_ORIGINS)
|
||||
|
||||
# 注册蓝图
|
||||
app.register_blueprint(clients_bp)
|
||||
app.register_blueprint(torrents_bp)
|
||||
|
||||
# 健康检查端点
|
||||
@app.route('/api/health')
|
||||
def health_check():
|
||||
return jsonify({
|
||||
'status': 'healthy',
|
||||
'message': 'qBittorrent 管理平台后端运行正常'
|
||||
})
|
||||
|
||||
# 根路径
|
||||
@app.route('/')
|
||||
def index():
|
||||
return jsonify({
|
||||
'name': 'qBittorrent 管理平台 API',
|
||||
'version': '1.0.0',
|
||||
'description': '多客户端 qBittorrent 集中管理平台后端 API'
|
||||
})
|
||||
|
||||
# 全局错误处理
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '接口不存在'
|
||||
}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '服务器内部错误'
|
||||
}), 500
|
||||
|
||||
return app
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
app.run(
|
||||
debug=Config.DEBUG,
|
||||
host=Config.APP_HOST,
|
||||
port=Config.APP_PORT
|
||||
)
|
20
backend/config.py
Normal file
20
backend/config.py
Normal file
@ -0,0 +1,20 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
"""应用配置类"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
|
||||
# 应用配置
|
||||
APP_HOST = os.environ.get('APP_HOST', '0.0.0.0')
|
||||
APP_PORT = int(os.environ.get('APP_PORT', 8888))
|
||||
DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true'
|
||||
|
||||
# 客户端配置文件路径
|
||||
CLIENTS_CONFIG_PATH = os.environ.get('CLIENTS_CONFIG_PATH', 'data/clients.json')
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGINS = ['http://localhost:3000', 'http://127.0.0.1:3000']
|
11
backend/data/clients.json
Normal file
11
backend/data/clients.json
Normal file
@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"id": "a0c59f6a-a4aa-4bd9-9108-192c42f8f6c7",
|
||||
"name": "zack-server",
|
||||
"host": "https://qbitn.nosuchip.com",
|
||||
"port": 3443,
|
||||
"username": "zack",
|
||||
"password": "haHA7359339.pq",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
67
backend/debug_transfer.py
Normal file
67
backend/debug_transfer.py
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
调试脚本:检查 qBittorrent transfer info 对象的属性
|
||||
"""
|
||||
import json
|
||||
from services.client_service import ClientService
|
||||
|
||||
def debug_transfer_info():
|
||||
client_service = ClientService()
|
||||
clients = client_service.get_enabled_clients()
|
||||
|
||||
if not clients:
|
||||
print("没有找到启用的客户端")
|
||||
return
|
||||
|
||||
for client in clients:
|
||||
print(f"\n调试客户端: {client['name']}")
|
||||
try:
|
||||
qbt_client = client_service._create_qbt_client(client)
|
||||
|
||||
# 先测试基本连接
|
||||
print(f"qBittorrent 版本: {qbt_client.app.version}")
|
||||
|
||||
# 获取种子列表
|
||||
torrents = qbt_client.torrents.info()
|
||||
print(f"种子数量: {len(torrents)}")
|
||||
|
||||
# 尝试不同的方式获取传输信息
|
||||
print("\n尝试获取传输信息:")
|
||||
|
||||
# 方法1: transfer.info()
|
||||
try:
|
||||
transfer_info = qbt_client.transfer.info()
|
||||
print(f"transfer.info() 成功: {type(transfer_info)}")
|
||||
print(f"内容: {transfer_info}")
|
||||
except Exception as e:
|
||||
print(f"transfer.info() 失败: {e}")
|
||||
|
||||
# 方法2: transfer_info()
|
||||
try:
|
||||
transfer_info = qbt_client.transfer_info()
|
||||
print(f"transfer_info() 成功: {type(transfer_info)}")
|
||||
print(f"内容: {transfer_info}")
|
||||
except Exception as e:
|
||||
print(f"transfer_info() 失败: {e}")
|
||||
|
||||
# 方法3: sync.maindata()
|
||||
try:
|
||||
maindata = qbt_client.sync.maindata()
|
||||
print(f"sync.maindata() 成功: {type(maindata)}")
|
||||
if hasattr(maindata, 'server_state'):
|
||||
server_state = maindata.server_state
|
||||
print(f"server_state: {server_state}")
|
||||
if hasattr(server_state, 'dl_info_speed'):
|
||||
print(f"下载速度: {server_state.dl_info_speed}")
|
||||
if hasattr(server_state, 'up_info_speed'):
|
||||
print(f"上传速度: {server_state.up_info_speed}")
|
||||
except Exception as e:
|
||||
print(f"sync.maindata() 失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"连接客户端失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_transfer_info()
|
25
backend/pyproject.toml
Normal file
25
backend/pyproject.toml
Normal file
@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "qbit-manager-backend"
|
||||
version = "0.1.0"
|
||||
description = "多客户端 qBittorrent 集中管理平台后端"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"flask>=3.0.0",
|
||||
"flask-cors>=4.0.0",
|
||||
"qbittorrent-api>=2024.1.59",
|
||||
"python-dotenv>=1.0.0",
|
||||
"gunicorn>=21.2.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-flask>=1.2.0",
|
||||
]
|
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@ -0,0 +1,5 @@
|
||||
flask>=3.0.0
|
||||
flask-cors>=4.0.0
|
||||
qbittorrent-api>=2024.3.60
|
||||
python-dotenv>=1.0.0
|
||||
gunicorn>=21.2.0
|
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 服务层模块
|
178
backend/services/client_service.py
Normal file
178
backend/services/client_service.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""
|
||||
qBittorrent 客户端服务层
|
||||
负责管理客户端连接信息和与 qBittorrent API 的交互
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from typing import List, Dict, Optional, Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import qbittorrentapi
|
||||
from config import Config
|
||||
|
||||
|
||||
class ClientService:
|
||||
"""qBittorrent 客户端服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.config_path = Config.CLIENTS_CONFIG_PATH
|
||||
self._ensure_config_dir()
|
||||
|
||||
def _ensure_config_dir(self):
|
||||
"""确保配置目录存在"""
|
||||
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
|
||||
if not os.path.exists(self.config_path):
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump([], f)
|
||||
|
||||
def get_clients(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有客户端配置"""
|
||||
try:
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
clients = json.load(f)
|
||||
# 移除密码字段,不返回给前端
|
||||
return [{k: v for k, v in client.items() if k != 'password'}
|
||||
for client in clients]
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
def get_client_by_id(self, client_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""根据ID获取客户端配置"""
|
||||
clients = self._load_clients_with_password()
|
||||
return next((client for client in clients if client['id'] == client_id), None)
|
||||
|
||||
def _load_clients_with_password(self) -> List[Dict[str, Any]]:
|
||||
"""加载包含密码的客户端配置(内部使用)"""
|
||||
try:
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return []
|
||||
|
||||
def add_client(self, client_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""添加新客户端"""
|
||||
clients = self._load_clients_with_password()
|
||||
|
||||
# 生成唯一ID
|
||||
import uuid
|
||||
client_id = str(uuid.uuid4())
|
||||
|
||||
client = {
|
||||
'id': client_id,
|
||||
'name': client_data['name'],
|
||||
'host': client_data['host'],
|
||||
'port': client_data['port'],
|
||||
'username': client_data.get('username', ''),
|
||||
'password': client_data.get('password', ''),
|
||||
'enabled': client_data.get('enabled', True)
|
||||
}
|
||||
|
||||
clients.append(client)
|
||||
self._save_clients(clients)
|
||||
|
||||
# 返回不包含密码的客户端信息
|
||||
return {k: v for k, v in client.items() if k != 'password'}
|
||||
|
||||
def update_client(self, client_id: str, client_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""更新客户端配置"""
|
||||
clients = self._load_clients_with_password()
|
||||
|
||||
for i, client in enumerate(clients):
|
||||
if client['id'] == client_id:
|
||||
# 更新字段
|
||||
client.update({
|
||||
'name': client_data.get('name', client['name']),
|
||||
'host': client_data.get('host', client['host']),
|
||||
'port': client_data.get('port', client['port']),
|
||||
'username': client_data.get('username', client['username']),
|
||||
'enabled': client_data.get('enabled', client['enabled'])
|
||||
})
|
||||
|
||||
# 只有提供了密码才更新
|
||||
if 'password' in client_data:
|
||||
client['password'] = client_data['password']
|
||||
|
||||
clients[i] = client
|
||||
self._save_clients(clients)
|
||||
|
||||
# 返回不包含密码的客户端信息
|
||||
return {k: v for k, v in client.items() if k != 'password'}
|
||||
|
||||
return None
|
||||
|
||||
def delete_client(self, client_id: str) -> bool:
|
||||
"""删除客户端"""
|
||||
clients = self._load_clients_with_password()
|
||||
original_count = len(clients)
|
||||
clients = [client for client in clients if client['id'] != client_id]
|
||||
|
||||
if len(clients) < original_count:
|
||||
self._save_clients(clients)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _save_clients(self, clients: List[Dict[str, Any]]):
|
||||
"""保存客户端配置到文件"""
|
||||
with open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(clients, f, indent=2, ensure_ascii=False)
|
||||
|
||||
def test_client_connection(self, client_id: str) -> Dict[str, Any]:
|
||||
"""测试客户端连接"""
|
||||
client = self.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return {'success': False, 'error': '客户端不存在'}
|
||||
|
||||
try:
|
||||
qbt_client = self._create_qbt_client(client)
|
||||
# 尝试获取应用版本来测试连接
|
||||
version = qbt_client.app.version
|
||||
return {
|
||||
'success': True,
|
||||
'version': version,
|
||||
'web_api_version': qbt_client.app.web_api_version
|
||||
}
|
||||
except qbittorrentapi.LoginFailed:
|
||||
return {'success': False, 'error': '认证失败,请检查用户名和密码'}
|
||||
except qbittorrentapi.APIConnectionError:
|
||||
return {'success': False, 'error': '连接失败,请检查主机地址和端口'}
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': f'连接错误: {str(e)}'}
|
||||
|
||||
def _create_qbt_client(self, client: Dict[str, Any]) -> qbittorrentapi.Client:
|
||||
"""创建 qBittorrent 客户端实例"""
|
||||
return qbittorrentapi.Client(
|
||||
host=client['host'],
|
||||
port=client['port'],
|
||||
username=client['username'],
|
||||
password=client['password'],
|
||||
REQUESTS_ARGS={'timeout': (5, 30)} # 连接超时5秒,读取超时30秒
|
||||
)
|
||||
|
||||
def get_enabled_clients(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有启用的客户端"""
|
||||
clients = self._load_clients_with_password()
|
||||
return [client for client in clients if client.get('enabled', True)]
|
||||
|
||||
def get_clients_with_connection(self) -> List[Dict[str, Any]]:
|
||||
"""获取客户端列表并测试连接状态"""
|
||||
clients = self.get_clients()
|
||||
|
||||
def test_connection(client):
|
||||
result = self.test_client_connection(client['id'])
|
||||
client['connected'] = result['success']
|
||||
if result['success']:
|
||||
client['version'] = result.get('version')
|
||||
client['web_api_version'] = result.get('web_api_version')
|
||||
else:
|
||||
client['error'] = result.get('error')
|
||||
return client
|
||||
|
||||
# 并发测试连接
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_client = {executor.submit(test_connection, client): client
|
||||
for client in clients}
|
||||
|
||||
results = []
|
||||
for future in as_completed(future_to_client):
|
||||
results.append(future.result())
|
||||
|
||||
return results
|
364
backend/services/torrent_service.py
Normal file
364
backend/services/torrent_service.py
Normal file
@ -0,0 +1,364 @@
|
||||
"""
|
||||
种子服务层
|
||||
负责聚合多个客户端的种子数据和执行种子操作
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import qbittorrentapi
|
||||
from .client_service import ClientService
|
||||
|
||||
|
||||
class TorrentService:
|
||||
"""种子服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.client_service = ClientService()
|
||||
|
||||
def get_all_torrents(self, client_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""获取所有客户端的种子列表"""
|
||||
clients = self.client_service.get_enabled_clients()
|
||||
|
||||
# 如果指定了客户端ID,则只获取这些客户端的数据
|
||||
if client_ids:
|
||||
clients = [client for client in clients if client['id'] in client_ids]
|
||||
|
||||
if not clients:
|
||||
return {
|
||||
'torrents': [],
|
||||
'global_stats': {
|
||||
'download_speed': 0,
|
||||
'upload_speed': 0,
|
||||
'total_torrents': 0,
|
||||
'active_torrents': 0,
|
||||
'downloading': 0,
|
||||
'seeding': 0,
|
||||
'paused': 0
|
||||
},
|
||||
'clients_status': []
|
||||
}
|
||||
|
||||
def fetch_client_data(client):
|
||||
"""获取单个客户端的数据"""
|
||||
try:
|
||||
qbt_client = self.client_service._create_qbt_client(client)
|
||||
|
||||
# 获取种子列表
|
||||
torrents = qbt_client.torrents.info()
|
||||
|
||||
# 获取全局统计信息
|
||||
transfer_info = qbt_client.transfer_info()
|
||||
|
||||
# 为每个种子添加客户端信息
|
||||
client_torrents = []
|
||||
for torrent in torrents:
|
||||
# TorrentDictionary 本身就是字典,直接转换
|
||||
torrent_dict = dict(torrent)
|
||||
torrent_dict['client_id'] = client['id']
|
||||
torrent_dict['client_name'] = client['name']
|
||||
client_torrents.append(torrent_dict)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'client_id': client['id'],
|
||||
'client_name': client['name'],
|
||||
'torrents': client_torrents,
|
||||
'stats': {
|
||||
'download_speed': transfer_info.dl_info_speed,
|
||||
'upload_speed': transfer_info.up_info_speed,
|
||||
'total_torrents': len(torrents),
|
||||
'downloading': len([t for t in torrents if t.state in ['downloading', 'stalledDL', 'metaDL']]),
|
||||
'seeding': len([t for t in torrents if t.state in ['uploading', 'stalledUP']]),
|
||||
'paused': len([t for t in torrents if 'paused' in t.state.lower()])
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'client_id': client['id'],
|
||||
'client_name': client['name'],
|
||||
'error': str(e),
|
||||
'torrents': [],
|
||||
'stats': {
|
||||
'download_speed': 0,
|
||||
'upload_speed': 0,
|
||||
'total_torrents': 0,
|
||||
'downloading': 0,
|
||||
'seeding': 0,
|
||||
'paused': 0
|
||||
}
|
||||
}
|
||||
|
||||
# 并发获取所有客户端数据
|
||||
all_torrents = []
|
||||
global_stats = {
|
||||
'download_speed': 0,
|
||||
'upload_speed': 0,
|
||||
'total_torrents': 0,
|
||||
'active_torrents': 0,
|
||||
'downloading': 0,
|
||||
'seeding': 0,
|
||||
'paused': 0
|
||||
}
|
||||
clients_status = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_client = {executor.submit(fetch_client_data, client): client
|
||||
for client in clients}
|
||||
|
||||
for future in as_completed(future_to_client):
|
||||
result = future.result()
|
||||
|
||||
# 收集种子数据
|
||||
all_torrents.extend(result['torrents'])
|
||||
|
||||
# 聚合统计数据
|
||||
stats = result['stats']
|
||||
global_stats['download_speed'] += stats['download_speed']
|
||||
global_stats['upload_speed'] += stats['upload_speed']
|
||||
global_stats['total_torrents'] += stats['total_torrents']
|
||||
global_stats['downloading'] += stats['downloading']
|
||||
global_stats['seeding'] += stats['seeding']
|
||||
global_stats['paused'] += stats['paused']
|
||||
|
||||
# 记录客户端状态
|
||||
clients_status.append({
|
||||
'client_id': result['client_id'],
|
||||
'client_name': result['client_name'],
|
||||
'connected': result['success'],
|
||||
'error': result.get('error'),
|
||||
'stats': stats
|
||||
})
|
||||
|
||||
# 计算活跃种子数
|
||||
global_stats['active_torrents'] = global_stats['downloading'] + global_stats['seeding']
|
||||
|
||||
return {
|
||||
'torrents': all_torrents,
|
||||
'global_stats': global_stats,
|
||||
'clients_status': clients_status
|
||||
}
|
||||
|
||||
def pause_torrents(self, torrent_hashes: List[str], client_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""暂停种子"""
|
||||
return self._execute_torrent_action('pause', torrent_hashes, client_id)
|
||||
|
||||
def resume_torrents(self, torrent_hashes: List[str], client_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""恢复种子"""
|
||||
return self._execute_torrent_action('resume', torrent_hashes, client_id)
|
||||
|
||||
def delete_torrents(self, torrent_hashes: List[str], delete_files: bool = False, client_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""删除种子"""
|
||||
return self._execute_torrent_action('delete', torrent_hashes, client_id, delete_files=delete_files)
|
||||
|
||||
def _execute_torrent_action(self, action: str, torrent_hashes: List[str], client_id: Optional[str] = None, **kwargs) -> Dict[str, Any]:
|
||||
"""执行种子操作"""
|
||||
if client_id:
|
||||
# 对指定客户端执行操作
|
||||
client = self.client_service.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return {'success': False, 'error': '客户端不存在'}
|
||||
|
||||
return self._execute_action_on_client(client, action, torrent_hashes, **kwargs)
|
||||
else:
|
||||
# 对所有客户端执行操作
|
||||
clients = self.client_service.get_enabled_clients()
|
||||
results = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||
future_to_client = {
|
||||
executor.submit(self._execute_action_on_client, client, action, torrent_hashes, **kwargs): client
|
||||
for client in clients
|
||||
}
|
||||
|
||||
for future in as_completed(future_to_client):
|
||||
client = future_to_client[future]
|
||||
try:
|
||||
result = future.result()
|
||||
result['client_id'] = client['id']
|
||||
result['client_name'] = client['name']
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
results.append({
|
||||
'success': False,
|
||||
'client_id': client['id'],
|
||||
'client_name': client['name'],
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
# 统计成功和失败的结果
|
||||
successful = [r for r in results if r['success']]
|
||||
failed = [r for r in results if not r['success']]
|
||||
|
||||
return {
|
||||
'success': len(successful) > 0,
|
||||
'results': results,
|
||||
'summary': {
|
||||
'total_clients': len(results),
|
||||
'successful_clients': len(successful),
|
||||
'failed_clients': len(failed)
|
||||
}
|
||||
}
|
||||
|
||||
def _execute_action_on_client(self, client: Dict[str, Any], action: str, torrent_hashes: List[str], **kwargs) -> Dict[str, Any]:
|
||||
"""在指定客户端上执行操作"""
|
||||
try:
|
||||
qbt_client = self.client_service._create_qbt_client(client)
|
||||
|
||||
if action == 'pause':
|
||||
qbt_client.torrents.pause(torrent_hashes=torrent_hashes)
|
||||
elif action == 'resume':
|
||||
qbt_client.torrents.resume(torrent_hashes=torrent_hashes)
|
||||
elif action == 'delete':
|
||||
delete_files = kwargs.get('delete_files', False)
|
||||
qbt_client.torrents.delete(torrent_hashes=torrent_hashes, delete_files=delete_files)
|
||||
else:
|
||||
return {'success': False, 'error': f'不支持的操作: {action}'}
|
||||
|
||||
return {'success': True, 'message': f'操作 {action} 执行成功'}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_torrent_details(self, torrent_hash: str, client_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取种子详细信息"""
|
||||
client = self.client_service.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
qbt_client = self.client_service._create_qbt_client(client)
|
||||
|
||||
# 获取种子基本信息
|
||||
torrents = qbt_client.torrents.info(torrent_hashes=torrent_hash)
|
||||
if not torrents:
|
||||
return None
|
||||
|
||||
torrent = torrents[0]
|
||||
torrent_dict = dict(torrent)
|
||||
|
||||
# 获取种子文件列表
|
||||
try:
|
||||
files = qbt_client.torrents.files(torrent_hash=torrent_hash)
|
||||
torrent_dict['files'] = [dict(file) for file in files]
|
||||
except:
|
||||
torrent_dict['files'] = []
|
||||
|
||||
# 获取 Tracker 信息
|
||||
try:
|
||||
trackers = qbt_client.torrents.trackers(torrent_hash=torrent_hash)
|
||||
torrent_dict['trackers'] = [dict(tracker) for tracker in trackers]
|
||||
except:
|
||||
torrent_dict['trackers'] = []
|
||||
|
||||
# 获取 Peers 信息
|
||||
try:
|
||||
peers = qbt_client.torrents.peers(torrent_hash=torrent_hash)
|
||||
torrent_dict['peers'] = [dict(peer) for peer in peers.values()]
|
||||
except:
|
||||
torrent_dict['peers'] = []
|
||||
|
||||
# 添加客户端信息
|
||||
torrent_dict['client_id'] = client['id']
|
||||
torrent_dict['client_name'] = client['name']
|
||||
|
||||
return torrent_dict
|
||||
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
def add_torrent_file(self, client_id: str, torrent_file_path: str, options: dict) -> dict:
|
||||
"""添加种子文件"""
|
||||
client = self.client_service.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return {'success': False, 'error': '客户端不存在'}
|
||||
|
||||
try:
|
||||
qbt_client = self.client_service._create_qbt_client(client)
|
||||
|
||||
# 准备添加选项
|
||||
add_options = {}
|
||||
if options.get('category'):
|
||||
add_options['category'] = options['category']
|
||||
if options.get('tags'):
|
||||
add_options['tags'] = options['tags']
|
||||
if options.get('save_path'):
|
||||
add_options['savepath'] = options['save_path']
|
||||
if options.get('paused'):
|
||||
add_options['paused'] = 'true'
|
||||
|
||||
# 添加种子文件
|
||||
with open(torrent_file_path, 'rb') as torrent_file:
|
||||
result = qbt_client.torrents.add(torrent_files=torrent_file, **add_options)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '种子文件添加成功',
|
||||
'result': result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def add_magnet_link(self, client_id: str, magnet_link: str, options: dict) -> dict:
|
||||
"""添加磁力链接"""
|
||||
client = self.client_service.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return {'success': False, 'error': '客户端不存在'}
|
||||
|
||||
try:
|
||||
qbt_client = self.client_service._create_qbt_client(client)
|
||||
|
||||
# 准备添加选项
|
||||
add_options = {}
|
||||
if options.get('category'):
|
||||
add_options['category'] = options['category']
|
||||
if options.get('tags'):
|
||||
add_options['tags'] = options['tags']
|
||||
if options.get('save_path'):
|
||||
add_options['savepath'] = options['save_path']
|
||||
if options.get('paused'):
|
||||
add_options['paused'] = 'true'
|
||||
|
||||
# 添加磁力链接
|
||||
result = qbt_client.torrents.add(urls=magnet_link, **add_options)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '磁力链接添加成功',
|
||||
'result': result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def add_torrent_url(self, client_id: str, torrent_url: str, options: dict) -> dict:
|
||||
"""添加种子URL"""
|
||||
client = self.client_service.get_client_by_id(client_id)
|
||||
if not client:
|
||||
return {'success': False, 'error': '客户端不存在'}
|
||||
|
||||
try:
|
||||
qbt_client = self.client_service._create_qbt_client(client)
|
||||
|
||||
# 准备添加选项
|
||||
add_options = {}
|
||||
if options.get('category'):
|
||||
add_options['category'] = options['category']
|
||||
if options.get('tags'):
|
||||
add_options['tags'] = options['tags']
|
||||
if options.get('save_path'):
|
||||
add_options['savepath'] = options['save_path']
|
||||
if options.get('paused'):
|
||||
add_options['paused'] = 'true'
|
||||
|
||||
# 添加种子URL
|
||||
result = qbt_client.torrents.add(urls=torrent_url, **add_options)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': '种子URL添加成功',
|
||||
'result': result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'error': str(e)}
|
Reference in New Issue
Block a user