support local restart and site auth wizard

This commit is contained in:
jxxghp
2026-04-16 19:21:00 +08:00
parent 6fcc5c84a6
commit 61a0a66c47
3 changed files with 237 additions and 7 deletions

View File

@@ -1,11 +1,15 @@
import json
import os
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Tuple
from typing import Optional, Tuple
import docker
import psutil
from app.core.config import settings
from app.log import logger
@@ -27,6 +31,8 @@ class SystemHelper(ConfigReloadMixin):
}
__system_flag_file = "/var/log/nginx/__moviepilot__"
__local_backend_runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
__local_restart_log_file = settings.LOG_PATH / "moviepilot.restart.stdout.log"
def on_config_changed(self):
logger.update_loggers()
@@ -39,10 +45,74 @@ class SystemHelper(ConfigReloadMixin):
"""
判断是否可以内部重启
"""
return (
Path("/var/run/docker.sock").exists()
or settings.DOCKER_CLIENT_API != "tcp://127.0.0.1:38379"
return SystemUtils.is_docker() or SystemHelper._is_local_cli_managed()
@staticmethod
def _load_runtime_file(path: Path) -> Optional[dict]:
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
@staticmethod
def _is_local_cli_managed() -> bool:
runtime = SystemHelper._load_runtime_file(SystemHelper.__local_backend_runtime_file)
if not runtime:
return False
pid = runtime.get("pid")
create_time = runtime.get("create_time")
if not pid:
return False
try:
pid = int(pid)
except (TypeError, ValueError):
return False
if pid != os.getpid():
return False
if create_time is None:
return True
try:
current_process = psutil.Process(os.getpid())
return abs(current_process.create_time() - float(create_time)) <= 2
except (psutil.Error, TypeError, ValueError):
return False
@staticmethod
def _spawn_local_restart_helper() -> None:
helper_code = (
"import os, subprocess, sys, time;"
"time.sleep(1.0);"
"cmd=[sys.executable, '-m', 'app.cli', 'restart', '--force', '--stop-timeout', '30', '--start-timeout', '60'];"
"subprocess.run(cmd, cwd=os.environ.get('MOVIEPILOT_ROOT'), env=os.environ.copy(), check=False)"
)
env = os.environ.copy()
env["MOVIEPILOT_ROOT"] = str(settings.ROOT_PATH)
env["PYTHONUNBUFFERED"] = "1"
SystemHelper.__local_restart_log_file.parent.mkdir(parents=True, exist_ok=True)
with SystemHelper.__local_restart_log_file.open("a", encoding="utf-8") as log_handle:
kwargs = {
"cwd": str(settings.ROOT_PATH),
"stdout": log_handle,
"stderr": subprocess.STDOUT,
"stdin": subprocess.DEVNULL,
"close_fds": True,
"env": env,
}
if os.name == "nt":
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
else:
kwargs["start_new_session"] = True
process = subprocess.Popen([sys.executable, "-c", helper_code], **kwargs)
logger.info(f"已创建本地 CLI 重启任务,辅助进程 PID: {process.pid}")
@staticmethod
def _get_container_id() -> str:
@@ -104,7 +174,14 @@ class SystemHelper(ConfigReloadMixin):
执行Docker重启操作
"""
if not SystemUtils.is_docker():
return False, "非Docker环境无法重启"
if not SystemHelper._is_local_cli_managed():
return False, "当前实例不是由 moviepilot CLI 启动,无法执行内建重启!"
try:
SystemHelper._spawn_local_restart_helper()
return True, ""
except Exception as err:
logger.error(f"本地 CLI 重启失败: {str(err)}")
return False, f"本地 CLI 重启失败:{str(err)}"
try:
# 检查容器是否配置了自动重启策略

View File

@@ -212,6 +212,8 @@ moviepilot setup --config-dir /path/to/moviepilot-config
- 默认下载目录与媒体库目录
- AI Agent
可按需启用,并配置 `LLM_PROVIDER``LLM_MODEL``LLM_API_KEY``LLM_BASE_URL`
- 用户站点认证
可按需选择认证站点并按站点要求填写用户名、UID、Passkey 等参数
- 下载器
- 媒体服务器
- 消息通知渠道
@@ -287,6 +289,7 @@ moviepilot version
说明:
- `start` 会先启动后端,再启动前端
- 通过系统内置的重启入口触发重启时,本地 CLI 安装模式也会复用同一套前后端进程管理完成重启
- 前端默认监听 `NGINX_PORT`,默认值 `3000`
- 后端默认监听 `PORT`,默认值 `3001`
- 前端通过 `service.js` 代理 `/api``/cookiecloud` 到后端

View File

@@ -998,7 +998,131 @@ def _collect_agent_config() -> dict[str, Any]:
return config
def run_setup_wizard(force_token: bool) -> dict[str, Any]:
def _load_auth_site_definitions_inner() -> dict[str, Any]:
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from app.helper.sites import SitesHelper
auth_sites = SitesHelper().get_authsites() or {}
definitions: dict[str, Any] = {}
for site_key, site_conf in auth_sites.items():
site_name = str(site_conf.get("name") or site_key).strip()
params: dict[str, Any] = {}
for param_key, param_conf in (site_conf.get("params") or {}).items():
params[param_key] = {
"name": str(param_conf.get("name") or param_key).strip(),
"type": str(param_conf.get("type") or "text").strip().lower(),
"placeholder": str(param_conf.get("placeholder") or "").strip(),
"tooltip": str(param_conf.get("tooltip") or "").strip(),
"convert": str(param_conf.get("convert") or "").strip().lower(),
}
if params:
definitions[site_key] = {
"name": site_name,
"params": params,
}
return definitions
def _load_auth_site_definitions(runtime_python: Optional[Path] = None) -> dict[str, Any]:
try:
return _load_auth_site_definitions_inner()
except Exception as exc:
if runtime_python and not _current_python_matches(runtime_python):
try:
with TemporaryDirectory() as temp_dir:
output_path = Path(temp_dir) / "auth-sites.json"
subprocess.run(
[
str(runtime_python),
str(Path(__file__).resolve()),
"query-auth-sites",
"--output-json-file",
str(output_path),
],
cwd=str(ROOT),
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
data = json.loads(output_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return data
except Exception as runtime_exc:
print_step(f"当前环境暂时无法读取站点认证资源,已跳过站点认证配置:{runtime_exc}")
return {}
print_step(f"当前环境暂时无法读取站点认证资源,已跳过站点认证配置:{exc}")
return {}
def _print_auth_sites(auth_sites: dict[str, Any]) -> None:
print("可用认证站点:")
items = [f"{site_key}({site_conf.get('name') or site_key})" for site_key, site_conf in sorted(auth_sites.items())]
line: list[str] = []
for item in items:
line.append(item)
if len(line) >= 4:
print(f" {' '.join(line)}")
line = []
if line:
print(f" {' '.join(line)}")
def _prompt_auth_param(param_key: str, param_meta: dict[str, Any]) -> Any:
label = str(param_meta.get("name") or param_key).strip()
placeholder = str(param_meta.get("placeholder") or "").strip()
tooltip = str(param_meta.get("tooltip") or "").strip()
prompt_label = label if not placeholder else f"{label} ({placeholder})"
if tooltip:
print(f"{prompt_label}{tooltip}")
while True:
if str(param_meta.get("type") or "text").strip().lower() == "password":
value = _prompt_secret_text(prompt_label, required=True)
else:
value = _prompt_text(prompt_label)
if str(param_meta.get("convert") or "").strip().lower() != "int":
return value
try:
return int(value)
except ValueError:
print("请输入有效数字。")
def _collect_site_auth_config(runtime_python: Optional[Path] = None) -> Optional[dict[str, Any]]:
print_step("用户站点认证配置")
if not _prompt_yes_no("是否配置用户站点认证", default=False):
return None
auth_sites = _load_auth_site_definitions(runtime_python=runtime_python)
if not auth_sites:
print_step("未能读取可用站点认证清单,已跳过用户站点认证配置")
return None
_print_auth_sites(auth_sites)
while True:
selected_site = _prompt_text("请输入认证站点代号").strip().lower()
if selected_site in auth_sites:
break
print("请输入上面列表中的站点代号。")
site_conf = auth_sites[selected_site]
print_step(f"正在配置站点认证:{site_conf.get('name') or selected_site}")
params = {
param_key: _prompt_auth_param(param_key, param_meta)
for param_key, param_meta in (site_conf.get("params") or {}).items()
}
return {
"site": selected_site,
"params": params,
}
def run_setup_wizard(force_token: bool, runtime_python: Optional[Path] = None) -> dict[str, Any]:
if not _is_interactive():
raise RuntimeError("交互式向导需要在终端中运行,请直接执行 moviepilot setup --wizard 或 moviepilot init --wizard")
@@ -1039,6 +1163,7 @@ def run_setup_wizard(force_token: bool) -> dict[str, Any]:
"downloader": _collect_downloader_config(),
"mediaserver": _collect_media_server_config(),
"notification": _collect_notification_config(),
"site_auth": _collect_site_auth_config(runtime_python=runtime_python),
}
@@ -1143,6 +1268,20 @@ def _apply_local_system_config_inner(config_payload: dict[str, Any]) -> None:
current_switches = system_config.get(SystemConfigKey.NotificationSwitchs) or []
system_config.set(SystemConfigKey.NotificationSwitchs, _merge_notification_switches(current_switches))
site_auth_item = config_payload.get("site_auth")
if isinstance(site_auth_item, dict) and site_auth_item.get("site") and site_auth_item.get("params"):
system_config.set(SystemConfigKey.UserSiteAuthParams, site_auth_item)
try:
from app.helper.sites import SitesHelper
status, msg = SitesHelper().check_user(site_auth_item.get("site"), site_auth_item.get("params"))
if status:
print_step(f"站点认证校验成功:{msg}")
else:
print_step(f"已保存站点认证配置,当前校验未通过:{msg}")
except Exception as exc:
print_step(f"已保存站点认证配置,当前未完成校验:{exc}")
system_config.set(SystemConfigKey.SetupWizardState, True)
print_step("已写入本地系统配置")
@@ -1196,7 +1335,7 @@ def init_local(
wizard_payload: Optional[dict[str, Any]] = None
if wizard:
wizard_payload = run_setup_wizard(force_token=force_token)
wizard_payload = run_setup_wizard(force_token=force_token, runtime_python=runtime_python)
else:
ensure_api_token(force_token=force_token)
@@ -1415,6 +1554,9 @@ def build_parser() -> argparse.ArgumentParser:
apply_config_parser = subparsers.add_parser("apply-config", help=argparse.SUPPRESS)
apply_config_parser.add_argument("--config-json-file", required=True, help=argparse.SUPPRESS)
query_auth_sites_parser = subparsers.add_parser("query-auth-sites", help=argparse.SUPPRESS)
query_auth_sites_parser.add_argument("--output-json-file", required=True, help=argparse.SUPPRESS)
return parser
@@ -1528,6 +1670,14 @@ def main() -> int:
raise RuntimeError("配置负载格式错误")
_apply_local_system_config_inner(payload)
return 0
if args.command == "query-auth-sites":
payload = _load_auth_site_definitions_inner()
Path(args.output_json_file).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return 0
except subprocess.CalledProcessError as exc:
print(f"命令执行失败,退出码:{exc.returncode}", file=sys.stderr)
return exc.returncode