This commit is contained in:
DengDai
2025-12-08 14:45:14 +08:00
commit 519589f8f5
60 changed files with 8191 additions and 0 deletions

35
backend/app.py Normal file
View File

@@ -0,0 +1,35 @@
# skit_panel_backend/app.py
from flask import Flask
from flask_cors import CORS
# 导入蓝图
from routes.dashboard import dashboard_bp
from routes.system import system_bp
from routes.media import media_bp
from routes.actions import actions_bp
from services.scheduler_service import init_scheduler
def create_app():
app = Flask(__name__)
# 配置 CORS允许你的前端地址访问
# 在开发中, "*" 是最简单的设置
CORS(app, resources={r"/api/*": {"origins": "*"}})
# 注册蓝图
app.register_blueprint(dashboard_bp)
app.register_blueprint(system_bp)
app.register_blueprint(media_bp)
app.register_blueprint(actions_bp)
@app.route("/")
def index():
return "<h1>SkitPanel Backend is running!</h1>"
with app.app_context():
init_scheduler()
return app
if __name__ == '__main__':
app = create_app()
# 生产环境应使用 Gunicorn 或 uWSGI
# debug=True 模式下,修改代码后服务会自动重启
app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -0,0 +1,42 @@
{
"douban": {
"cookie": ""
},
"skit_paths": [],
"ptskit": {
"url": "",
"cookie": "",
"user_agent": "SkitPanel/1.0"
},
"downloader": "qbittorrent",
"qbittorrent": {
"host": "",
"port": 8080,
"username": "",
"password": ""
},
"transmission": {
"host": "",
"port": 9091,
"username": "",
"password": ""
},
"emby": {
"host": "",
"port": 8096,
"use_ssl": false,
"api_key": ""
},
"plex": {
"host": "",
"port": 32400,
"use_ssl": false,
"token": ""
},
"fnos": {
"host": "",
"port": 1999,
"use_ssl": false,
"authorization": ""
}
}

67
backend/config.py Normal file
View File

@@ -0,0 +1,67 @@
import json
import os
import copy
from threading import Lock
CONFIG_PATH = 'config.json'
class ConfigManager:
"""线程安全地处理 config.json 的读取、写入和访问"""
def __init__(self, path=CONFIG_PATH):
self.path = path
self._lock = Lock()
self.config = self._load_config()
def _load_config(self):
"""加载配置文件,如果不存在则创建一个空的"""
with self._lock:
if os.path.exists(self.path):
try:
with open(self.path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def get_config(self, sensitive=False):
"""
获取配置。
:param sensitive: False时将敏感信息替换为 '******'
"""
config_copy = copy.deepcopy(self.config)
if sensitive:
return config_copy
# 隐藏敏感信息
sensitive_keys = {
'douban': 'cookie', 'ptskit': 'cookie',
'emby': 'api_key', 'plex': 'token', 'fnos': 'authorization'
}
for section, key in sensitive_keys.items():
if section in config_copy and key in config_copy[section]:
config_copy[section][key] = '******' if config_copy[section][key] else ''
# 隐藏密码
for section in ['qbittorrent', 'transmission']:
if section in config_copy and 'password' in config_copy[section]:
config_copy[section]['password'] = ''
return config_copy
def save_config(self, new_config):
"""保存配置到文件"""
with self._lock:
try:
with open(self.path, 'w', encoding='utf-8') as f:
json.dump(new_config, f, indent=4, ensure_ascii=False)
self.config = new_config # 更新内存中的配置
return True, None
except Exception as e:
return False, str(e)
def get(self, key, default=None):
"""获取特定配置项"""
return self.config.get(key, default)
# 创建一个全局单例
config_manager = ConfigManager()

14
backend/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
# 核心框架
flask==3.1.2
flask-cors==6.0.1
# 下载器客户端
qbittorrent-api==2025.7.0
transmission-rpc==7.0.11
# 网络请求与解析
requests==2.32.5
beautifulsoup4==4.13.5
lxml==6.0.1
plexapi==4.17.1
APScheduler==3.11.0

View File

41
backend/routes/actions.py Normal file
View File

@@ -0,0 +1,41 @@
from flask import Blueprint, jsonify, request
from config import config_manager
from utils.placeholders import ServicePlaceholder, long_running_task
actions_bp = Blueprint('actions', __name__, url_prefix='/api')
@actions_bp.route('/downloader/add', methods=['POST'])
def add_download_task():
# 逻辑: 根据配置,将下载链接添加到 qBittorrent 或 Transmission
# 返回格式: {"message": "任务添加成功"}
data = request.get_json()
if not data or 'download_url' not in data:
return jsonify({"error": "缺少 download_url"}), 400
downloader_type = config_manager.get('downloader')
config = config_manager.get(downloader_type)
manager = ServicePlaceholder(config)
success, message = manager.add_task(data['download_url'])
if success:
return jsonify({"message": f"任务已成功添加至 {downloader_type}"})
else:
return jsonify({"error": message}), 500
@actions_bp.route('/scrape', methods=['POST'])
def scrape_media():
# 逻辑: 接收媒体路径,在后台线程中启动刮削任务
# 返回格式 (202 Accepted): {"message": "刮削任务已加入队列..."}
data = request.get_json()
media_path = data.get('media_path')
if not media_path:
return jsonify({"error": "缺少 media_path"}), 400
douban_config = config_manager.get('douban')
manager = ServicePlaceholder(douban_config)
# 在后台线程中执行刮削
long_running_task(manager.scrape, media_path)
# 立即返回 202 Accepted 响应
return jsonify({"message": "刮削任务已加入队列,请稍后刷新查看结果"}), 202

View File

@@ -0,0 +1,53 @@
from flask import Blueprint, jsonify
from config import config_manager
from utils.placeholders import ServicePlaceholder
from utils.plex_utils import PlexManager
dashboard_bp = Blueprint('dashboard', __name__, url_prefix='/api/dashboard')
@dashboard_bp.route('/initial_check', methods=['GET'])
def initial_check():
# 逻辑: 检查配置文件中是否至少有一个关键项被配置例如ptskit的URL
# 返回格式: {"is_configured": true | false}
is_configured = bool(config_manager.get('ptskit', {}).get('url'))
return jsonify({"is_configured": is_configured})
@dashboard_bp.route('/stats', methods=['GET'])
def get_stats():
# 逻辑: 分别连接 qBittorrent, Transmission, Emby 等服务获取它们的项目总数
# 返回格式: {"local_skits": int, "fnos_items": int, ...}
qb_manager = ServicePlaceholder(config_manager.get('qbittorrent'))
plex_config = config_manager.get('plex')
plex_manager = PlexManager(plex_config)
plex_items = plex_manager.get_stats()
return jsonify({
"local_skits": 15,
"fnos_items": 12,
"emby_items": 12,
"plex_items": plex_items,
"qb_total": qb_manager.get_stats(),
"tr_total": 4
})
@dashboard_bp.route('/component_statuses', methods=['GET'])
def get_component_statuses():
# 逻辑: 对每个配置的服务进行一次连接测试,并返回其状态
# 返回格式: {"qbittorrent": {"status": "success" | "error" | "unconfigured", "message": str}, ...}
plex_config = config_manager.get('plex')
plex_status = {"status": "unconfigured", "message": "未配置"}
if plex_config and plex_config.get('host'):
plex_manager = PlexManager(plex_config)
success, message = plex_manager.test_connection()
plex_status = {"status": "success" if success else "error", "message": message}
return jsonify({
"douban_cookie": {"status": "success", "message": "Cookie已配置"},
"ptskit_cookie": {"status": "success", "message": "Cookie已配置"},
"local_path": {"status": "success", "message": "已挂载"},
"qbittorrent": {"status": "success", "message": "连接成功 v4.5.2"},
"transmission": {"status": "error", "message": "连接失败: 认证错误"},
"fnos": {"status": "unconfigured", "message": "未配置"},
"emby": {"status": "success", "message": "连接成功"},
"plex": plex_status
})

102
backend/routes/media.py Normal file
View File

@@ -0,0 +1,102 @@
from flask import Blueprint, jsonify, request
from config import config_manager
from utils.plex_utils import PlexManager
from plexapi.exceptions import Unauthorized, NotFound
from services.cache_service import CacheManager
from utils.pagination import paginate
media_bp = Blueprint('media', __name__, url_prefix='/api')
@media_bp.route('/ptskit/torrents', methods=['GET'])
def get_ptskit_torrents():
# 逻辑: 访问 PTSKIT 网站,解析种子列表页,支持分页和搜索
# 返回格式: {"total": int, "list": [{"id": str, "title": str, ...}]}
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 20, type=int)
keyword = request.args.get('keyword', '')
mock_list = [{
"id": f"torrent-{i + (page-1)*page_size}",
"title": f"[PTSKIT] 搜索'{keyword}'的结果 {i+1}",
"size": f"{i+1}.5 GB",
"seeders": 50 - i, "leechers": i,
"download_url": f"http://example.com/download.php?id={i}"
} for i in range(page_size)]
return jsonify({"total": 123, "list": mock_list})
@media_bp.route('/local-media', methods=['GET'])
def get_local_media():
# 逻辑: 扫描配置中的 skit_paths 目录,列出所有短剧文件夹
# 返回格式: {"total": int, "list": [{"id": str, "title": str, "path": str, ...}]}
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 20, type=int)
mock_list = [{
"id": f"local-{i + (page-1)*page_size}",
"title": f"本地短剧-{i+1}",
"path": f"/skits/本地短剧-{i+1}",
"poster": f"https://picsum.photos/seed/{i}/200/300",
"scraped": i % 2 == 0
} for i in range(page_size)]
return jsonify({"total": 88, "list": mock_list})
@media_bp.route('/<server_type>/library', methods=['GET'])
def get_media_server_library(server_type):
# 逻辑: 根据 server_type (emby, plex, fnos) 连接对应服务,获取媒体库
# 返回格式: {"total": int, "list": [{"id": str, "title": str, ...}]}
# if server_type not in ['emby', 'plex', 'fnos']:
# return jsonify({"error": "不支持的媒体服务器类型"}), 404
if server_type == 'plex':
try:
plex_config = config_manager.get('plex')
manager = PlexManager(plex_config)
libraries = manager.get_libraries()
return jsonify({"total": len(libraries), "list": libraries})
except ValueError as e: # 通常是未配置错误
return jsonify({"error": str(e)}), 400
except Unauthorized as e:
return jsonify({"error": f"Plex 认证失败: {e}"}), 401
except Exception as e:
return jsonify({"error": f"连接 Plex 时发生错误: {e}"}), 503
elif server_type in ['emby', 'fnos']:
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 20, type=int)
mock_list = [{
"id": f"{server_type}-{i + (page-1)*page_size}",
"title": f"[{server_type.upper()}] 服务器上的短剧 {i+1}",
"year": 2023,
"poster": f"https://picsum.photos/seed/{server_type}{i}/200/300"
} for i in range(page_size)]
return jsonify({"total": 66, "list": mock_list})
return jsonify({"error": "不支持的媒体服务器类型"}), 404
@media_bp.route('/plex/libraries/<library_id>/items', methods=['GET'])
def get_plex_library_items(library_id):
page = request.args.get('page', 1, type=int)
page_size = request.args.get('pageSize', 20, type=int)
try:
# 1. 初始化缓存管理器,为每个库使用单独的缓存文件
# TTL 设置为 3660 秒1小时+1分钟确保比定时任务间隔稍长
cache_mgr = CacheManager(f"plex_lib_{library_id}.json", ttl_seconds=3660)
# 2. 初始化 Plex 管理器
plex_config = config_manager.get('plex')
manager = PlexManager(plex_config)
# 3. 使用 cache_mgr.get_data 获取数据
# 它会自动处理:读缓存,或在缓存失效时调用 manager.get_library_items
all_items = cache_mgr.get_data(manager.get_library_items, library_id=library_id)
# 4. 使用分页工具进行分页
paginated_data = paginate(all_items, page, page_size)
return jsonify(paginated_data)
except ValueError as e: # 通常是配置错误
return jsonify({"error": str(e)}), 400
except NotFound: # get_library_items 可能会在强制刷新时抛出
return jsonify({"error": f"ID为 '{library_id}' 的 Plex 媒体库不存在"}), 4404
except Unauthorized as e:
return jsonify({"error": f"Plex 认证失败: {e}"}), 401
except Exception as e:
return jsonify({"error": f"获取 Plex 项目时发生内部错误: {e}"}), 500

61
backend/routes/system.py Normal file
View File

@@ -0,0 +1,61 @@
from flask import Blueprint, jsonify, request
from config import config_manager
from utils.plex_utils import PlexManager
from utils.placeholders import ServicePlaceholder
system_bp = Blueprint('system', __name__, url_prefix='/api/system')
MANAGER_MAP = {
'plex': PlexManager,
# TODO: 当你实现其他 Manager 后,在这里添加它们
# 'qbittorrent': QBManager,
# 'transmission': TransmissionManager,
# 'emby': EmbyManager,
# 'fnos': FnosManager,
}
@system_bp.route('/config', methods=['GET'])
def get_system_config():
"""获取系统配置(屏蔽敏感信息)"""
return jsonify(config_manager.get_config(sensitive=False))
@system_bp.route('/config', methods=['POST'])
def save_system_config():
"""保存系统配置"""
new_config = request.get_json()
if not isinstance(new_config, dict):
return jsonify({"error": "无效的JSON格式"}), 400
success, error = config_manager.save_config(new_config)
if success:
return jsonify({"message": "配置保存成功!"})
else:
return jsonify({"error": f"保存失败: {error}"}), 500
@system_bp.route('/test_connection', methods=['POST'])
def test_connection():
"""动态测试指定服务的连接"""
data = request.get_json()
service_type = data.get('type')
service_config = data.get('config')
if not service_type or not service_config:
return jsonify({"error": "请求参数不完整"}), 400
# 3. 动态选择 Manager
# 从 MANAGER_MAP 中获取对应的类。
# 如果 service_type 不在映射中(例如 'emby' 还没实现),则默认使用 ServicePlaceholder
ManagerClass = MANAGER_MAP.get(service_type, ServicePlaceholder)
# 使用从前端实时传来的配置实例化 Manager
manager_instance = ManagerClass(service_config)
try:
# 调用实例的 test_connection 方法
# 我们新的 Manager 返回 (bool, str),正好符合这里的逻辑
success, message = manager_instance.test_connection()
return jsonify({"success": success, "message": message})
except Exception as e:
# 添加一个通用异常捕获,防止 Manager 内部的意外错误导致服务器崩溃
return jsonify({
"success": False,
"message": f"测试时发生内部错误: {str(e)}"
}), 500

View File

View File

@@ -0,0 +1,76 @@
import os
import json
import time
import logging
log = logging.getLogger(__name__)
# 定义缓存目录
CACHE_DIR = os.path.join(os.path.dirname(__file__), '..', 'cache')
os.makedirs(CACHE_DIR, exist_ok=True) # 自动创建目录
class CacheManager:
"""
一个通用的JSON文件缓存管理器。
"""
def __init__(self, cache_file_name: str, ttl_seconds: int = 3600):
"""
:param cache_file_name: 缓存文件名, e.g., 'plex_library.json'
:param ttl_seconds: 缓存的生命周期默认1小时
"""
self.cache_path = os.path.join(CACHE_DIR, cache_file_name)
self.ttl = ttl_seconds
def _is_stale(self) -> bool:
"""检查缓存是否已过期"""
if not os.path.exists(self.cache_path):
return True # 文件不存在,视为过期
file_mod_time = os.path.getmtime(self.cache_path)
if (time.time() - file_mod_time) > self.ttl:
return True # 文件存在但已超过TTL视为过期
return False
def read_cache(self) -> list | dict | None:
"""从文件读取缓存"""
try:
with open(self.cache_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def write_cache(self, data: list | dict):
"""将数据写入缓存文件"""
try:
with open(self.cache_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
except Exception as e:
log.error(f"写入缓存文件 {self.cache_path} 失败: {e}")
def get_data(self, fetch_func, *args, **kwargs) -> list | dict:
"""
获取数据的主要方法。如果缓存有效则从缓存读取,否则调用 fetch_func 获取新数据。
:param fetch_func: 一个用于获取实时数据的函数 (e.g., plex_manager.get_items)
:param args: 传递给 fetch_func 的位置参数
:param kwargs: 传递给 fetch_func 的关键字参数
:return: 数据
"""
if not self._is_stale():
log.debug(f"从缓存加载数据: {self.cache_path}")
cached_data = self.read_cache()
if cached_data is not None:
return cached_data
log.info(f"缓存失效或不存在,正在从源获取新数据: {fetch_func.__name__}")
try:
fresh_data = fetch_func(*args, **kwargs)
self.write_cache(fresh_data)
return fresh_data
except Exception as e:
log.error(f"调用 fetch_func ({fetch_func.__name__}) 失败: {e}", exc_info=True)
# 如果获取新数据失败,尝试返回旧的缓存(即使已过期),这比返回错误要好
log.warning("获取新数据失败,尝试使用旧缓存作为备用。")
old_data = self.read_cache()
return old_data if old_data is not None else []

View File

@@ -0,0 +1,40 @@
import logging
from config import config_manager
from utils.plex_utils import PlexManager
from .cache_service import CacheManager
log = logging.getLogger(__name__)
def update_all_plex_libraries_cache():
"""
强制刷新所有已配置的Plex媒体库的缓存。
这个函数将被定时任务调用。
"""
log.info("开始执行定时任务更新Plex媒体库缓存...")
plex_config = config_manager.get('plex')
if not plex_config or not plex_config.get('host'):
log.info("Plex未配置跳过更新任务。")
return
try:
manager = PlexManager(plex_config)
libraries = manager.get_libraries()
for lib in libraries:
lib_id = lib['id']
# 为每个媒体库创建一个独立的缓存文件
cache_mgr = CacheManager(f"plex_lib_{lib_id}.json")
# 调用 get_data 并传入真正的 fetch 函数,这会强制刷新缓存
log.debug(f"正在为Plex媒体库 '{lib['name']}' ({lib_id}) 刷新缓存...")
cache_mgr.get_data(manager.get_library_items, library_id=lib_id)
log.info("Plex媒体库缓存更新任务完成。")
except Exception as e:
log.error(f"更新Plex缓存时发生错误: {e}", exc_info=True)
# TODO: 在这里添加其他更新函数,例如:
# def update_local_skits_cache():
# log.info("开始更新本地短剧缓存...")
# ...

View File

@@ -0,0 +1,35 @@
import logging
import atexit
from apscheduler.schedulers.background import BackgroundScheduler
from .data_updater import update_all_plex_libraries_cache
log = logging.getLogger(__name__)
scheduler = BackgroundScheduler(daemon=True)
def init_scheduler():
"""初始化并启动定时任务调度器"""
if scheduler.running:
log.warning("调度器已在运行中。")
return
try:
# 在这里添加你的定时任务
scheduler.add_job(
func=update_all_plex_libraries_cache,
trigger='interval',
hours=1, # 每1小时执行一次
id='update_plex_job',
replace_existing=True
)
# TODO: 添加其他任务
# scheduler.add_job(...)
scheduler.start()
log.info("定时任务调度器已启动。")
# 确保在应用退出时能优雅地关闭调度器
atexit.register(lambda: scheduler.shutdown())
except Exception as e:
log.error(f"启动调度器失败: {e}", exc_info=True)

View File

View File

@@ -0,0 +1,54 @@
import requests
import time
import os
class DoubanManager:
def __init__(self, config):
self.cookie = config.get('cookie', '')
def test_connection(self):
if not self.cookie:
return False, "未配置 Cookie"
# 简单测试,可以访问一个需要登录的页面
headers = {'Cookie': self.cookie}
try:
res = requests.get('https://www.douban.com/mine/', headers=headers, timeout=10)
if '登录' in res.text:
return False, "Cookie 已失效"
return True, "Cookie 有效"
except Exception as e:
return False, f"网络请求失败: {e}"
def scrape(self, media_path):
"""
模拟耗时的刮削任务
:param media_path: 要刮削的媒体文件夹路径
"""
print(f"开始刮削: {media_path}")
# 1. 提取剧名
series_name = os.path.basename(media_path)
print(f"提取剧名: {series_name}")
# 2. 在豆瓣搜索 (模拟)
time.sleep(5)
print("模拟豆瓣搜索中...")
# 3. 获取信息并写入 NFO 和海报 (模拟)
time.sleep(5)
nfo_path = os.path.join(media_path, 'movie.nfo')
poster_path = os.path.join(media_path, 'poster.jpg')
with open(nfo_path, 'w', encoding='utf-8') as f:
f.write(f"<movie><title>{series_name}</title><plot>这是一个模拟刮削的剧情简介。</plot></movie>")
# 模拟下载海报
try:
# 使用一个占位图
img_data = requests.get("https://via.placeholder.com/300x450.png?text=Poster").content
with open(poster_path, 'wb') as handler:
handler.write(img_data)
except Exception as e:
print(f"下载海报失败: {e}")
print(f"刮削完成: {media_path}")

View File

View File

@@ -0,0 +1,36 @@
# skit_panel_backend/utils/pagination.py
def paginate(data_list: list, page: int, page_size: int):
"""
一个简单的列表分页工具。
:param data_list: 要分页的完整列表。
:param page: 当前页码 (从1开始)。
:param page_size: 每页的项目数。
:return: 一个包含分页后数据和总数的字典。
"""
if not isinstance(data_list, list):
# 如果输入不是列表,返回空结果
return {"total": 0, "list": []}
total = len(data_list)
# 确保页码和页面大小是正整数
page = max(1, page)
page_size = max(1, page_size)
start = (page - 1) * page_size
# 防止切片索引越界
if start >= total:
return {"total": total, "list": []}
end = start + page_size
paginated_list = data_list[start:end]
return {
"total": total,
"list": paginated_list
}

View File

@@ -0,0 +1,45 @@
import time
import threading
"""
这个文件包含了所有与外部服务交互的模拟类。
在实际开发中,您应该将每个类移动到自己的文件中(如 qbittorrent_utils.py
并实现与真实服务的交互逻辑。
"""
def long_running_task(target, *args, **kwargs):
"""一个辅助函数,用于在后台线程中运行耗时任务"""
thread = threading.Thread(target=target, args=args, kwargs=kwargs)
thread.daemon = True
thread.start()
class ServicePlaceholder:
def __init__(self, config):
self.config = config
def test_connection(self):
# 模拟:检查配置是否存在
if self.config and all(self.config.values()):
return True, "模拟连接成功"
return False, "模拟连接失败:配置不完整"
def get_stats(self):
# 模拟返回一个统计数字
return 42
def get_library(self, page=1, page_size=20, keyword=None):
# 模拟返回一个分页的媒体库列表
return {
"total": 50,
"list": [{"id": f"item-{i}", "title": f"服务器上的短剧 {i}", "year": 2024} for i in range(page_size)]
}
def add_task(self, url):
# 模拟添加任务
return True, "模拟:任务添加成功"
def scrape(self, path):
# 模拟一个耗时的刮削过程
print(f"[后台任务] 开始刮削: {path}")
time.sleep(10)
print(f"[后台任务] 刮削完成: {path}")

141
backend/utils/plex_utils.py Normal file
View File

@@ -0,0 +1,141 @@
import logging
from plexapi.server import PlexServer
from plexapi.exceptions import Unauthorized, NotFound
log = logging.getLogger(__name__)
class PlexManager:
"""
用于与 Plex Media Server 交互的工具类。
- 适配项目 config.json 格式。
- 在发生错误时抛出异常,而不是返回错误字典。
- 成功时直接返回数据。
"""
def __init__(self, config):
"""
根据配置初始化 PlexManager。
:param config: 来自 config.json 的 'plex' 部分。
"""
self.config = config
self.token = config.get('token')
host = config.get('host')
port = config.get('port')
use_ssl = config.get('use_ssl', False)
if not all([host, port, self.token]):
self.baseurl = None
else:
scheme = 'https' if use_ssl else 'http'
self.baseurl = f"{scheme}://{host}:{port}"
self.server = None
log.debug(f"PlexManager initialized for URL: {self.baseurl}")
def _connect(self):
"""
连接到 Plex 服务器。如果连接已建立则直接返回。
如果失败,则会抛出异常。
"""
if self.server:
return
if not self.baseurl:
raise ValueError("Plex 未配置 (host, port, token)")
try:
log.debug(f"尝试连接到 Plex: {self.baseurl}")
# plexapi 内部有重试机制,但我们可以设置一个合理的超时
self.server = PlexServer(self.baseurl, self.token, timeout=10)
# 访问一个属性来验证连接
_ = self.server.friendlyName
log.info(f"成功连接到 Plex 服务器: {self.server.friendlyName}")
except Unauthorized:
log.error("Plex 连接失败: Token 无效或权限不足")
raise # 将原始异常重新抛出
except Exception as e:
log.error(f"Plex 连接失败: {e}", exc_info=True)
self.server = None # 连接失败时重置 server 对象
raise # 重新抛出,让调用者处理
def test_connection(self):
"""
测试与 Plex 的连接,并返回一个易于前端展示的状态元组。
:return: (bool, str) -> (是否成功, 消息)
"""
try:
self._connect()
message = f"连接成功: {self.server.friendlyName} (v{self.server.version})"
return True, message
except Exception as e:
return False, f"连接失败: {e}"
def get_stats(self):
"""获取所有媒体库的项目总数,用于仪表盘。"""
try:
self._connect()
return len(self.server.library.sections())
except Exception:
return 0 # 如果连接失败返回0
def get_libraries(self):
"""
获取媒体库列表。
:return: list of dicts, 每个字典代表一个媒体库
"""
self._connect()
libraries = []
log.debug("正在获取 Plex 媒体库列表")
for section in self.server.library.sections():
libraries.append({
'id': section.key,
'name': section.title,
'type': section.type,
'path': ', '.join(section.locations)
})
log.debug(f"成功获取 {len(libraries)} 个 Plex 媒体库")
return libraries
def get_library_items(self, library_id):
"""
获取指定媒体库中的所有项目。
此方法会抛出 NotFound 或 ValueError 异常,需要调用者捕获。
:param library_id: 媒体库的 ID (key)
:return: list of dicts, 每个字典代表一个媒体项目
"""
self._connect()
log.debug(f"正在获取 Plex 媒体库 '{library_id}' 的项目")
# plexapi v4.12.0+ sectionByID 接受字符串或整数
section = self.server.library.sectionByID(library_id)
items = []
if section.type not in ['movie', 'show']:
log.warning(f"跳过不支持的 Plex 库类型: {section.type}")
return items
for item in section.all():
item_data = {
'id': item.ratingKey,
'title': item.title,
'type': item.type,
'year': item.year,
'summary': item.summary,
'poster': self.server.url(item.thumb, True) if item.thumb else None,
'path': ', '.join(item.locations),
}
items.append(item_data)
log.debug(f"从库 '{section.title}' 获取到 {len(items)} 个项目")
return items
def refresh_library(self, library_id):
"""
发送刷新指定媒体库的请求。
:param library_id: 媒体库的 ID (key)
"""
self._connect()
log.debug(f"正在请求刷新 Plex 媒体库: {library_id}")
section = self.server.library.sectionByID(library_id)
section.update()
log.info(f"已向 Plex 媒体库 '{section.title}' 发送刷新请求")

View File

View File

@@ -0,0 +1,31 @@
import qbittorrentapi
class QBittorrentManager:
def __init__(self, config):
self.client = qbittorrentapi.Client(**config)
def test_connection(self):
try:
self.client.auth_log_in()
version = self.client.app.version
api_version = self.client.app.webapi_version
return True, f"连接成功qBittorrent 版本: {version} (API: {api_version})"
except qbittorrentapi.LoginFailed as e:
return False, f"登录失败: {e}"
except Exception as e:
return False, f"连接失败: {e}"
def get_stats(self):
try:
self.client.auth_log_in()
return len(self.client.torrents_info())
except:
return 0
def add_torrent(self, url):
try:
self.client.auth_log_in()
result = self.client.torrents_add(urls=url)
return result == "Ok.", "任务添加成功" if result == "Ok." else "任务添加失败"
except Exception as e:
return False, str(e)

View File