init
This commit is contained in:
35
backend/app.py
Normal file
35
backend/app.py
Normal 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)
|
||||
42
backend/config.json.example
Normal file
42
backend/config.json.example
Normal 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
67
backend/config.py
Normal 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
14
backend/requirements.txt
Normal 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
|
||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
41
backend/routes/actions.py
Normal file
41
backend/routes/actions.py
Normal 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
|
||||
53
backend/routes/dashboard.py
Normal file
53
backend/routes/dashboard.py
Normal 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
102
backend/routes/media.py
Normal 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
61
backend/routes/system.py
Normal 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
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
76
backend/services/cache_service.py
Normal file
76
backend/services/cache_service.py
Normal 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 []
|
||||
40
backend/services/data_updater.py
Normal file
40
backend/services/data_updater.py
Normal 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("开始更新本地短剧缓存...")
|
||||
# ...
|
||||
35
backend/services/scheduler_service.py
Normal file
35
backend/services/scheduler_service.py
Normal 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)
|
||||
|
||||
0
backend/utils/__init__.py
Normal file
0
backend/utils/__init__.py
Normal file
54
backend/utils/douban_utils.py
Normal file
54
backend/utils/douban_utils.py
Normal 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}")
|
||||
|
||||
0
backend/utils/media_server_utils.py
Normal file
0
backend/utils/media_server_utils.py
Normal file
36
backend/utils/pagination.py
Normal file
36
backend/utils/pagination.py
Normal 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
|
||||
}
|
||||
|
||||
45
backend/utils/placeholders.py
Normal file
45
backend/utils/placeholders.py
Normal 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
141
backend/utils/plex_utils.py
Normal 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}' 发送刷新请求")
|
||||
0
backend/utils/ptskit_utils.py
Normal file
0
backend/utils/ptskit_utils.py
Normal file
31
backend/utils/qbittorrent_utils.py
Normal file
31
backend/utils/qbittorrent_utils.py
Normal 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)
|
||||
0
backend/utils/transmission_utils.py
Normal file
0
backend/utils/transmission_utils.py
Normal file
Reference in New Issue
Block a user