Files
xymediavault-releases/install.sh
T

714 lines
38 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
CATALOG_URL=${XYMEDIA_CATALOG_URL:-https://git.keeper.work/admin/xymediavault-releases/raw/branch/main/catalog-v1.json}
RELEASE_BASE=${XYMEDIA_RELEASE_BASE:-https://git.keeper.work/admin/xymediavault-releases/raw/branch/main}
INSTALL_NONCE=$(date +%s)
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd -P)
INSTALL_DIR=${XYMEDIA_INSTALL_DIR:-$PWD}
PG_HOST=postgres
PG_PORT=5432
PG_DB=xymedia
PG_USER=xymedia_app
PG_PASSWORD_FILE=
SKIP_COMPONENTS=0
EXISTING_DB=0
API_PORT=${XYMEDIA_API_PORT:-18080}
WEBDAV_PORT=${XYMEDIA_WEBDAV_PORT:-18081}
TVBOX_PORT=${XYMEDIA_TVBOX_PORT:-18082}
TOTAL_STEPS=9
STEP=0
INSTALL_LOG=
TTY_IN=${XYMEDIA_INSTALL_TEST_TTY:-/dev/tty}
TTY_OUT=${XYMEDIA_INSTALL_TEST_TTY_OUTPUT:-/dev/tty}
TTY_TEST_MODE=0
TTY_TEST_INDEX=0
TTY_TEST_LINES=()
if [[ -n ${XYMEDIA_INSTALL_TEST_TTY:-} ]]; then TTY_TEST_MODE=1; mapfile -t TTY_TEST_LINES <"$TTY_IN"; fi
XIAOYA_DIR=${XYMEDIA_XIAOYA_DIR:-}
XIAOYA_CONTAINER_EXPLICIT=${XYMEDIA_XIAOYA_CONTAINER:+1}
XIAOYA_CONTAINER=${XYMEDIA_XIAOYA_CONTAINER:-xiaoya-alist}
CONTROLLER_ONLY=0
MENU_PROFILES=
CONTROLLER_URL=${XYMEDIA_XIAOYA_CONTROLLER_URL:-}
CONTROLLER_BIND_ADDRESS=${XYMEDIA_CONTROLLER_BIND_ADDRESS:-0.0.0.0}
CONTROLLER_PORT=${XYMEDIA_CONTROLLER_PORT:-19090}
REMOTE_MODE=0
REMOTE_TOKEN=
CONTROLLER_BIND_EXPLICIT=${XYMEDIA_CONTROLLER_BIND_ADDRESS:+1}
CONTROLLER_PORT_EXPLICIT=${XYMEDIA_CONTROLLER_PORT:+1}
MAINTENANCE_ACTION=
FUSE_PATH_INPUT=
confirm_fuse_action() {
local confirmation
confirmation=$(prompt_tty '该操作将停止并重新启动 app;输入 FUSE_CONFIRM 继续: ')
[[ $confirmation == FUSE_CONFIRM ]] || die '未确认 FUSE 生命周期操作'
}
require_public_app_installation() {
local missing=()
[[ -f "$INSTALL_DIR/.env" ]] || missing+=(.env)
[[ -f "$INSTALL_DIR/compose.yaml" ]] || missing+=(compose.yaml)
[[ -f "$INSTALL_DIR/config.yaml" ]] || missing+=(config.yaml)
[[ -f "$INSTALL_DIR/compose.fuse.yaml" ]] || missing+=(compose.fuse.yaml)
[[ -x "$INSTALL_DIR/remount-fuse.sh" ]] || missing+=(remount-fuse.sh)
grep -Eq '^ app:' "$INSTALL_DIR/compose.yaml" 2>/dev/null || missing+=(app-service)
if ((${#missing[@]})); then
printf '不支持 FUSE 维护:缺少完整 app 安装(%s)。\n' "${missing[*]}" >>$TTY_OUT
return 1
fi
return 0
}
read_install_env() {
awk -F= -v wanted="$1" '$1 == wanted {sub(/^[^=]*=/, ""); print; exit}' "$INSTALL_DIR/.env" 2>/dev/null || true
}
print_fuse_status() {
local enabled path mount_id source fstype health
enabled=$(read_install_env XYMEDIA_FUSE_ENABLED)
path=$(read_install_env XYMEDIA_FUSE_HOST_PATH)
printf 'FUSE 状态:XYMEDIA_FUSE_ENABLED=%s\n' "${enabled:-未配置}" >>$TTY_OUT
printf 'FUSE 路径:XYMEDIA_FUSE_HOST_PATH=%s\n' "${path:-未配置}" >>$TTY_OUT
if [[ -z $path ]]; then
printf '挂载身份:未配置路径\n' >>$TTY_OUT
elif ! command -v mountpoint >/dev/null 2>&1 || ! command -v findmnt >/dev/null 2>&1; then
printf '挂载身份:无法确认(mountpoint/findmnt 不可用)\n' >>$TTY_OUT
elif ! mountpoint -q "$path" 2>/dev/null; then
printf '挂载身份:未挂载\n' >>$TTY_OUT
else
mount_id=$(findmnt -rn -M "$path" -o ID 2>/dev/null || true)
source=$(findmnt -rn -M "$path" -o SOURCE 2>/dev/null || true)
fstype=$(findmnt -rn -M "$path" -o FSTYPE 2>/dev/null || true)
if [[ -n $mount_id && $source == xymediavault ]] && { [[ $fstype == fuse.xymediavault ]] || [[ $fstype == fuse ]]; }; then
printf '挂载身份:owned xymediavault/fuse.xymediavaultID=%s\n' "$mount_id" >>$TTY_OUT
elif [[ -n $mount_id ]]; then
printf '挂载身份:foreign/unknownID=%s source=%s fstype=%s\n' "$mount_id" "$source" "$fstype" >>$TTY_OUT
else
printf '挂载身份:无法确认\n' >>$TTY_OUT
fi
fi
health=$(curl --silent --show-error --max-time 5 -o /dev/null -w '%{http_code}' "http://127.0.0.1:${API_PORT}/api/health" 2>/dev/null || true)
[[ $health =~ ^[0-9]{3}$ ]] || health='无法连接'
printf 'app health%s\n' "$health" >>$TTY_OUT
}
while (($#)); do
case "$1" in
--install-dir) INSTALL_DIR=${2:?missing value}; shift 2;;
--postgres-host) PG_HOST=${2:?missing value}; shift 2;;
--postgres-port) PG_PORT=${2:?missing value}; shift 2;;
--postgres-db) PG_DB=${2:?missing value}; shift 2;;
--postgres-user) PG_USER=${2:?missing value}; shift 2;;
--postgres-password-file) PG_PASSWORD_FILE=${2:?missing value}; shift 2;;
--skip-components) SKIP_COMPONENTS=1; shift;;
--existing-db) EXISTING_DB=1; shift;;
-h|--help) usage; exit 0;;
*) die "unknown option: $1";;
esac
done
die() { printf '\n\033[31mxymediavault installer: %s\033[0m\n' "$*" >&2; [[ -n $INSTALL_LOG ]] && printf '详情日志:%s\n' "$INSTALL_LOG" >&2; exit 1; }
INSTALL_TMP_DIR=
cleanup_installer_tmp() { [[ -z ${INSTALL_TMP_DIR:-} ]] || rm -rf -- "$INSTALL_TMP_DIR"; }
trap cleanup_installer_tmp EXIT INT TERM
init_installer_log() {
local tmp_parent=${TMPDIR:-/tmp} mode
[[ -d $tmp_parent && ! -L $tmp_parent ]] || die 'TMPDIR must be an existing non-symlink directory'
if (( EUID == 0 )); then
mode=$(stat -c '%a' "$tmp_parent")
[[ $(stat -c '%u' "$tmp_parent") -eq 0 ]] || die 'TMPDIR must be owned by root when running as root'
[[ $((mode & 022)) -eq 0 || $((mode & 1000)) -ne 0 ]] || die 'TMPDIR must not be group/world writable unless sticky'
fi
umask 077
INSTALL_TMP_DIR=$(mktemp -d "$tmp_parent/xymediavault-install.XXXXXX") || die '无法创建私有安装临时目录'
chmod 700 "$INSTALL_TMP_DIR"
INSTALL_LOG=$(mktemp "$INSTALL_TMP_DIR/install.XXXXXX.log") || die '无法创建安装日志'
chmod 600 "$INSTALL_LOG"
}
guard_existing_install_tree() {
[[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || return 0
INSTALL_PATH="$INSTALL_DIR" python3 - <<'PY' || die '安装目录路径不能包含符号链接'
import os, pathlib
p = pathlib.Path(os.environ['INSTALL_PATH'])
cur = pathlib.Path(p.root)
for part in p.parts[1:]:
cur /= part
if cur.exists() and cur.is_symlink(): raise SystemExit(1)
if p.exists():
s = p.stat()
if os.geteuid() == 0 and (s.st_uid != 0 or s.st_mode & 0o022): raise SystemExit(2)
PY
[[ ! -e "$INSTALL_DIR/.env" || ! -L "$INSTALL_DIR/.env" ]] || die '.env must not be a symlink'
}
progress() {
STEP=$((STEP + 1))
printf '\033[2K\r[%d/%d] %s\n' "$STEP" "$TOTAL_STEPS" "$1"
if [[ -n $INSTALL_LOG ]]; then
printf '[%d/%d] %s\n' "$STEP" "$TOTAL_STEPS" "$1" >>"$INSTALL_LOG"
fi
}
run_logged() {
if "$@" >>"$INSTALL_LOG" 2>&1; then
return 0
fi
return 1
}
run_visible_logged() {
"$@" 2>&1 | tee -a "$INSTALL_LOG"
return "${PIPESTATUS[0]}"
}
usage() { sed -n '1,20p' "$0"; }
read_tty() { if ((TTY_TEST_MODE)); then [[ $TTY_TEST_INDEX -lt ${#TTY_TEST_LINES[@]} ]] || return 1; printf -v "$1" '%s' "${TTY_TEST_LINES[$TTY_TEST_INDEX]}"; TTY_TEST_INDEX=$((TTY_TEST_INDEX + 1)); else IFS= read -r "$1" <"$TTY_IN"; fi; }
prompt_tty() { [[ -r $TTY_IN && -w $TTY_OUT ]] || die 'interactive menu requires a TTY; use menu option 1 from a terminal'; printf '%s' "$1" >>$TTY_OUT; read_tty value || die 'unable to read terminal input'; printf '%s' "$value"; }
prompt_tty_into() { [[ -r $TTY_IN && -w $TTY_OUT ]] || die 'interactive menu requires a TTY; use menu option 1 from a terminal'; printf '%s' "$2" >>$TTY_OUT; read_tty "$1" || die 'unable to read terminal input'; }
controller_token_file() { printf '%s/secrets/xiaoya-controller-token' "$INSTALL_DIR"; }
ensure_xiaoya_dir() {
local required=${1:-0}
[[ -n $XIAOYA_DIR ]] || prompt_tty_into XIAOYA_DIR "小雅安装/数据目录 [${INSTALL_DIR}/xiaoya]: "
[[ -n $XIAOYA_DIR || $required != 1 ]] || die '小雅目录不能为空'
[[ -n $XIAOYA_DIR ]] || XIAOYA_DIR="$INSTALL_DIR/xiaoya"
[[ $XIAOYA_DIR = /* && $XIAOYA_DIR != / ]] || die '小雅目录必须是绝对路径且不能是根目录'
if [[ -d $XIAOYA_DIR ]] && [[ -n $(find "$XIAOYA_DIR" -mindepth 1 -maxdepth 1 -print -quit) ]]; then
printf '检测到已有非空小雅目录:%s\n' "$XIAOYA_DIR" >>$TTY_OUT
printf '请选择 attach / install-controller / separate / return' >>$TTY_OUT
read_tty choice || die 'unable to read terminal input'
[[ $choice == attach || $choice == install-controller || $choice == separate || $choice == return ]] || die '无效选择,已停止以保护已有数据'
[[ $choice != return ]] || exit 0
fi
mkdir -p "$XIAOYA_DIR" "$INSTALL_DIR/secrets"
}
write_controller_token() {
local file; mkdir -p "$INSTALL_DIR/secrets"; file=$(controller_token_file)
if [[ ! -s $file ]]; then umask 077; if command -v openssl >/dev/null; then openssl rand -hex 32 >"$file"; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$file"; fi; chmod 600 "$file"; fi
}
menu() {
[[ $# -eq 0 ]] || return 0
[[ -r $TTY_IN && -w $TTY_OUT ]] || die 'no TTY: interactive installer menu cannot run from a non-interactive pipe'
while :; do
printf '\nXyMediaVault 安装器\n1) 仅安装管理平台\n2) 安装管理平台和本机小雅\n3) 安装管理平台并连接远程小雅控制器\n4) 仅安装小雅控制器\n5) 状态/诊断/维护\n0) 退出\n选择:' >>$TTY_OUT
read_tty choice || die 'unable to read terminal input'
case "$choice" in
1) return 0;;
2) ensure_xiaoya_dir; printf '小雅目录已准备:%s;将安装小雅和小雅控制器。\n' "$XIAOYA_DIR" >>$TTY_OUT; write_controller_token; CONTROLLER_URL='http://xymedia-edge-controller:19090'; MENU_PROFILES='xiaoya xiaoya-control'; return 0;;
3) local url name key; url=$(prompt_tty '控制器地址:'); key=$(prompt_tty '控制器密钥:'); name=$(prompt_tty '显示名称:'); [[ $url == https://* || ${ALLOW_HTTP:-0} == 1 ]] || die '远程连接默认要求 HTTPS;设置 ALLOW_HTTP=1 后才允许 HTTP'; curl --fail --silent --show-error --location --max-time 10 -H "X-XyMedia-Controller-Token: $key" "${url%/}/health" >/dev/null || die '控制器健康检查失败,未保存连接'; REMOTE_MODE=1; REMOTE_TOKEN=$key; CONTROLLER_URL="${url%/}"; printf '远程连接 %s (%s) 校验成功,配置已保存;控制器密钥不会显示。\n' "$name" "$CONTROLLER_URL" >>$TTY_OUT; return 0;;
4) ensure_xiaoya_dir 1; if [[ ${XYMEDIA_INSTALL_TEST_DISCOVERY_ONLY:-0} != 1 && -z $CONTROLLER_BIND_EXPLICIT ]]; then prompt_tty_into CONTROLLER_BIND_ADDRESS "控制器监听地址 [${CONTROLLER_BIND_ADDRESS}]: "; CONTROLLER_BIND_ADDRESS=${CONTROLLER_BIND_ADDRESS:-0.0.0.0}; fi; if [[ ${XYMEDIA_INSTALL_TEST_DISCOVERY_ONLY:-0} != 1 && -z $CONTROLLER_PORT_EXPLICIT ]]; then prompt_tty_into CONTROLLER_PORT "控制器端口 [${CONTROLLER_PORT}]: "; CONTROLLER_PORT=${CONTROLLER_PORT:-19090}; fi; write_controller_token; CONTROLLER_ONLY=1; printf '小雅控制器安装目录已准备。\n' >>$TTY_OUT; MENU_PROFILES='xiaoya-control'; return 0;;
5) while :; do
printf '\n维护菜单\n1) 查看状态\n2) 一键挂载本地媒体库\n3) 启动/重启已配置挂载\n4) 停用本地媒体库挂载\n0) 返回\n选择:' >>$TTY_OUT
read_tty maintenance_choice || die 'unable to read terminal input'
case "$maintenance_choice" in
1) require_public_app_installation || continue; print_fuse_status;;
2) require_public_app_installation || continue; prompt_tty_into FUSE_PATH_INPUT '宿主机媒体库目录(必须为空):'; confirm_fuse_action; MAINTENANCE_ACTION=configure; return 0;;
3) require_public_app_installation || continue; confirm_fuse_action; MAINTENANCE_ACTION=restart; return 0;;
4) require_public_app_installation || continue; confirm_fuse_action; MAINTENANCE_ACTION=disable; return 0;;
0) continue 2;; *) printf '无效选择\n' >>$TTY_OUT;;
esac
done;;
0) exit 0;; *) printf '无效选项\n' >>$TTY_OUT;;
esac
done
}
menu "$@"
if [[ ${XYMEDIA_INSTALL_TEST_MENU_ONLY:-0} == 1 ]]; then
printf 'TEST_MENU_PROFILES=%s\n' "$MENU_PROFILES" >$TTY_OUT
exit 0
fi
validate_controller_settings() {
[[ $CONTROLLER_PORT =~ ^[0-9]+$ && $CONTROLLER_PORT -ge 1 && $CONTROLLER_PORT -le 65535 ]] || die '控制器端口必须是 1 到 65535 之间的数字'
[[ $CONTROLLER_BIND_ADDRESS != *$'\r'* && $CONTROLLER_BIND_ADDRESS != *$'\n'* ]] || die '控制器监听地址不能包含换行'
case "$CONTROLLER_BIND_ADDRESS" in
''|localhost|0.0.0.0|::) return 0;;
esac
if ! python3 - "$CONTROLLER_BIND_ADDRESS" <<'PY'
import ipaddress, sys
try:
ipaddress.ip_address(sys.argv[1])
except ValueError:
raise SystemExit(1)
PY
then
die '控制器监听地址必须是 IPv4、IPv6、0.0.0.0、:: 或 localhost'
fi
}
validate_container_name() {
[[ $XIAOYA_CONTAINER =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}$ ]] || die '小雅容器名称不合法,不能包含换行或 Compose 特殊字符'
}
progress '检查安装环境'
[[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || die 'install directory must be an absolute non-root path'
guard_existing_install_tree
init_installer_log
command -v docker >/dev/null || die 'Docker is required'
if docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose); elif command -v docker-compose >/dev/null; then COMPOSE=(docker-compose); else die 'Docker Compose is required'; fi
docker info >/dev/null 2>&1 || die 'cannot connect to Docker'
command -v curl >/dev/null || die 'curl is required'
command -v zstd >/dev/null || die 'zstd is required for release archives'
command -v python3 >/dev/null || die 'python3 is required for safe archive extraction'
if [[ -n $MAINTENANCE_ACTION ]]; then
require_public_app_installation || die '维护操作仅支持已有完整 app 安装'
case "$MAINTENANCE_ACTION" in
configure) exec env XYMEDIA_INSTALL_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/remount-fuse.sh" --configure "$FUSE_PATH_INPUT";;
restart) exec env XYMEDIA_INSTALL_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/remount-fuse.sh" --restart;;
disable) exec env XYMEDIA_INSTALL_DIR="$INSTALL_DIR" sh "$INSTALL_DIR/remount-fuse.sh" --disable;;
esac
fi
discover_xiaoya_container() {
local id record selected choice image state
local -a ids=() candidates=() ordered=()
if [[ -n ${XIAOYA_CONTAINER_EXPLICIT:-} ]]; then
docker container inspect "$XIAOYA_CONTAINER" >/dev/null 2>&1 || die "未找到小雅容器:$XIAOYA_CONTAINER"
printf '使用指定的小雅容器:%sDocker inspect 已确认)\n' "$XIAOYA_CONTAINER" >>$TTY_OUT
return 0
fi
mapfile -t ids < <(docker ps -a -q)
for id in "${ids[@]}"; do
record=$(docker inspect "$id" 2>/dev/null | python3 -c '
import json, re, sys
try:
item = json.load(sys.stdin)[0]
config = item.get("Config") or {}
name = (item.get("Name") or "").lstrip("/")
image = config.get("Image") or item.get("Image") or ""
labels = config.get("Labels") or {}
haystack = " ".join([name, image] + [str(k) + "=" + str(v) for k, v in labels.items()]).lower()
if not re.search(r"(?:alist|xiaoya|xymedia/xiaoya)", haystack):
raise SystemExit(0)
print(name + "\t" + image + "\t" + ((item.get("State") or {}).get("Status") or "unknown"))
except (ValueError, IndexError, TypeError, AttributeError):
raise SystemExit(1)
') || true
[[ -n $record ]] && candidates+=("$record")
done
for record in "${candidates[@]}"; do [[ $record == *$'\trunning' ]] && ordered+=("$record"); done
for record in "${candidates[@]}"; do [[ $record == *$'\trunning' ]] || ordered+=("$record"); done
if ((${#ordered[@]} == 0)); then
prompt_tty_into selected '未自动发现小雅容器,请输入容器名称(将执行 Docker inspect):'
[[ -n $selected ]] || die '小雅容器名称不能为空'
docker container inspect "$selected" >/dev/null 2>&1 || die "容器不存在或无法 inspect$selected"
XIAOYA_CONTAINER=$selected
printf '已确认手动指定的小雅容器:%s\n' "$XIAOYA_CONTAINER" >>$TTY_OUT
elif ((${#ordered[@]} == 1)); then
IFS=$'\t' read -r XIAOYA_CONTAINER image state <<<"${ordered[0]}"
printf '自动发现小雅容器:%s(镜像:%s,状态:%s)\n' "$XIAOYA_CONTAINER" "$image" "$state" >>$TTY_OUT
[[ $state == running ]] || printf '警告:小雅容器当前已停止;仍允许安装控制器,状态页会提示。\n' >>$TTY_OUT
else
printf '发现多个可能的小雅容器(运行中的优先):\n' >>$TTY_OUT
local index=1
for record in "${ordered[@]}"; do
IFS=$'\t' read -r name image state <<<"$record"
printf '%d) %s(镜像:%s,状态:%s\n' "$index" "$name" "$image" "$state" >>$TTY_OUT
index=$((index + 1))
done
prompt_tty_into choice '请选择容器编号:'
[[ $choice =~ ^[0-9]+$ && $choice -ge 1 && $choice -le ${#ordered[@]} ]] || die '无效的小雅容器编号'
IFS=$'\t' read -r XIAOYA_CONTAINER image state <<<"${ordered[$((choice - 1))]}"
printf '已选择小雅容器:%s(镜像:%s,状态:%s)\n' "$XIAOYA_CONTAINER" "$image" "$state" >>$TTY_OUT
[[ $state == running ]] || printf '警告:小雅容器当前已停止;仍允许安装控制器,状态页会提示。\n' >>$TTY_OUT
fi
}
validate_xiaoya_data_dir() {
local inspect_json
[[ -d $XIAOYA_DIR && $XIAOYA_DIR = /* && $XIAOYA_DIR != / ]] || die '小雅数据目录必须是已存在的绝对目录'
inspect_json=$(docker inspect "$XIAOYA_CONTAINER" 2>/dev/null) || die "无法 inspect 小雅容器:$XIAOYA_CONTAINER"
XIAOYA_DIR=$(XIAOYA_DIR_INPUT="$XIAOYA_DIR" python3 -c '
import json, os, pathlib, sys
value = os.environ["XIAOYA_DIR_INPUT"]
path = pathlib.Path(value)
if not path.is_absolute() or not path.is_dir():
raise SystemExit("小雅数据目录不存在或不是目录")
current = pathlib.Path(path.root)
for part in path.parts[1:]:
current /= part
if current.is_symlink():
raise SystemExit("小雅数据目录及其父目录不能包含符号链接")
canonical = str(path.resolve(strict=True))
try:
payload = json.load(sys.stdin)
item = payload[0]
mounts = item.get("Mounts") or []
except (ValueError, IndexError, TypeError, AttributeError):
raise SystemExit("Docker inspect 返回无效数据")
binds = []
for mount in mounts:
if not isinstance(mount, dict) or mount.get("Type") != "bind":
continue
destination = mount.get("Destination")
source = mount.get("Source")
if destination not in ("/data", "/www/data") or not isinstance(source, str) or not source.startswith("/"):
continue
source_path = pathlib.Path(source)
if not source_path.is_dir():
continue
source_current = pathlib.Path(source_path.root)
for part in source_path.parts[1:]:
source_current /= part
if source_current.is_symlink():
raise SystemExit("Docker 挂载源不能包含符号链接")
binds.append((destination, str(source_path.resolve(strict=True))))
if not binds:
raise SystemExit("所选容器没有 /data 或 /www/data 的主机目录挂载")
unique = {}
for destination, source in binds:
unique.setdefault(destination, set()).add(source)
if "/data" in unique:
if len(unique["/data"]) != 1:
raise SystemExit("所选容器的 /data 挂载源不唯一")
selected = next(iter(unique["/data"]))
elif len(unique.get("/www/data", set())) == 1:
selected = next(iter(unique["/www/data"]))
else:
raise SystemExit("所选容器的 /www/data 挂载源不唯一")
if canonical != selected:
raise SystemExit("小雅数据目录与所选容器的 Docker 挂载源不一致")
print(canonical)
' <<<"$inspect_json") || die "小雅数据目录必须与容器 /data 或 /www/data 的 Docker 主机挂载源一致"
printf '已校验小雅数据目录与容器挂载一致:%s\n' "$XIAOYA_DIR" >>$TTY_OUT
}
if (( CONTROLLER_ONLY )); then
validate_controller_settings
[[ $CONTROLLER_BIND_ADDRESS == :: ]] && CONTROLLER_BIND_ADDRESS='[::]'
if [[ $CONTROLLER_BIND_ADDRESS == *:* && $CONTROLLER_BIND_ADDRESS != \[*\] ]]; then
CONTROLLER_BIND_ADDRESS="[$CONTROLLER_BIND_ADDRESS]"
fi
discover_xiaoya_container
validate_container_name
[[ ${XYMEDIA_INSTALL_TEST_DISCOVERY_ONLY:-0} == 1 ]] && exit 0
validate_xiaoya_data_dir
fi
if (( ! CONTROLLER_ONLY )) && [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi
if (( ! CONTROLLER_ONLY )) && [[ $PG_HOST != postgres && -z $PG_PASSWORD_FILE ]]; then die 'an external database requires --postgres-password-file'; fi
if ! [[ $API_PORT =~ ^[0-9]+$ && $WEBDAV_PORT =~ ^[0-9]+$ && $TVBOX_PORT =~ ^[0-9]+$ ]]; then die 'ports must be numeric'; fi
if (( ! CONTROLLER_ONLY )) && ! [[ $PG_PORT =~ ^[0-9]+$ && $PG_DB =~ ^[A-Za-z0-9_]+$ && $PG_USER =~ ^[A-Za-z0-9._-]+$ && $PG_HOST =~ ^[A-Za-z0-9._-]+$ ]]; then die 'invalid PostgreSQL connection values'; fi
progress '准备安装目录与数据库凭据'
mkdir -p "$INSTALL_DIR" "$INSTALL_DIR"/{state,secrets,config}
if (( ! CONTROLLER_ONLY )); then
mkdir -p "$INSTALL_DIR"/{data/postgres,data,components,releases}
write_controller_token
fi
if (( CONTROLLER_ONLY )); then
umask 077
chmod 700 "$INSTALL_DIR/secrets"
fi
if (( REMOTE_MODE )); then
umask 077
printf '%s' "$REMOTE_TOKEN" >"$INSTALL_DIR/secrets/xiaoya-controller-token"
chmod 600 "$INSTALL_DIR/secrets/xiaoya-controller-token"
fi
discover_host_addresses() {
XYMEDIA_HOST_OVERRIDE="${XYMEDIA_HOST_ADDRESSES:-}" python3 - <<'PY'
import ipaddress, os, re, socket, subprocess
def valid(value):
try:
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
if ip.is_loopback or ip.is_link_local or ip.is_unspecified or ip.is_multicast:
return None
return str(ip)
raw = os.environ.get('XYMEDIA_HOST_OVERRIDE', '')
values = re.split(r'[\r\n,]+', raw) if raw.strip() else []
if not values:
try:
route = subprocess.check_output(['ip', 'route', 'get', '1.1.1.1'], text=True, stderr=subprocess.DEVNULL)
match = re.search(r'\bsrc\s+(\S+)', route)
if match:
values.append(match.group(1))
except (OSError, subprocess.SubprocessError):
pass
try:
addr = subprocess.check_output(['ip', '-o', 'addr', 'show', 'scope', 'global'], text=True, stderr=subprocess.DEVNULL)
values.extend(re.findall(r'\binet6?\s+([^/\s]+)', addr))
except (OSError, subprocess.SubprocessError):
pass
if not values:
for family, target in ((socket.AF_INET, ('1.1.1.1', 80)), (socket.AF_INET6, ('2606:4700:4700::1111', 80, 0, 0))):
sock = socket.socket(family, socket.SOCK_DGRAM)
try:
sock.connect(target)
values.append(sock.getsockname()[0])
except OSError:
pass
finally:
sock.close()
seen = set()
for value in values:
normalized = valid(value)
if normalized and normalized not in seen:
seen.add(normalized)
print(normalized)
PY
}
HOST_ADDRESSES_INITIAL="$(discover_host_addresses || true)"
if (( CONTROLLER_ONLY )) || [[ $MENU_PROFILES == *xiaoya-control* ]]; then
if [[ ! -e "$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES" ]]; then
umask 077
printf '%s\n' "$HOST_ADDRESSES_INITIAL" >"$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES"
chmod 600 "$INSTALL_DIR/config/XYMEDIA_HOST_ADDRESSES"
fi
fi
write_probe="$INSTALL_DIR/xymediavault-write-probe-$$"
if ! touch "$write_probe" || ! rm -f "$write_probe"; then
die "安装目录不可写:$INSTALL_DIR;请使用 --install-dir 指定本机可写目录,例如 /opt/xymediavault"
fi
chmod 700 "$INSTALL_DIR/secrets"
if (( ! CONTROLLER_ONLY )) && [[ -n $PG_PASSWORD_FILE ]]; then
[[ -r $PG_PASSWORD_FILE ]] || die 'password file is not readable'
if [[ "$PG_PASSWORD_FILE" != "$INSTALL_DIR/secrets/postgres-password" ]]; then
cp "$PG_PASSWORD_FILE" "$INSTALL_DIR/secrets/xymedia-postgres-password"
PG_PASSWORD_FILE="$INSTALL_DIR/secrets/xymedia-postgres-password"
fi
elif (( ! CONTROLLER_ONLY )); then
PG_PASSWORD_FILE="$INSTALL_DIR/secrets/xymedia-postgres-password"
if [[ ! -s $PG_PASSWORD_FILE ]]; then
umask 077
if command -v openssl >/dev/null; then openssl rand -hex 32 >"$PG_PASSWORD_FILE"; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n' >"$PG_PASSWORD_FILE"; fi
fi
fi
if (( ! CONTROLLER_ONLY )); then chmod 600 "$PG_PASSWORD_FILE"; fi
if (( ! CONTROLLER_ONLY )); then
if (( ! CONTROLLER_ONLY )); then
rm -f "$INSTALL_DIR/secrets/postgres-password"
fi
fi
TEMPLATE_DIR="$INSTALL_DIR/bootstrap"
mkdir -p "$TEMPLATE_DIR"
template_file() {
local name=$1 downloaded="$TEMPLATE_DIR/$1"
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$RELEASE_BASE/$name?installer=$INSTALL_NONCE" -o "$downloaded"
printf '%s\n' "$downloaded"
}
if (( CONTROLLER_ONLY )); then
COMPOSE_TEMPLATE=$(template_file compose-controller.yaml)
else
CONFIG_TEMPLATE=$(template_file config.yaml)
COMPOSE_TEMPLATE=$(template_file compose.yaml)
DOCKERFILE_TEMPLATE=$(template_file Dockerfile.bootstrap)
COMPOSE_FUSE_TEMPLATE=$(template_file compose.fuse.yaml)
REMOUNT_FUSE_TEMPLATE=$(template_file remount-fuse.sh)
fi
umask 077
HOST_ADDRESSES_ENV="$(printf '%s' "$HOST_ADDRESSES_INITIAL" | tr '\r\n' ',,' | sed 's/,,*/,/g; s/^,//; s/,$//')"
if [[ "$HOST_ADDRESSES_ENV" == *$'\r'* || "$HOST_ADDRESSES_ENV" == *$'\n'* ]]; then die 'invalid host address value'; fi
[[ ! -L "$INSTALL_DIR/.env" ]] || die '.env must not be a symlink'
env_tmp=$(mktemp "$INSTALL_DIR/.env.tmp.XXXXXX") || die '无法创建安全的 .env 临时文件'
chmod 600 "$env_tmp"
if [[ -f "$INSTALL_DIR/.env" ]]; then cp "$INSTALL_DIR/.env" "$env_tmp"; fi
FUSE_ENABLED=$(awk -F= '$1 == "XYMEDIA_FUSE_ENABLED" {print $2; exit}' "$env_tmp" 2>/dev/null || true)
FUSE_HOST_PATH=$(awk -F= '$1 == "XYMEDIA_FUSE_HOST_PATH" {print $2; exit}' "$env_tmp" 2>/dev/null || true)
[[ $FUSE_ENABLED == true || $FUSE_ENABLED == false ]] || FUSE_ENABLED=false
python3 - "$env_tmp" "$INSTALL_DIR" "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER" "$API_PORT" "$WEBDAV_PORT" "$TVBOX_PORT" "$CONTROLLER_URL" "${XIAOYA_DIR:-$INSTALL_DIR/xiaoya}" "$XIAOYA_CONTAINER" "$HOST_ADDRESSES_ENV" "$CONTROLLER_BIND_ADDRESS" "$CONTROLLER_PORT" "$FUSE_ENABLED" "$FUSE_HOST_PATH" <<'PY'
import pathlib, sys
p=pathlib.Path(sys.argv[1])
keys=('XYMEDIA_INSTALL_DIR','XYMEDIA_POSTGRES_HOST','XYMEDIA_POSTGRES_PORT','XYMEDIA_POSTGRES_DATABASE','XYMEDIA_POSTGRES_USER','XYMEDIA_API_PORT','XYMEDIA_WEBDAV_PORT','XYMEDIA_TVBOX_PORT','XYMEDIA_XIAOYA_CONTROLLER_URL','XYMEDIA_XIAOYA_DATA_DIR','XYMEDIA_XIAOYA_CONTAINER','XYMEDIA_HOST_ADDRESSES','XYMEDIA_CONTROLLER_BIND_ADDRESS','XYMEDIA_CONTROLLER_PORT','XYMEDIA_FUSE_ENABLED','XYMEDIA_FUSE_HOST_PATH')
values=dict(zip(keys, sys.argv[2:]))
seen=set(); output=[]
for line in p.read_text().splitlines():
key=line.split('=',1)[0] if '=' in line else ''
if key in values:
output.append(key+'='+values[key]); seen.add(key)
else:
output.append(line)
output.extend(key+'='+value for key,value in values.items() if key not in seen)
p.write_text('\n'.join(output)+'\n')
PY
[[ ! -L "$INSTALL_DIR/.env" ]] || die '.env became a symlink during update'
mv -f "$env_tmp" "$INSTALL_DIR/.env"
progress '下载公开版本目录(读取 App、TMM、Title 当前版本)'
catalog_sep='?'
case "$CATALOG_URL" in *\?*) catalog_sep='&';; esac
curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "${CATALOG_URL}${catalog_sep}installer=$INSTALL_NONCE" -o "$INSTALL_DIR/catalog-v1.json"
python3 - "$INSTALL_DIR/catalog-v1.json" <<'PY' || die 'invalid public catalog'
import json, sys
catalog = json.load(open(sys.argv[1]))
assert catalog.get("schema_version") == 1
for component in ("app", "tmm", "title", "controller"):
assert isinstance(catalog.get("components", {}).get(component, {}).get("artifacts"), dict)
PY
case "$(uname -m)" in x86_64|amd64) PLATFORM=linux-amd64;; aarch64|arm64) PLATFORM=linux-arm64;; *) die "unsupported host architecture: $(uname -m)";; esac
artifact() {
python3 - "$INSTALL_DIR/catalog-v1.json" "$1" "$2" <<'PY'
import json, sys
catalog, component, platform = sys.argv[1:]
artifacts = json.load(open(catalog))["components"][component]["artifacts"]
entry = artifacts.get(platform) or artifacts.get("linux-any")
if not isinstance(entry, dict) or not isinstance(entry.get("version"), str) or not isinstance(entry.get("asset_url"), str):
raise SystemExit(1)
print(entry["version"] + "\t" + entry["asset_url"])
PY
}
download_extract() {
local component=$1
local version=$2
local url=$3
local target=$4
local archive="$INSTALL_DIR/state/$component-$version.archive"
printf ' 正在下载 %s %s%s\n' "$component" "$version" "${url##*/}"
curl --fail --show-error --location --proto '=https' --tlsv1.2 --progress-bar "$url" -o "$archive"
printf ' 正在解压 %s %s\n' "$component" "$version"
local tarball="$archive.tar"
if [[ $url == *.tar.gz || $url == *.tgz ]]; then gzip -dc "$archive" >"$tarball"; else zstd -dc "$archive" >"$tarball"; fi
python3 - "$tarball" "$target" <<'PY'
import pathlib, sys, tarfile
archive, target = sys.argv[1:]
pathlib.Path(target).mkdir(parents=True, exist_ok=True)
with tarfile.open(archive, 'r:*') as tf:
members = tf.getmembers()
for m in members:
name = m.name.lstrip('./')
if not name or name.startswith('/') or '..' in pathlib.PurePosixPath(name).parts:
raise SystemExit('archive contains traversal path')
if m.issym() or m.islnk() or not (m.isfile() or m.isdir()):
raise SystemExit('archive contains link or special file')
m.name = name
tf.extractall(target, members=members)
PY
rm -f "$tarball"
}
if (( CONTROLLER_ONLY )); then
IFS=$'\t' read -r controller_version controller_url < <(artifact controller "$PLATFORM") || die '公开目录没有当前架构的 controller 制品'
progress "下载小雅控制器 $controller_version ($PLATFORM)"
download_extract controller "$controller_version" "$controller_url" "$INSTALL_DIR/controller-stage"
edge=$(find "$INSTALL_DIR/controller-stage" -type f -name xymedia-edge -perm -u+x -print -quit)
[[ -n $edge ]] || die 'controller archive lacks executable xymedia-edge'
mkdir -p "$INSTALL_DIR/controller"
cp "$edge" "$INSTALL_DIR/controller/xymedia-edge"
chmod 755 "$INSTALL_DIR/controller/xymedia-edge"
[[ -s "$INSTALL_DIR/secrets/xiaoya-controller-token" ]] || write_controller_token
cp "$COMPOSE_TEMPLATE" "$INSTALL_DIR/compose.yaml"
progress '启动小雅控制器'
run_logged "${COMPOSE[@]}" --profile xiaoya-control --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d xiaoya-control || die '小雅控制器启动失败'
printf '小雅控制器已安装,监听地址:%s:%s;请按防火墙配置访问,控制器密钥不会显示。\n' "$CONTROLLER_BIND_ADDRESS" "$CONTROLLER_PORT"
exit 0
fi
IFS=$'\t' read -r APP_VERSION APP_URL < <(artifact app "$PLATFORM")
APP_STAGE="$INSTALL_DIR/state/app-$APP_VERSION"
progress "下载应用包 $APP_VERSION ($PLATFORM)"
download_extract app "$APP_VERSION" "$APP_URL" "$APP_STAGE"
APP_ROOT="$APP_STAGE/xymediavault-$APP_VERSION-$PLATFORM"
[[ -x $APP_ROOT/bin/xymediavault && -x $APP_ROOT/bin/xymedia-supervisor && -d $APP_ROOT/web/dist && -f $APP_ROOT/release.json ]] || die "app package $APP_VERSION lacks the required xymedia-supervisor; use a newer public app package"
if [[ -x $APP_ROOT/bin/xymedia-edge ]]; then
mkdir -p "$INSTALL_DIR/controller"
cp "$APP_ROOT/bin/xymedia-edge" "$INSTALL_DIR/controller/xymedia-edge"
chmod 755 "$INSTALL_DIR/controller/xymedia-edge"
fi
mkdir -p "$INSTALL_DIR/releases/releases"
[[ ! -e "$INSTALL_DIR/releases/releases/$APP_VERSION" ]] && mv "$APP_ROOT" "$INSTALL_DIR/releases/releases/$APP_VERSION"
ln -sfn "releases/$APP_VERSION" "$INSTALL_DIR/releases/current"
if (( ! SKIP_COMPONENTS )); then
progress '下载 TMM 与 Title 组件包'
for component in tmm title; do
platform=$([[ $component == tmm ]] && printf linux-any || printf "$PLATFORM")
IFS=$'\t' read -r version url < <(artifact "$component" "$platform")
download_extract "$component" "$version" "$url" "$INSTALL_DIR/state/$component-$version"
archive="$INSTALL_DIR/state/$component-$version.archive"
[[ -s $archive ]] || die "$component archive was not downloaded"
component_root="$INSTALL_DIR/state/$component-$version"
if [[ ! -d "$component_root/payload" ]]; then
component_root=$(find "$component_root" -mindepth 1 -maxdepth 1 -type d | head -n 1)
[[ -n $component_root && -d "$component_root/payload" ]] || die "$component archive has no payload directory"
fi
if [[ $component == tmm ]]; then
[[ -f "$component_root/payload/xymedia-api.jar" ]] || die 'TMM archive is missing payload/xymedia-api.jar'
compgen -G "$component_root/payload/lib/*" >/dev/null || die 'TMM archive is missing payload/lib files'
else
for required in payload/python/bin/python payload/service/entrypoint.py payload/service/app.py payload/service/engine.json payload/NOTICE payload/licenses/guessit-LICENSE.txt payload/licenses/flask-LICENSE.txt payload/licenses/gunicorn-LICENSE.txt; do [[ -f "$component_root/$required" ]] || die "Title archive is missing $required"; done
fi
cp "$archive" "$INSTALL_DIR/components/$component.tar.zst"
done
fi
CONTROLLER_URL="$CONTROLLER_URL" PG_HOST="$PG_HOST" PG_PORT="$PG_PORT" PG_DB="$PG_DB" PG_USER="$PG_USER" python3 - "$CONFIG_TEMPLATE" "$INSTALL_DIR/config.yaml" <<'PY'
import os, pathlib, sys
template, output = map(pathlib.Path, sys.argv[1:])
values = {
"${XYMEDIA_POSTGRES_HOST}": os.environ["PG_HOST"],
"${XYMEDIA_POSTGRES_PORT}": os.environ["PG_PORT"],
"${XYMEDIA_POSTGRES_DATABASE}": os.environ["PG_DB"],
"${XYMEDIA_POSTGRES_USER}": os.environ["PG_USER"],
"${XYMEDIA_XIAOYA_CONTROLLER_URL}": os.environ["CONTROLLER_URL"],
}
content = template.read_text()
for placeholder, value in values.items():
content = content.replace(placeholder, value)
output.write_text(content)
PY
rm -f "$INSTALL_DIR/compose.yaml" "$INSTALL_DIR/Dockerfile.bootstrap"
cp "$COMPOSE_TEMPLATE" "$DOCKERFILE_TEMPLATE" "$INSTALL_DIR/"
cp "$COMPOSE_FUSE_TEMPLATE" "$REMOUNT_FUSE_TEMPLATE" "$INSTALL_DIR/"
chmod 700 "$INSTALL_DIR/remount-fuse.sh"
FUSE_ENABLED=$(awk -F= '$1 == "XYMEDIA_FUSE_ENABLED" {print $2; exit}' "$INSTALL_DIR/.env")
COMPOSE_FUSE=()
if [[ $FUSE_ENABLED == true ]]; then COMPOSE_FUSE=(-f "$INSTALL_DIR/compose.fuse.yaml"); fi
FUSE_ENABLED="$FUSE_ENABLED" python3 - "$INSTALL_DIR/config.yaml" <<'PY'
import os, pathlib, re, sys
p = pathlib.Path(sys.argv[1])
text = p.read_text()
enabled = os.environ['FUSE_ENABLED'] == 'true'
text = re.sub(r'(?m)^( auto_mount:)\s*.*$', r'\1 ' + ('true' if enabled else 'false'), text)
text = re.sub(r'(?m)^( mount_path:)\s*.*$', r'\1 /mnt/xymediavault', text)
tmp = p.with_name(p.name + '.tmp')
tmp.write_text(text)
tmp.replace(p)
PY
if [[ -n $MENU_PROFILES ]]; then
[[ ${XYMEDIA_INSTALL_CONFIRM:-} == INSTALL_XIAOYA ]] || { confirm=$(prompt_tty '输入 INSTALL_XIAOYA 继续: '); [[ $confirm == INSTALL_XIAOYA ]] || die '未确认小雅安装'; }
for profile in $MENU_PROFILES; do run_logged "${COMPOSE[@]}" --profile "$profile" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d || die "小雅 profile 启动失败:$profile"; done
fi
progress '下载 bootstrap runtime'
if ! run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" pull app; then
die 'bootstrap runtime 下载失败;请确认能访问 git.keeper.work Container Registry'
fi
progress '启动 PostgreSQL'
if [[ $PG_HOST == postgres ]]; then
if ! run_logged "${COMPOSE[@]}" --profile local-db --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d postgres; then
die 'PostgreSQL 启动失败'
fi
fi
if [[ $PG_HOST == postgres ]]; then
ready=0
for elapsed in $(seq 0 298 2); do
container_id=$("${COMPOSE[@]}" --profile local-db --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" ps -q postgres 2>>"$INSTALL_LOG" || true)
health=
if [[ -n $container_id ]]; then
health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id" 2>>"$INSTALL_LOG" || true)
fi
if [[ $health == healthy ]]; then
ready=1
break
fi
printf '\033[2K\r[%d/%d] 等待 PostgreSQL 就绪(%s 秒)' "$STEP" "$TOTAL_STEPS" "$elapsed"
sleep 2
done
printf '\n'
if [[ $ready != 1 ]]; then
printf '\n--- PostgreSQL 容器日志 ---\n' >>"$INSTALL_LOG"
"${COMPOSE[@]}" --profile local-db --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" logs postgres >>"$INSTALL_LOG" 2>&1 || true
die 'PostgreSQL 未在限定时间内就绪'
fi
fi
progress '初始化数据库结构'
if (( EXISTING_DB )); then
export XYMEDIA_EXTERNAL_MIGRATION_CONFIRM=YES
if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm --no-deps app-migrate migrate --config /app/config.yaml; then
die '已有数据库迁移失败'
fi
else
if ! run_visible_logged "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm --no-deps app-migrate migrate --new --config /app/config.yaml; then
die '数据库初始化失败'
fi
fi
progress '启动 XyMediaVault 服务'
if ! run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d app; then
die 'XyMediaVault 启动失败'
fi
for elapsed in $(seq 0 118 2); do
if curl --fail --silent "http://127.0.0.1:$API_PORT/api/health" >/dev/null; then
cp "$INSTALL_LOG" "$INSTALL_DIR/install.log" 2>/dev/null || true
printf '\033[2K\r[%d/%d] 安装完成:XyMediaVault 已就绪\n' "$TOTAL_STEPS" "$TOTAL_STEPS"
printf '管理地址:http://127.0.0.1:%s\n安装日志:%s\n' "$API_PORT" "$INSTALL_LOG"
exit 0
fi
printf '\033[2K\r[%d/%d] 等待 XyMediaVault 健康检查(%s 秒)' "$STEP" "$TOTAL_STEPS" "$elapsed"
sleep 2
done
printf '\n'
die 'XyMediaVault 未在限定时间内通过健康检查'