Files
xymediavault-releases/install.sh
T

823 lines
43 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:-xymedia-xiaoya}
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 '该操作将停止并重新启动应用,输入 1 继续,其他内容取消: ')
[[ $confirmation == 1 ]] || die '已取消挂载操作'
}
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 '挂载身份:外部或未知(ID=%s 来源=%s 类型=%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 '应用健康状态:%s\n' "$health" >>$TTY_OUT
}
print_controller_info() {
local env_file="$INSTALL_DIR/.env" token_file address port token
[[ -f $env_file ]] || { printf '未找到安装配置,请先完成安装。\n' >>$TTY_OUT; return 1; }
address=$(awk -F= '$1 == "XYMEDIA_CONTROLLER_BIND_ADDRESS" {print $2; exit}' "$env_file")
port=$(awk -F= '$1 == "XYMEDIA_CONTROLLER_PORT" {print $2; exit}' "$env_file")
token_file="$INSTALL_DIR/secrets/xiaoya-controller-token"
token=$(cat "$token_file" 2>/dev/null || true)
[[ -n $address ]] || address='0.0.0.0'
[[ -n $port ]] || port=19090
[[ -n $token ]] || { printf '未找到控制器密钥,请先完成控制器安装。\n' >>$TTY_OUT; return 1; }
printf '小雅控制器地址:http://%s:%s\n小雅控制器密钥:%s\n' "$address" "$port" "$token" >>$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 "未知选项:$1";;
esac
done
persist_installer_log() {
[[ -n ${INSTALL_LOG:-} && -f $INSTALL_LOG ]] || return 0
[[ -n ${INSTALL_DIR:-} && $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || return 0
mkdir -p "$INSTALL_DIR" 2>/dev/null || return 0
[[ ! -L "$INSTALL_DIR/install.log" ]] || return 0
cp "$INSTALL_LOG" "$INSTALL_DIR/install.log" 2>/dev/null || true
chmod 600 "$INSTALL_DIR/install.log" 2>/dev/null || true
}
die() { printf '\n\033[31mxymediavault installer: %s\033[0m\n' "$*" >&2; persist_installer_log; [[ -f ${INSTALL_DIR:-}/install.log ]] && printf '详情日志:%s/install.log\n' "$INSTALL_DIR" >&2 || { [[ -n $INSTALL_LOG ]] && printf '详情日志:%s\n' "$INSTALL_LOG" >&2; }; exit 1; }
INSTALL_TMP_DIR=
cleanup_installer_tmp() { persist_installer_log; [[ -z ${INSTALL_TMP_DIR:-} ]] || rm -rf -- "$INSTALL_TMP_DIR"; }
trap cleanup_installer_tmp EXIT
init_installer_log() {
local tmp_parent=${TMPDIR:-/tmp} mode
[[ -d $tmp_parent && ! -L $tmp_parent ]] || die '临时目录必须存在且不能是符号链接目录'
if (( EUID == 0 )); then
mode=$(stat -c '%a' "$tmp_parent")
[[ $(stat -c '%u' "$tmp_parent") -eq 0 ]] || die '以管理员权限运行时,临时目录必须由 root 所有'
[[ $((mode & 022)) -eq 0 || $((mode & 1000)) -ne 0 ]] || die '临时目录不能允许组用户或其他用户写入,除非启用了粘滞位'
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_DIR=$(INSTALL_PATH="$INSTALL_DIR" python3 - <<'PY'
import os, pathlib
p = pathlib.Path(os.environ['INSTALL_PATH'])
print(p.resolve(strict=False))
PY
) || die '无法规范化安装目录路径'
[[ $INSTALL_DIR != / ]] || die '安装目录不能是根目录'
if [[ -e $INSTALL_DIR ]]; then
[[ -d $INSTALL_DIR && ! -L $INSTALL_DIR ]] || die '安装目录必须是普通目录'
fi
[[ ! -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 '交互菜单需要终端,请在终端中运行安装器'; printf '%s' "$1" >>$TTY_OUT; read_tty value || die '无法读取终端输入'; printf '%s' "$value"; }
prompt_tty_into() { [[ -r $TTY_IN && -w $TTY_OUT ]] || die '交互菜单需要终端,请在终端中运行安装器'; printf '%s' "$2" >>$TTY_OUT; read_tty "$1" || die '无法读取终端输入'; }
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 '请选择:1) 继续使用此目录 0) 返回:' >>$TTY_OUT
read_tty choice || die '无法读取终端输入'
[[ $choice == 1 || $choice == 0 ]] || die '无效选择,已停止以保护已有数据'
[[ $choice != 0 ]] || 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 '没有可用终端:安装菜单不能通过非交互管道运行'
while :; do
printf '\nXyMediaVault 安装器\n1) 仅安装管理平台\n2) 安装管理平台和本机小雅\n3) 安装管理平台并连接远程小雅控制器\n4) 仅安装小雅控制器\n5) 状态/诊断/维护\n6) 查看小雅控制器地址和密钥\n0) 退出\n选择:' >>$TTY_OUT
read_tty choice || die '无法读取终端输入'
case "$choice" in
1) return 0;;
2) ensure_xiaoya_dir; printf '小雅目录已准备:%s;将安装小雅和小雅控制器。\n' "$XIAOYA_DIR" >>$TTY_OUT; write_controller_token; CONTROLLER_URL='http://xymedia-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 '无法读取终端输入'
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;;
6) print_controller_info || true;;
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 '安装目录必须是绝对路径且不能是根目录'
guard_existing_install_tree
init_installer_log
command -v docker >/dev/null || die '需要安装 Docker'
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'; fi
docker info >/dev/null 2>&1 || die '无法连接 Docker,请确认 Docker 已启动'
command -v curl >/dev/null || die '需要安装 curl'
command -v zstd >/dev/null || die '需要安装 zstd,用于解压发布包'
command -v python3 >/dev/null || die '需要安装 python3,用于安全解压发布包'
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"
}
install_controller_binary() {
local source=$1 target="$INSTALL_DIR/controller/xymedia-edge" staged
mkdir -p "$INSTALL_DIR/controller"
staged=$(mktemp "$INSTALL_DIR/controller/.xymedia-edge.XXXXXX") || die '无法准备控制器更新文件'
chmod 600 "$staged"
if ! cp "$source" "$staged"; then rm -f "$staged"; die '控制器文件复制失败'; fi
chmod 755 "$staged"
if [[ -e $target ]] && docker container inspect xymedia-controller >/dev/null 2>&1; then
run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" stop xiaoya-control || { rm -f "$staged"; die '无法停止旧的小雅控制器'; }
fi
[[ ! -L $target ]] || { rm -f "$staged"; die '控制器文件不能是符号链接'; }
if ! mv -f "$staged" "$target"; then rm -f "$staged"; die '控制器文件替换失败'; fi
chmod 755 "$target"
}
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'
install_controller_binary "$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
install_controller_binary "$APP_ROOT/bin/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" XIAOYA_CONTAINER="$XIAOYA_CONTAINER" 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"],
"${XYMEDIA_XIAOYA_CONTAINER}": os.environ["XIAOYA_CONTAINER"],
}
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 '安装小雅将创建并启动相关容器,输入 1 继续,其他内容取消: '); [[ $confirm == 1 ]] || 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
if [[ $MENU_PROFILES == *xiaoya-control* ]]; then
controller_state=$(docker inspect --format '{{.State.Status}}' xymedia-controller 2>>"$INSTALL_LOG" || true)
[[ $controller_state == running ]] || die '本机小雅控制器未正常运行'
printf '本机小雅控制器已自动配置:xymedia-controller:19090;密钥已自动写入,不显示。\n'
fi
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
wait_for_postgres() {
local started_at elapsed container_id health
local previous_int previous_term
postgres_wait_interrupted=0
started_at=$(date +%s)
previous_int=$(trap -p INT || true)
previous_term=$(trap -p TERM || true)
trap 'postgres_wait_interrupted=1' INT TERM
while :; do
if (( postgres_wait_interrupted )); then
printf '\n已收到中断信号,正在停止 PostgreSQL 等待。\n'
printf '%s\n' 'PostgreSQL wait interrupted by signal' >>"$INSTALL_LOG"
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
[[ -n $previous_int ]] && eval "$previous_int" || trap - INT
[[ -n $previous_term ]] && eval "$previous_term" || trap - TERM
return 130
fi
elapsed=$(( $(date +%s) - started_at ))
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
printf '\033[2K\r[%d/%d] PostgreSQL 已就绪(%s 秒)\n' "$STEP" "$TOTAL_STEPS" "$elapsed"
[[ -n $previous_int ]] && eval "$previous_int" || trap - INT
[[ -n $previous_term ]] && eval "$previous_term" || trap - TERM
return 0
fi
printf '\033[2K\r[%d/%d] 等待 PostgreSQL 就绪(已用 %s 秒,状态:%s' "$STEP" "$TOTAL_STEPS" "$elapsed" "${health:-unknown}"
printf 'PostgreSQL status: elapsed=%s health=%s\n' "$elapsed" "${health:-unknown}" >>"$INSTALL_LOG"
sleep 2
done
}
wait_for_postgres || die 'PostgreSQL 等待被中断,安装未完成'
fi
progress '初始化数据库结构'
printf '正在初始化数据库,请稍候...\n'
run_migration() {
local migration_log migration_rc
migration_log=$(mktemp "$INSTALL_TMP_DIR/migration.XXXXXX.log") || die '无法创建数据库迁移日志'
chmod 600 "$migration_log"
if (( EXISTING_DB )); then
export XYMEDIA_EXTERNAL_MIGRATION_CONFIRM=YES
if "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm -T --no-deps app-migrate migrate --config /app/config.yaml >"$migration_log" 2>&1; then
migration_rc=0
else
migration_rc=$?
fi
else
if "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" run --rm -T --no-deps app-migrate migrate --new --config /app/config.yaml >"$migration_log" 2>&1; then
migration_rc=0
else
migration_rc=$?
fi
fi
cat "$migration_log"
cat "$migration_log" >>"$INSTALL_LOG"
printf '数据库迁移退出码:%s\n' "$migration_rc"
printf '数据库迁移退出码:%s\n' "$migration_rc" >>"$INSTALL_LOG"
return "$migration_rc"
}
set +e
run_migration
migration_rc=$?
set -e
if [[ $migration_rc -ne 0 ]]; then
persist_installer_log
if (( EXISTING_DB )); then
die "已有数据库迁移失败(退出码:$migration_rc"
fi
die "数据库初始化失败(退出码:$migration_rc"
fi
printf '数据库初始化完成\n'
printf '数据库初始化完成\n' >>"$INSTALL_LOG"
progress '启动 XyMediaVault 服务'
set +e
run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d app
app_start_rc=$?
set -e
if [[ $app_start_rc -ne 0 ]]; then
persist_installer_log
die "XyMediaVault 启动失败(退出码:$app_start_rc"
fi
wait_for_app_health() {
local started_at elapsed app_wait_interrupted=0 previous_int previous_term
started_at=$(date +%s)
previous_int=$(trap -p INT || true)
previous_term=$(trap -p TERM || true)
trap 'app_wait_interrupted=1' INT TERM
while :; do
if (( app_wait_interrupted )); then
printf '\n已收到中断信号,正在停止应用健康检查。\n'
printf '%s\n' 'XyMediaVault health wait interrupted by signal' >>"$INSTALL_LOG"
printf '\n--- XyMediaVault 容器日志 ---\n' >>"$INSTALL_LOG"
"${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" logs app >>"$INSTALL_LOG" 2>&1 || true
[[ -n $previous_int ]] && eval "$previous_int" || trap - INT
[[ -n $previous_term ]] && eval "$previous_term" || trap - TERM
return 130
fi
elapsed=$(( $(date +%s) - started_at ))
if curl --fail --silent "http://127.0.0.1:$API_PORT/api/health" >/dev/null; then
[[ -n $previous_int ]] && eval "$previous_int" || trap - INT
[[ -n $previous_term ]] && eval "$previous_term" || trap - TERM
return 0
fi
printf '\033[2K\r[%d/%d] 等待 XyMediaVault 健康检查(已用 %s 秒)' "$STEP" "$TOTAL_STEPS" "$elapsed"
printf 'XyMediaVault health status: elapsed=%s\n' "$elapsed" >>"$INSTALL_LOG"
sleep 2
done
}
wait_for_app_health || die 'XyMediaVault 健康检查被中断,安装未完成'
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' "$API_PORT"
if [[ $MENU_PROFILES == 'xiaoya xiaoya-control' ]]; then
print_controller_info
fi
printf '安装日志:%s/install.log\n' "$INSTALL_DIR"
exit 0