748 lines
40 KiB
Bash
748 lines
40 KiB
Bash
#!/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.xymediavault(ID=%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
|
||
}
|
||
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) 状态/诊断/维护\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;;
|
||
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 '使用指定的小雅容器:%s(Docker 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
|
||
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 '初始化数据库结构'
|
||
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 --name "xymedia-app-migrate-${INSTALL_NONCE}" --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 --name "xymedia-app-migrate-${INSTALL_NONCE}" --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 未在限定时间内通过健康检查'
|