diff --git a/app/helper/system.py b/app/helper/system.py index 7ddeba00..61d5685c 100644 --- a/app/helper/system.py +++ b/app/helper/system.py @@ -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: # 检查容器是否配置了自动重启策略 diff --git a/docs/cli.md b/docs/cli.md index 0ceec341..73401951 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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` 到后端 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 7d2bc2a1..4c7946a1 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -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