Files
xymediavault-releases/install.sh
T

1508 lines
80 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=${XYMEDIA_POSTGRES_HOST:-}
PG_PORT=${XYMEDIA_POSTGRES_PUBLIC_PORT:-5432}
PG_DB=xymedia
PG_USER=${XYMEDIA_POSTGRES_USER:-xymedia_app}
PG_PASSWORD_FILE=
INSTALL_EXISTING=0
XYMEDIA_POSTGRES_PUBLIC_PORT=${XYMEDIA_POSTGRES_PUBLIC_PORT:-}
PG_HOST_ENV_EXPLICIT=${XYMEDIA_POSTGRES_HOST:+1}
SKIP_COMPONENTS=0
EXISTING_DB=0
LOCAL_DB=0
PG_HOST_CLI=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}
OPTION2_XIAOYA_PENDING=0
XIAOYA_FOUND=0
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=
CLEANUP_CHOICE=
XYMEDIA_INSTANCE_ID=${XYMEDIA_INSTANCE_ID:-}
cleanup_interrupted=0
cleanup_journal=
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_HOST_ADDRESSES" {print $2; exit}' "$env_file" | cut -d, -f1)
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=$(awk -F= '$1 == "XYMEDIA_CONTROLLER_BIND_ADDRESS" {print $2; exit}' "$env_file")
[[ -n $address && $address != 0.0.0.0 && $address != :: ]] || address='127.0.0.1'
[[ -n $port ]] || port=19090
[[ -n $token ]] || { printf '未找到控制器密钥,请先完成控制器安装。\n' >>$TTY_OUT; return 1; }
printf '小雅控制器地址:http://%s:%s\n小雅控制器密钥:%s\n' "$address" "$port" "$token" >>$TTY_OUT
}
print_database_info() {
local env_file="$INSTALL_DIR/.env" host port db user password_file password local_db
[[ -f $env_file ]] || { printf '未找到安装配置,请先完成安装。\n' >>$TTY_OUT; return 1; }
host=$(read_install_env XYMEDIA_POSTGRES_HOST)
port=$(read_install_env XYMEDIA_POSTGRES_PORT)
db=$(read_install_env XYMEDIA_POSTGRES_DATABASE)
user=$(read_install_env XYMEDIA_POSTGRES_USER)
if [[ $host == *:* && $host != \[*\] ]]; then host="[$host]"; fi
printf 'PostgreSQL 地址:%s:%s\nPostgreSQL 数据库:%s\nPostgreSQL 用户:%s\n' "$host" "$port" "$db" "$user" >>$TTY_OUT
password_file="$INSTALL_DIR/secrets/xymedia-postgres-password"
local_db=$(read_install_env XYMEDIA_POSTGRES_LOCAL_DB)
if [[ $local_db == 1 || $host == postgres || $LOCAL_DB == 1 ]] && [[ -s $password_file ]]; then
password=$(cat "$password_file")
printf 'PostgreSQL 密码:%s\n' "$password" >>$TTY_OUT
else
printf 'PostgreSQL 密码:外部数据库密码由用户自行保管,不在安装器显示。\n' >>$TTY_OUT
fi
}
validate_cleanup_root() {
[[ $INSTALL_DIR = /* && $INSTALL_DIR != / && -d $INSTALL_DIR && ! -L $INSTALL_DIR ]] || die '清理要求安装目录是已存在的非符号链接绝对目录'
INSTALL_PATH="$INSTALL_DIR" python3 - <<'PY'
import os, pathlib
p = pathlib.Path(os.environ['INSTALL_PATH'])
current = pathlib.Path(p.root)
for part in p.parts[1:]:
current /= part
info = current.lstat()
if info.st_uid != 0 or info.st_mode & 0o022 or current.is_symlink(): raise SystemExit(1)
if p.resolve(strict=True) == pathlib.Path('/'):
raise SystemExit(1)
PY
[[ -f "$INSTALL_DIR/.env" && ! -L "$INSTALL_DIR/.env" ]] || die '清理要求安装目录存在非符号链接 .env;无法确认实例所有权'
local recorded_dir recorded_id
recorded_dir=$(cleanup_read_env XYMEDIA_INSTALL_DIR)
recorded_id=$(cleanup_read_env XYMEDIA_INSTANCE_ID)
[[ $recorded_dir == "$INSTALL_DIR" ]] || die '安装配置中的实例目录与当前目录不一致,拒绝清理'
[[ $recorded_id =~ ^[a-z0-9_-]+$ ]] || die '安装配置缺少有效 XYMEDIA_INSTANCE_ID,拒绝清理旧实例'
XYMEDIA_INSTANCE_ID=$recorded_id
}
cleanup_read_env() { awk -F= -v wanted="$1" '$1 == wanted {sub(/^[^=]*=/, ""); print; exit}' "$INSTALL_DIR/.env" 2>/dev/null || true; }
cleanup_canonical_child() {
[[ -n $1 && -d $1 && ! -L $1 ]] || return 1
CLEANUP_PATH_INPUT="$1" CLEANUP_ROOT="$2" python3 - <<'PY'
import os, pathlib
p = pathlib.Path(os.environ['CLEANUP_PATH_INPUT'])
root = pathlib.Path(os.environ['CLEANUP_ROOT']).resolve(strict=True)
if not p.is_absolute() or any((p.root.joinpath(*p.parts[1:i+1])).is_symlink() for i in range(len(p.parts)-1)): raise SystemExit(1)
canonical = p.resolve(strict=True)
try: canonical.relative_to(root)
except ValueError: raise SystemExit(1)
if canonical == root: raise SystemExit(1)
print(canonical)
PY
}
cleanup_unmount_owned_fuse() {
local path mount_id source fstype
path=$(cleanup_read_env XYMEDIA_FUSE_HOST_PATH)
[[ -n $path ]] || return 0
path=$(cleanup_canonical_child "$path" /) || die 'FUSE 路径无效,已停止清理'
command -v mountpoint >/dev/null 2>&1 && command -v findmnt >/dev/null 2>&1 || die '无法验证 FUSE 挂载身份,已停止清理'
mountpoint -q "$path" 2>/dev/null || return 0
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)
[[ -n $mount_id && $source == xymediavault && ( $fstype == fuse.xymediavault || $fstype == fuse ) ]] || die 'FUSE 路径不是可确认的 XyMediaVault 挂载,已停止清理'
if command -v fusermount3 >/dev/null 2>&1; then fusermount3 -u "$path" || die '受管 FUSE 卸载失败,未删除任何数据'; elif command -v fusermount >/dev/null 2>&1; then fusermount -u "$path" || die '受管 FUSE 卸载失败,未删除任何数据'; else umount "$path" || die '缺少安全卸载命令,未删除任何数据'; fi
mountpoint -q "$path" 2>/dev/null && die 'FUSE 卸载后仍处于挂载状态,未删除任何数据' || true
}
cleanup_inspect_ok() {
local expected=$1 inspected=$2 require_mount=$3
INSPECTED_JSON="$inspected" EXPECTED_NAME="$expected" EXPECTED_ID="$XYMEDIA_INSTANCE_ID" EXPECTED_DIR="$INSTALL_DIR" REQUIRE_MOUNT="$require_mount" python3 - <<'PY'
import json, os, pathlib
try:
item = json.loads(os.environ['INSPECTED_JSON'])[0]
labels = (item.get('Config') or {}).get('Labels') or {}
if (item.get('Name') or '').lstrip('/') != os.environ['EXPECTED_NAME']: raise SystemExit(1)
if labels.get('com.xymediavault.managed') != 'true': raise SystemExit(1)
if labels.get('com.xymediavault.instance_id') != os.environ['EXPECTED_ID']: raise SystemExit(1)
if labels.get('com.xymediavault.install_dir') != os.environ['EXPECTED_DIR']: raise SystemExit(1)
if os.environ['REQUIRE_MOUNT'] == '1':
data = pathlib.Path(os.environ['EXPECTED_DATA']).resolve(strict=True)
matched = False
for mount in item.get('Mounts') or []:
if mount.get('Type') == 'bind' and mount.get('Destination') in ('/data', '/www/data'):
source = pathlib.Path(mount.get('Source', '')).resolve(strict=True)
if source == data: matched = True
if not matched: raise SystemExit(1)
except (ValueError, IndexError, TypeError, AttributeError, OSError): raise SystemExit(1)
PY
}
cleanup_container() {
local name=$1 require_mount=${2:-0} expected_data=${3:-} inspected
inspected=$(docker container inspect "$name" 2>/dev/null) || return 0
[[ -n $inspected ]] || return 0
EXPECTED_DATA="$expected_data" cleanup_inspect_ok "$name" "$inspected" "$require_mount" || die "容器 $name 缺少当前实例所有权标签或挂载校验失败,拒绝删除"
docker rm -f -- "$name" >>"$INSTALL_LOG" 2>&1 || die "删除容器失败:$name(详情见日志)"
}
cleanup_all() {
local include_xiaoya=$1 marker data_path canonical_data name managed confirm inspected
validate_cleanup_root
if [[ -e "$INSTALL_DIR/.cleanup.journal" ]]; then
[[ -f "$INSTALL_DIR/.cleanup.journal" && ! -L "$INSTALL_DIR/.cleanup.journal" ]] || die '清理日志不是安全的普通文件,拒绝继续'
[[ $(stat -c '%u' "$INSTALL_DIR/.cleanup.journal") -eq 0 && $(( $(stat -c '%a' "$INSTALL_DIR/.cleanup.journal") & 077 )) -eq 0 ]] || die '清理日志权限不安全,拒绝继续'
printf '检测到未完成的清理日志:%s\n' "$INSTALL_DIR/.cleanup.journal" >>$TTY_OUT
confirm=$(prompt_tty '继续未完成清理请输入数字 1,输入 0 取消: ')
[[ $confirm == 1 ]] || { printf '已取消恢复清理。\n' >>$TTY_OUT; return 0; }
fi
marker=$(cleanup_read_env XYMEDIA_XIAOYA_MANAGED); data_path=$(cleanup_read_env XYMEDIA_XIAOYA_DATA_DIR); canonical_data=
if [[ $include_xiaoya == 1 && $marker != 1 ]]; then
printf '当前安装未记录本机小雅由安装器创建,选项 2 不可用;未删除任何内容。\n' >>$TTY_OUT
return 0
fi
if [[ $include_xiaoya == 1 && $marker == 1 ]]; then canonical_data=$(cleanup_canonical_child "$data_path" "$INSTALL_DIR") || die '小雅数据未被标记为安装目录内的安全路径,已停止清理'; fi
local -a containers=(xymedia-app xymedia-postgres xymedia-controller xymedia-aliyuntvtoken)
if [[ $include_xiaoya == 1 && $marker == 1 ]]; then containers+=(xymedia-xiaoya); fi
local -a migration_ids=() migration_names=()
while IFS=$'\t' read -r name id; do
[[ -n $id ]] || continue
inspected=$(docker container inspect "$id" 2>/dev/null) || continue
if [[ $name =~ ^xymedia-app-migrate-[0-9]+$ ]] && cleanup_inspect_ok "$name" "$inspected" 0; then migration_names+=("$name"); migration_ids+=("$id"); fi
done < <(docker ps -a --filter label=com.xymediavault.managed=true --format '{{.Names}}\t{{.ID}}')
containers+=("${migration_names[@]}")
for name in "${containers[@]}"; do
inspected=$(docker container inspect "$name" 2>/dev/null) || continue
cleanup_inspect_ok "$name" "$inspected" "$([[ $name == xymedia-xiaoya ]] && printf 1 || printf 0)" || die "容器 $name 缺少当前实例所有权标签,拒绝清理;未删除容器"
done
printf '\n将删除以下精确容器(不存在的会跳过):\n' >>$TTY_OUT; printf ' %s\n' "${containers[@]}" >>$TTY_OUT
printf '将删除安装器管理数据:%s/data %s/secrets %s/components %s/releases %s/controller %s/state\n' "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" "$INSTALL_DIR" >>$TTY_OUT
printf '以及受管配置、Compose、日志和状态文件;其他文件、外部小雅数据和未标记小雅容器保留。\n' >>$TTY_OUT
[[ -z $canonical_data ]] || printf '小雅数据(已确认在安装目录内):%s\n' "$canonical_data" >>$TTY_OUT
confirm=$(prompt_tty '确认删除以上内容?请输入数字 1,其他内容取消: '); [[ $confirm == 1 ]] || { printf '已取消清理。\n' >>$TTY_OUT; return 0; }
[[ ! -L "$INSTALL_DIR/.cleanup.journal" ]] || die '清理日志是符号链接,拒绝清理'
mkdir "$INSTALL_DIR/.install.lock" 2>/dev/null || die '已有安装或维护操作正在进行,拒绝清理'
cleanup_lock_dir="$INSTALL_DIR/.install.lock"
cleanup_journal="$INSTALL_DIR/.cleanup.journal"
(umask 077; printf 'stage=confirmed\ninstance_id=%s\n' "$XYMEDIA_INSTANCE_ID" >"$cleanup_journal") || die '无法创建清理日志,未删除任何内容'
cleanup_exit() { local status=$?; rmdir "${cleanup_lock_dir:-}" 2>/dev/null || true; if (( cleanup_interrupted )); then (umask 077; printf 'stage=interrupted\ninstance_id=%s\n' "$XYMEDIA_INSTANCE_ID" >"$cleanup_journal"); printf '清理被中断,已保留恢复日志:%s\n' "$cleanup_journal" >>$TTY_OUT; fi; persist_installer_log; cleanup_installer_tmp; exit "$status"; }
cleanup_trap() { cleanup_interrupted=1; exit 130; }
trap cleanup_exit EXIT
trap cleanup_trap INT TERM
cleanup_checkpoint() { if (( cleanup_interrupted )); then printf 'stage=%s\n' "$1" >"$cleanup_journal"; return 1; fi; printf 'stage=%s\n' "$1" >"$cleanup_journal"; return 0; }
cleanup_interrupted=0
cleanup_unmount_owned_fuse
for managed in xymedia-app xymedia-postgres xymedia-controller xymedia-aliyuntvtoken xymedia-xiaoya; do [[ " ${containers[*]} " == *" $managed "* ]] || continue; cleanup_checkpoint "before-$managed" || exit 130; cleanup_container "$managed" "$([[ $managed == xymedia-xiaoya ]] && printf 1 || printf 0)" "$canonical_data"; done
local index; for index in "${!migration_ids[@]}"; do cleanup_checkpoint "before-migration-${migration_names[$index]}" || exit 130; inspected=$(docker container inspect "${migration_ids[$index]}" 2>/dev/null) || continue; cleanup_inspect_ok "${migration_names[$index]}" "$inspected" 0 || continue; docker rm -f -- "${migration_ids[$index]}" >>"$INSTALL_LOG" 2>&1 || die "删除迁移容器失败(详情见日志)"; done
if [[ -n $canonical_data ]]; then
[[ ! -L "$canonical_data" ]] || die '小雅数据路径是符号链接,已停止清理'
rm -rf -- "$canonical_data"
fi
for managed in data secrets components releases controller state bootstrap; do [[ ! -L "$INSTALL_DIR/$managed" ]] || die "受管路径是符号链接,已停止清理"; rm -rf -- "$INSTALL_DIR/$managed"; done
for managed in .env config.yaml compose.yaml compose.fuse.yaml Dockerfile.bootstrap remount-fuse.sh catalog-v1.json install.log; do [[ ! -L "$INSTALL_DIR/$managed" ]] || die "受管文件是符号链接,已停止清理"; rm -f -- "$INSTALL_DIR/$managed"; done
rm -f -- "$cleanup_journal"; cleanup_journal=; printf '清理完成;安装目录中的其他文件和外部小雅数据已保留。\n' >>$TTY_OUT
}
while (($#)); do
case "$1" in
--install-dir) INSTALL_DIR=${2:?missing value}; shift 2;;
--postgres-host) PG_HOST=${2:?missing value}; PG_HOST_CLI=1; 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; }
component_cache_test_mode() {
local catalog=${XYMEDIA_INSTALL_TEST_CATALOG:?} component platform version url sha archive marker_version marker_sha stage downloaded tarball actual
mkdir -p "$INSTALL_DIR/components" "$INSTALL_DIR/state"
for component in tmm title; do
if [[ $component == tmm ]]; then platform=linux-any
elif [[ -n ${XYMEDIA_INSTALL_TEST_PLATFORM:-} ]]; then platform=$XYMEDIA_INSTALL_TEST_PLATFORM
else case "$(uname -m)" in x86_64|amd64) platform=linux-amd64;; aarch64|arm64) platform=linux-arm64;; *) die "unsupported host architecture: $(uname -m)";; esac
fi
IFS=$'\t' read -r version url sha < <(python3 - "$catalog" "$component" "$platform" <<'PY'
import json, re, sys
from urllib.parse import urlsplit
c = json.load(open(sys.argv[1])); name, platform = sys.argv[2:]
e = c['components'][name]['artifacts'].get(platform) or c['components'][name]['artifacts'].get('linux-any')
if not re.fullmatch(r'[0-9a-fA-F]{64}', e.get('sha256', '')): raise SystemExit(1)
url = e.get('asset_url', '')
parsed = urlsplit(url)
try: port = parsed.port
except ValueError: raise SystemExit(1)
if (parsed.scheme != 'https' or parsed.netloc.lower() != 'git.keeper.work' or
parsed.username is not None or parsed.password is not None or port is not None or
parsed.query or parsed.fragment or '%' in (parsed.path or '') or
re.fullmatch(r'/api/packages/[^/]+/generic/[^/]+/[A-Za-z0-9][A-Za-z0-9.-]*-[0-9a-fA-F]{12}(?:-[a-z0-9][a-z0-9-]{5,80})?/[^/]+', parsed.path or '') is None):
raise SystemExit(1)
print(e['version'], e['asset_url'], e['sha256'].lower(), sep='\t')
PY
) || die '公开目录缺少带 SHA-256 的组件制品'
archive="$INSTALL_DIR/components/$component.tar.zst"; marker_version="$INSTALL_DIR/components/$component.version"; marker_sha="$INSTALL_DIR/components/$component.sha256"
if [[ -s $archive && -f $marker_version && -f $marker_sha && $(<"$marker_version") == "$version" && $(<"$marker_sha") == "$sha" && $(sha256sum "$archive" | cut -d' ' -f1) == "$sha" ]]; then
printf '%s %s 已安装且完整,跳过下载\n' "$([[ $component == tmm ]] && printf TMM || printf Title)" "$version"; continue
fi
stage=$(mktemp -d "$INSTALL_DIR/state/.$component.XXXXXX")
downloaded="$stage/$component.tar.zst"; tarball="$stage/$component.tar"
curl --connect-timeout 10 --max-time 300 --fail --silent --show-error --location --proto '=https' --tlsv1.2 -o "$downloaded" "$url" || die "$component 下载失败"
actual=$(sha256sum "$downloaded" | cut -d' ' -f1); [[ $actual == "$sha" ]] || die "$component SHA256 校验失败"
zstd -dc "$downloaded" >"$tarball" || die "$component 解压失败"
python3 - "$tarball" "$stage/root" <<'PY'
import pathlib, sys, tarfile
archive, target = sys.argv[1:]; pathlib.Path(target).mkdir()
with tarfile.open(archive) as tf:
members = tf.getmembers()
for member in members:
name = member.name.lstrip('./')
if not name or name.startswith('/') or '..' in pathlib.PurePosixPath(name).parts or member.issym() or member.islnk() or not (member.isfile() or member.isdir()): raise SystemExit(1)
member.name = name
try:
tf.extractall(target, members=members, filter='data')
except TypeError:
tf.extractall(target, members=members)
PY
cp "$downloaded" "$archive.tmp"; printf '%s\n' "$version" >"$marker_version.tmp"; printf '%s\n' "$sha" >"$marker_sha.tmp"
mv -f "$archive.tmp" "$archive"; mv -f "$marker_version.tmp" "$marker_version"; mv -f "$marker_sha.tmp" "$marker_sha"; rm -rf "$stage"
done
exit 0
}
if [[ ${XYMEDIA_INSTALL_TEST_COMPONENT_CACHE_ONLY:-0} == 1 ]]; then
INSTALL_DIR=${XYMEDIA_INSTALL_DIR:?XYMEDIA_INSTALL_DIR is required}; component_cache_test_mode
fi
INSTALL_TMP_DIR=
RELEASE_ROLLBACK_ACTIVE=0
RELEASE_TARGET=
RELEASE_BACKUP=
cleanup_installer_tmp() {
if (( RELEASE_ROLLBACK_ACTIVE )) && [[ -n $RELEASE_BACKUP && -d $RELEASE_BACKUP ]]; then
rm -rf -- "$RELEASE_TARGET" 2>/dev/null || true
mv -- "$RELEASE_BACKUP" "$RELEASE_TARGET" 2>/dev/null || true
ln -sfn "releases/$(basename "$RELEASE_TARGET")" "$INSTALL_DIR/releases/current" 2>/dev/null || true
printf '%s\n' '应用 release 失败,已尝试恢复旧 release' >>"${INSTALL_LOG:-/dev/null}"
"${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" stop app >/dev/null 2>&1 || true
"${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d app >/dev/null 2>&1 || true
fi
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'
}
ensure_instance_id() {
[[ -n "$XYMEDIA_INSTANCE_ID" ]] || XYMEDIA_INSTANCE_ID=$(awk -F= '$1 == "XYMEDIA_INSTANCE_ID" {print $2; exit}' "$INSTALL_DIR/.env" 2>/dev/null || true)
if [[ -z "$XYMEDIA_INSTANCE_ID" ]]; then
XYMEDIA_INSTANCE_ID=$(od -An -tx1 -N8 /dev/urandom 2>/dev/null | tr -d ' \n')
[[ -n "$XYMEDIA_INSTANCE_ID" ]] || XYMEDIA_INSTANCE_ID="install-${INSTALL_NONCE}"
fi
[[ $XYMEDIA_INSTANCE_ID =~ ^[a-z0-9_-]+$ ]] || die 'XYMEDIA_INSTANCE_ID 格式无效'
}
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]}"
}
start_app_after_migration() {
local helper="$INSTALL_TMP_DIR/start-app-after-migration.sh"
cat >"$helper" <<'START_APP_HELPER'
#!/usr/bin/env bash
set +e
install_dir=$1
install_log=$2
shift 2
tty_out=${XYMEDIA_INSTALL_TEST_TTY_OUTPUT:-/dev/tty}
emit() {
printf '%s\n' "$1"
printf '%s\n' "$1" >>"$install_log" 2>/dev/null || true
}
emit '正在启动 XyMediaVault 应用...'
if "$@" >>"$install_log" 2>&1; then
app_rc=0
else
app_rc=$?
fi
emit "应用启动命令退出码:$app_rc"
return_code=$app_rc
exit "$return_code"
START_APP_HELPER
chmod 700 "$helper" 2>/dev/null || true
bash "$helper" "$INSTALL_DIR" "$INSTALL_LOG" "$@"
return $?
}
if [[ ${XYMEDIA_INSTALL_TEST_POST_MIGRATION_ONLY:-0} == 1 ]]; then
INSTALL_DIR=${XYMEDIA_INSTALL_DIR:-$PWD}
mkdir -p "$INSTALL_DIR"
INSTALL_LOG="$INSTALL_DIR/install.log"
: >"$INSTALL_LOG"
COMPOSE=(docker)
COMPOSE_FUSE=()
MENU_PROFILES=${XYMEDIA_INSTALL_TEST_MENU_PROFILES:-}
set +e
migration_rc=0
printf '数据库迁移退出码:%s\n' "$migration_rc"
printf '数据库迁移退出码:%s\n' "$migration_rc" >>"$INSTALL_LOG"
printf '[安装流程] 已完成数据库迁移,继续启动应用\n'
printf '[安装流程] 已完成数据库迁移,继续启动应用\n' >>"$INSTALL_LOG"
printf '数据库初始化完成\n'
printf '数据库初始化完成\n' >>"$INSTALL_LOG"
if [[ $MENU_PROFILES == *xiaoya-control* ]]; then
printf '正在将本机小雅 Controller 同步到管理平台数据库...\n'
printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' >>"$INSTALL_LOG"
run_visible_logged "${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 bootstrap-controller --config /app/config.yaml </dev/null
fi
progress '启动 XyMediaVault 服务' || true
start_app_after_migration "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d --force-recreate app
app_start_rc=$?
if (( app_start_rc != 0 )); then
persist_installer_log || true
printf 'XyMediaVault 启动失败(退出码:%s\n' "$app_start_rc" >&2
exit 1
fi
if [[ $MENU_PROFILES == *xiaoya-control* ]]; then
if print_controller_info; then
:
else
printf '控制器连接信息暂时不可读取,请安装完成后通过菜单 6 查看。\n'
fi
fi
set -e
exit 0
fi
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) 查看小雅控制器地址和密钥\n7) 查看数据库连接信息\n0) 退出\n选择:' >>$TTY_OUT
read_tty choice || die '无法读取终端输入'
case "$choice" in
1) return 0;;
2) ensure_xiaoya_dir; printf '小雅目录已准备:%s;安装器将在 Docker 检查后检测并复用已有小雅。\n' "$XIAOYA_DIR" >>$TTY_OUT; write_controller_token; CONTROLLER_URL='http://xymedia-controller:19090'; OPTION2_XIAOYA_PENDING=1; 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) 停用本地媒体库挂载\n5) 清理 XyMediaVault 容器及数据\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;;
5) printf '清理选项:\n1) 只清理 XyMediaVault\n2) 清理 XyMediaVault 和本机安装的小雅\n0) 取消\n选择:' >>$TTY_OUT; read_tty cleanup_choice || die '无法读取终端输入'; case "$cleanup_choice" in 1) CLEANUP_CHOICE=1; return 0;; 2) CLEANUP_CHOICE=2; return 0;; 0) continue;; *) printf '无效选择\n' >>$TTY_OUT;; esac;;
0) continue 2;; *) printf '无效选择\n' >>$TTY_OUT;;
esac
done;;
6) print_controller_info || true;;
7) print_database_info || true;;
0) exit 0;; *) printf '无效选项\n' >>$TTY_OUT;;
esac
done
}
if [[ -f "$INSTALL_DIR/.env" ]]; then
[[ -n ${XYMEDIA_XIAOYA_DIR:-} ]] || XIAOYA_DIR=$(read_install_env XYMEDIA_XIAOYA_DATA_DIR)
if [[ -z ${XYMEDIA_XIAOYA_CONTAINER:-} ]]; then
existing_xiaoya_container=$(read_install_env XYMEDIA_XIAOYA_CONTAINER)
if [[ -n $existing_xiaoya_container ]]; then
XIAOYA_CONTAINER=$existing_xiaoya_container
XIAOYA_CONTAINER_EXPLICIT=1
fi
fi
fi
menu "$@"
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
if [[ -f "$INSTALL_DIR/.env" ]]; then INSTALL_EXISTING=1; fi
if (( INSTALL_EXISTING )); then
if [[ -z ${XYMEDIA_XIAOYA_DIR:-} ]]; then
XIAOYA_DIR=$(read_install_env XYMEDIA_XIAOYA_DATA_DIR)
fi
if [[ -z ${XYMEDIA_XIAOYA_CONTAINER:-} ]]; then
existing_xiaoya_container=$(read_install_env XYMEDIA_XIAOYA_CONTAINER)
if [[ -n $existing_xiaoya_container ]]; then
XIAOYA_CONTAINER=$existing_xiaoya_container
XIAOYA_CONTAINER_EXPLICIT=1
fi
fi
fi
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 $CLEANUP_CHOICE ]]; then
(( EUID == 0 )) || die '清理要求 root 权限'
cleanup_all "$([[ $CLEANUP_CHOICE == 2 ]] && printf 1 || printf 0)"
exit 0
fi
ensure_instance_id
if (( ! CONTROLLER_ONLY )) && (( INSTALL_EXISTING )); then
old_host=$(read_install_env XYMEDIA_POSTGRES_HOST)
old_port=$(read_install_env XYMEDIA_POSTGRES_PORT)
old_db=$(read_install_env XYMEDIA_POSTGRES_DATABASE)
old_user=$(read_install_env XYMEDIA_POSTGRES_USER)
[[ -n $old_host && -z $PG_HOST_ENV_EXPLICIT && $PG_HOST_CLI -eq 0 ]] && PG_HOST=$old_host
[[ -n $old_port ]] && PG_PORT=$old_port
[[ -n $old_db ]] && PG_DB=$old_db
[[ -n $old_user ]] && PG_USER=$old_user
old_local=$(read_install_env XYMEDIA_POSTGRES_LOCAL_DB)
if [[ $old_local == 1 || ( $old_host == postgres && $EXISTING_DB != 1 ) ]]; then
LOCAL_DB=1
[[ -n $PG_HOST_ENV_EXPLICIT || $PG_HOST_CLI -eq 1 ]] || PG_HOST=$old_host
fi
fi
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:-} ]] && (( ! OPTION2_XIAOYA_PENDING )); then
docker container inspect "$XIAOYA_CONTAINER" >/dev/null 2>&1 || die "未找到小雅容器:$XIAOYA_CONTAINER"
XIAOYA_FOUND=1
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
if (( OPTION2_XIAOYA_PENDING )); then
XIAOYA_FOUND=0
XIAOYA_CONTAINER=xymedia-xiaoya
return 0
fi
prompt_tty_into selected '未自动发现小雅容器,请输入容器名称(将执行 Docker inspect):'
[[ -n $selected ]] || die '小雅容器名称不能为空'
docker container inspect "$selected" >/dev/null 2>&1 || die "容器不存在或无法 inspect$selected"
XIAOYA_CONTAINER=$selected
XIAOYA_FOUND=1
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
XIAOYA_FOUND=1
[[ $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))]}"
XIAOYA_FOUND=1
printf '已选择小雅容器:%s(镜像:%s,状态:%s)\n' "$XIAOYA_CONTAINER" "$image" "$state" >>$TTY_OUT
[[ $state == running ]] || printf '警告:小雅容器当前已停止;仍允许安装控制器,状态页会提示。\n' >>$TTY_OUT
fi
}
prepare_option2_xiaoya() {
(( OPTION2_XIAOYA_PENDING )) || return 0
discover_xiaoya_container
validate_container_name
if (( XIAOYA_FOUND )); then
validate_xiaoya_data_dir
XYMEDIA_XIAOYA_MANAGED=0
MENU_PROFILES='xiaoya-control'
printf '检测到已有小雅容器:%s,将复用,不创建新的小雅容器。\n' "$XIAOYA_CONTAINER" >>$TTY_OUT
else
XYMEDIA_XIAOYA_MANAGED=1
MENU_PROFILES='xiaoya xiaoya-control'
printf '未检测到已有小雅容器,将安装新的小雅容器:%s。\n' "$XIAOYA_CONTAINER" >>$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
prepare_option2_xiaoya
if [[ ${XYMEDIA_INSTALL_TEST_MENU_ONLY:-0} == 1 || ${XYMEDIA_INSTALL_TEST_OPTION2_ONLY:-0} == 1 ]]; then
printf 'TEST_MENU_PROFILES=%s\nTEST_XIAOYA_CONTAINER=%s\nTEST_XIAOYA_MANAGED=%s\n' "$MENU_PROFILES" "$XIAOYA_CONTAINER" "${XYMEDIA_XIAOYA_MANAGED:-0}" >>$TTY_OUT
exit 0
fi
if (( ! CONTROLLER_ONLY )) && (( PG_HOST_CLI )) && [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi
if (( ! CONTROLLER_ONLY )) && (( PG_HOST_CLI )) && [[ $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_PORT -ge 1024 && $PG_PORT -le 65535 && $PG_DB =~ ^[A-Za-z0-9_]+$ && $PG_USER =~ ^[A-Za-z0-9._-]+$ ]]; then die 'invalid PostgreSQL connection values'; fi
if (( ! CONTROLLER_ONLY )) && [[ -n $PG_HOST ]] && ! python3 - "$PG_HOST" <<'PY'
import ipaddress, sys
try:
ipaddress.ip_address(sys.argv[1])
except ValueError:
if not __import__('re').fullmatch(r'[A-Za-z0-9.-]+', sys.argv[1]): raise SystemExit(1)
PY
then die 'PostgreSQL 主机必须是 IPv4、IPv6 或合法主机名'; 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
PG_PASSWORD_FILE="$INSTALL_DIR/secrets/xymedia-postgres-password"
if [[ ! -s $PG_PASSWORD_FILE ]]; then
umask 077
if (( INSTALL_EXISTING )) && [[ -s "$INSTALL_DIR/secrets/postgres-password" ]]; then
cp "$INSTALL_DIR/secrets/postgres-password" "$PG_PASSWORD_FILE"
elif [[ -n ${PG_PASSWORD_FILE_INPUT:-} ]]; then
cp "$PG_PASSWORD_FILE_INPUT" "$PG_PASSWORD_FILE"
elif 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
chmod 600 "$PG_PASSWORD_FILE"
fi
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)"
postgres_host_is_container_ip() {
local container_ips
[[ $1 == postgres ]] && return 0
container_ips=$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' xymedia-postgres 2>/dev/null || true)
[[ " $container_ips " == *" $1 "* ]]
}
if (( ! CONTROLLER_ONLY )) && (( LOCAL_DB )); then
if [[ -z $HOST_ADDRESSES_INITIAL && -z $PG_HOST_ENV_EXPLICIT ]]; then
die '未检测到可用宿主机 IP,请设置 XYMEDIA_POSTGRES_HOST 后重试'
fi
if [[ -z $PG_HOST_ENV_EXPLICIT && $PG_HOST_CLI -eq 0 ]]; then
PG_HOST=$(printf '%s\n' "$HOST_ADDRESSES_INITIAL" | head -n 1)
printf '本地数据库将使用宿主机地址:%s\n' "$PG_HOST" >>$TTY_OUT
fi
fi
select_random_postgres_port() {
local requested=${XYMEDIA_POSTGRES_PUBLIC_PORT:-}
if [[ -n $requested ]]; then
[[ $requested =~ ^[0-9]+$ && $requested -ge 1024 && $requested -le 65535 ]] || die 'XYMEDIA_POSTGRES_PUBLIC_PORT 必须是 1024 到 65535 之间的数字'
printf '%s\n' "$requested"
return 0
fi
python3 - <<'PY'
import random, socket
for _ in iter(int, 1):
port = random.randint(20000, 40000)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(('0.0.0.0', port))
print(port)
break
except OSError:
pass
finally:
sock.close()
PY
}
postgres_port_is_owned() {
local published
published=$(docker inspect --format '{{range $p, $bindings := .NetworkSettings.Ports}}{{if eq $p "5432/tcp"}}{{range $bindings}}{{.HostPort}}{{end}}{{end}}{{end}}' xymedia-postgres 2>/dev/null || true)
[[ -n $published && $published == "$PG_PORT" ]]
}
postgres_port_is_available() {
PG_PORT_CHECK=$1 python3 - <<'PY'
import os, socket
port = int(os.environ['PG_PORT_CHECK'])
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.bind(('0.0.0.0', port))
except OSError:
raise SystemExit(1)
finally:
sock.close()
PY
}
random_postgres_user() {
python3 - <<'PY'
import secrets, string
print('x' + ''.join(secrets.choice(string.ascii_lowercase + string.digits + '_') for _ in range(15)))
PY
}
if (( ! CONTROLLER_ONLY )) && (( ! INSTALL_EXISTING )) && (( ! PG_HOST_CLI )); then
if [[ -z ${XYMEDIA_POSTGRES_HOST:-} && -z $PG_HOST_ENV_EXPLICIT ]]; then
[[ -n $HOST_ADDRESSES_INITIAL ]] || die '未检测到可用宿主机 IP,请设置 XYMEDIA_POSTGRES_HOST 后重试'
PG_HOST=$(printf '%s\n' "$HOST_ADDRESSES_INITIAL" | head -n 1)
fi
PG_PORT=$(select_random_postgres_port) || die '无法选择 PostgreSQL 对外端口,请稍后重试'
XYMEDIA_POSTGRES_PUBLIC_PORT=$PG_PORT
PG_USER=$(random_postgres_user) || die '无法生成 PostgreSQL 用户名'
LOCAL_DB=1
fi
if (( ! CONTROLLER_ONLY )) && (( LOCAL_DB )) && (( ! PG_HOST_CLI )); then
[[ -n ${XYMEDIA_POSTGRES_PUBLIC_PORT:-} ]] && PG_PORT=$XYMEDIA_POSTGRES_PUBLIC_PORT
if ! postgres_port_is_owned && { [[ $PG_PORT == 5432 ]] || ! postgres_port_is_available "$PG_PORT"; }; then
PG_PORT=$(select_random_postgres_port) || die '无法选择 PostgreSQL 对外端口,请稍后重试'
XYMEDIA_POSTGRES_PUBLIC_PORT=$PG_PORT
printf '检测到 PostgreSQL 端口被其他服务占用,已改用随机端口:%s\n' "$PG_PORT" >>$TTY_OUT
fi
fi
if (( ! CONTROLLER_ONLY )) && (( LOCAL_DB )); then
[[ -n $PG_HOST ]] || die '本机 bundled PostgreSQL 必须使用宿主机 IP,请设置 XYMEDIA_POSTGRES_HOST 后重试'
python3 - "$PG_HOST" <<'PY' || die 'PostgreSQL 主机必须是 IPv4、IPv6 或合法主机名'
import ipaddress, re, sys
try:
ipaddress.ip_address(sys.argv[1])
except ValueError:
if not re.fullmatch(r'[A-Za-z0-9.-]+', sys.argv[1]): raise SystemExit(1)
PY
fi
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/xymedia-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 (( INSTALL_EXISTING )) && [[ -s "$INSTALL_DIR/secrets/postgres-password" ]]; then cp "$INSTALL_DIR/secrets/postgres-password" "$PG_PASSWORD_FILE"
elif 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 (( LOCAL_DB )) && [[ -z ${XYMEDIA_POSTGRES_PUBLIC_PORT:-} ]]; then XYMEDIA_POSTGRES_PUBLIC_PORT=$PG_PORT; fi
# Keep the legacy secret for existing administrators and old installations.
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
XIAOYA_MANAGED_ENV=$(awk -F= '$1 == "XYMEDIA_XIAOYA_MANAGED" {print $2; exit}' "$env_tmp" 2>/dev/null || true)
[[ ${XYMEDIA_XIAOYA_MANAGED:-0} == 1 ]] || XYMEDIA_XIAOYA_MANAGED=${XIAOYA_MANAGED_ENV:-0}
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" "${XYMEDIA_XIAOYA_MANAGED:-0}" "$XYMEDIA_INSTANCE_ID" "$LOCAL_DB" "$XYMEDIA_POSTGRES_PUBLIC_PORT" <<'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','XYMEDIA_XIAOYA_MANAGED','XYMEDIA_INSTANCE_ID','XYMEDIA_POSTGRES_LOCAL_DB','XYMEDIA_POSTGRES_PUBLIC_PORT')
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 --connect-timeout 10 --max-time 60 --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
import re
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)
for entry in catalog["components"][component]["artifacts"].values():
assert isinstance(entry, dict) and re.fullmatch(r"[0-9a-fA-F]{64}", entry.get("sha256", ""))
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")
import re
if not isinstance(entry, dict) or not isinstance(entry.get("version"), str) or not isinstance(entry.get("asset_url"), str) or not isinstance(entry.get("sha256"), str) or re.fullmatch(r"[0-9a-fA-F]{64}", entry["sha256"]) is None:
raise SystemExit(1)
url = entry["asset_url"]
from urllib.parse import urlsplit
parsed = urlsplit(url)
try:
port = parsed.port
except ValueError:
raise SystemExit(1)
if (
parsed.scheme != "https"
or parsed.netloc.lower() != "git.keeper.work"
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or port is not None
or "%" in (parsed.path or "")
or re.fullmatch(r"/api/packages/[^/]+/generic/[^/]+/[^/]+/[^/]+", parsed.path or "") is None
):
raise SystemExit(1)
print(entry["version"] + "\t" + url + "\t" + entry["sha256"].lower())
PY
}
download_extract() {
local component=$1
local version=$2
local url=$3
local expected_sha=$4
local target=$5
local work archive tarball result effective status actual
work=$(mktemp -d "$INSTALL_TMP_DIR/${component}.XXXXXX")
archive="$work/$component.archive"
printf ' 正在下载 %s %s%s\n' "$component" "$version" "${url##*/}"
result=$(curl --connect-timeout 10 --max-time 300 --fail --show-error --location --proto '=https' --tlsv1.2 --progress-bar -o "$archive" -w '%{url_effective}' "$url") || die "$component 下载失败"
effective=$result
python3 - "$effective" <<'PY' || die "$component 下载重定向不在 canonical Generic Package URL"
import re, sys
from urllib.parse import urlsplit
url = sys.argv[1]
parsed = urlsplit(url)
try:
port = parsed.port
except ValueError:
raise SystemExit(1)
if (
parsed.scheme != "https"
or parsed.netloc.lower() != "git.keeper.work"
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or port is not None
or "%" in (parsed.path or "")
or re.fullmatch(r"/api/packages/[^/]+/generic/[^/]+/[^/]+/[^/]+", parsed.path or "") is None
):
raise SystemExit(1)
PY
actual=$(sha256sum "$archive" | cut -d' ' -f1); [[ $actual == "$expected_sha" ]] || die "$component SHA256 校验失败"
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
try:
tf.extractall(target, members=members, filter='data')
except TypeError:
tf.extractall(target, members=members)
PY
cp "$archive" "$target/.download.archive"
rm -f "$tarball"
rm -rf "$work"
}
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 controller_sha < <(artifact controller "$PLATFORM") || die '公开目录没有当前架构的 controller 制品'
progress "下载小雅控制器 $controller_version ($PLATFORM)"
download_extract controller "$controller_version" "$controller_url" "$controller_sha" "$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 APP_SHA < <(artifact app "$PLATFORM")
APP_STAGE=$(mktemp -d "$INSTALL_TMP_DIR/app.XXXXXX")
progress "下载应用包 $APP_VERSION ($PLATFORM)"
download_extract app "$APP_VERSION" "$APP_URL" "$APP_SHA" "$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"
if [[ -e "$INSTALL_DIR/releases/releases/$APP_VERSION" ]]; then
progress '停止旧版本应用以替换同版本 release'
run_logged "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" stop app || die '无法停止旧版本应用'
fi
release_target="$INSTALL_DIR/releases/releases/$APP_VERSION"
release_backup="$INSTALL_DIR/releases/.${APP_VERSION}.old.$$"
RELEASE_TARGET=$release_target
RELEASE_BACKUP=$release_backup
[[ ! -L "$release_target" ]] || die 'release 目录不能是符号链接'
mv "$APP_ROOT" "$release_target.new.$$" || die '无法准备 release 替换'
if [[ -e "$release_target" ]]; then
mv "$release_target" "$release_backup" || die '无法备份旧 release'
fi
mv "$release_target.new.$$" "$release_target" || { mv "$release_backup" "$release_target" 2>/dev/null || true; die '无法安装新 release'; }
RELEASE_ROLLBACK_ACTIVE=1
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 sha < <(artifact "$component" "$platform")
archive="$INSTALL_DIR/components/$component.tar.zst"
marker_version="$INSTALL_DIR/components/$component.version"
marker_sha="$INSTALL_DIR/components/$component.sha256"
if [[ -s $archive && -f $marker_version && -f $marker_sha ]] && [[ $(<"$marker_version") == "$version" ]] && [[ $(<"$marker_sha") == "$sha" ]] && [[ $(sha256sum "$archive" | cut -d' ' -f1) == "$sha" ]]; then
printf '%s %s 已安装且完整,跳过下载\n' "$([[ $component == tmm ]] && printf TMM || printf Title)" "$version"
continue
fi
component_stage=$(mktemp -d "$INSTALL_TMP_DIR/$component.XXXXXX")
download_extract "$component" "$version" "$url" "$sha" "$component_stage"
archive_tmp="$INSTALL_TMP_DIR/$component.archive"
cp "$component_stage/.download.archive" "$archive_tmp"
[[ -s $archive_tmp ]] || die "$component archive was not retained"
component_root="$component_stage"
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_tmp" "$archive.tmp"
printf '%s\n' "$version" >"$marker_version.tmp"
printf '%s\n' "$sha" >"$marker_sha.tmp"
mv -f "$archive.tmp" "$archive"; mv -f "$marker_version.tmp" "$marker_version"; mv -f "$marker_sha.tmp" "$marker_sha"
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/"
if (( PG_HOST_CLI )) || [[ -n ${XYMEDIA_POSTGRES_HOST:-} ]]; then
python3 - "$INSTALL_DIR/compose.yaml" <<'PY'
import pathlib, re, sys
p = pathlib.Path(sys.argv[1])
text = p.read_text()
text = re.sub(r'\n extra_hosts:\n - "host\.docker\.internal:host-gateway"', '', text)
p.write_text(text)
PY
fi
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
if [[ ${XYMEDIA_XIAOYA_MANAGED:-0} == 1 ]]; then
[[ ${XYMEDIA_INSTALL_CONFIRM:-} == INSTALL_XIAOYA ]] || { confirm=$(prompt_tty '安装小雅将创建并启动相关容器,输入 1 继续,其他内容取消: '); [[ $confirm == 1 ]] || die '已取消小雅安装'; }
fi
for profile in $MENU_PROFILES; do
case "$profile" in
xiaoya) profile_service=xiaoya-alist;;
xiaoya-control) profile_service=xiaoya-control;;
*) die "未知小雅 profile$profile";;
esac
if [[ $profile == xiaoya ]]; then
printf '正在创建并启动小雅容器:%s\n' "$XIAOYA_CONTAINER"
else
printf '正在创建并启动小雅控制器:xymedia-controller\n'
fi
if ! run_logged "${COMPOSE[@]}" --profile "$profile" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d --no-deps --force-recreate "$profile_service"; then
if [[ $profile == xiaoya ]]; then
profile_state=$(docker inspect --format '{{.State.Status}}' "$XIAOYA_CONTAINER" 2>>"$INSTALL_LOG" || true)
else
profile_state=$(docker inspect --format '{{.State.Status}}' xymedia-controller 2>>"$INSTALL_LOG" || true)
fi
if [[ $profile_state == running ]]; then
printf '小雅 profile 启动命令返回异常,但目标容器已运行,继续安装:%s\n' "$profile"
printf '小雅 profile 启动命令返回异常,但目标容器已运行,继续安装:%s\n' "$profile" >>"$INSTALL_LOG"
else
die "小雅 profile 启动失败:$profile"
fi
fi
if [[ $profile == xiaoya ]]; then
printf '小雅容器已启动:%s\n' "$XIAOYA_CONTAINER"
else
printf '小雅控制器已启动:xymedia-controller\n'
fi
done
if [[ $MENU_PROFILES == *xiaoya-control* ]]; then
controller_state=$(docker inspect --format '{{.State.Status}}' xymedia-controller 2>>"$INSTALL_LOG" || true)
[[ $controller_state == running ]] || die '本机小雅控制器未正常运行'
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 (( LOCAL_DB )); 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 (( LOCAL_DB )); 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
wait_postgres_external() {
local started_at elapsed db_network project_name network_name
local -a client_command
PG_PASSWORD=$(<"$PG_PASSWORD_FILE")
[[ -n $PG_PASSWORD ]] || die 'PostgreSQL 密码不能为空'
if (( LOCAL_DB )); then
db_network=$(docker inspect --format '{{range $network, $config := .NetworkSettings.Networks}}{{$network}}{{"\n"}}{{end}}' xymedia-postgres 2>>"$INSTALL_LOG" | head -n 1)
else
project_name=${COMPOSE_PROJECT_NAME:-$(basename "$INSTALL_DIR")}
network_name="${project_name}_default"
db_network=$(docker network ls --filter "label=com.docker.compose.project=$project_name" --format '{{.Name}}' 2>>"$INSTALL_LOG" | head -n 1 || true)
if [[ -z $db_network ]]; then
db_network=$network_name
docker network create --label "com.docker.compose.project=$project_name" --label com.docker.compose.network=default "$db_network" >>"$INSTALL_LOG" 2>&1 || true
fi
fi
[[ -n $db_network ]] || die '无法确定 PostgreSQL 外部连接探测所需的 Compose 网络'
started_at=$(date +%s)
while :; do
client_command=(docker run --rm --network "$db_network" -e "PGPASSWORD=$PG_PASSWORD" postgres:17-bookworm psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -c 'SELECT 1')
if "${client_command[@]}" >>"$INSTALL_LOG" 2>&1; then
elapsed=$(( $(date +%s) - started_at ))
printf '\033[2K\r[%d/%d] PostgreSQL 外部连接已验证(%s 秒)\n' "$STEP" "$TOTAL_STEPS" "$elapsed"
return 0
fi
elapsed=$(( $(date +%s) - started_at ))
printf '\033[2K\r[%d/%d] 数据库外部连接仍在等待(%s秒)' "$STEP" "$TOTAL_STEPS" "$elapsed"
printf 'PostgreSQL external status: elapsed=%s host=%s port=%s\n' "$elapsed" "$PG_HOST" "$PG_PORT" >>"$INSTALL_LOG"
sleep 2
done
}
wait_postgres_external || die 'PostgreSQL 外部连接验证失败'
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 </dev/null >"$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 </dev/null >"$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=$?
if [[ $migration_rc -ne 0 ]]; then
exit_code=1
persist_installer_log || true
if (( EXISTING_DB )); then
printf '已有数据库迁移失败(退出码:%s)\n' "$migration_rc" >&2
else
printf '数据库初始化失败(退出码:%s)\n' "$migration_rc" >&2
fi
exit "$exit_code"
fi
printf '[安装流程] 已完成数据库迁移,继续启动应用\n'
printf '[安装流程] 已完成数据库迁移,继续启动应用\n' >>"$INSTALL_LOG"
printf '数据库初始化完成\n'
printf '数据库初始化完成\n' >>"$INSTALL_LOG"
bootstrap_local_controller() {
[[ $MENU_PROFILES == *xiaoya-control* ]] || return 0
(( REMOTE_MODE == 0 )) || return 0
printf '正在将本机小雅 Controller 同步到管理平台数据库...\n'
printf '正在将本机小雅 Controller 同步到管理平台数据库...\n' >>"$INSTALL_LOG"
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 -T --no-deps app-migrate bootstrap-controller --config /app/config.yaml </dev/null; then
printf '本机小雅 Controller 数据库同步失败,安装未完成。\n' >&2
return 1
fi
}
if bootstrap_local_controller; then
:
else
persist_installer_log || true
exit 1
fi
progress '启动 XyMediaVault 服务' || true
start_app_after_migration "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" "${COMPOSE_FUSE[@]}" up -d --force-recreate app
app_start_rc=$?
if (( app_start_rc != 0 )); then
exit_code=1
persist_installer_log || true
printf 'XyMediaVault 启动失败(退出码:%s\n' "$app_start_rc" >&2
exit "$exit_code"
fi
set -e
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
}
if wait_for_app_health; then
:
else
printf 'XyMediaVault 健康检查被中断,安装未完成。\n' >&2
persist_installer_log || true
exit 1
fi
if (( RELEASE_ROLLBACK_ACTIVE )); then rm -rf -- "$release_backup"; RELEASE_ROLLBACK_ACTIVE=0; fi
cp "$INSTALL_LOG" "$INSTALL_DIR/install.log" 2>/dev/null || true
printf '\033[2K\r[%d/%d] 安装完成:XyMediaVault 已就绪\n' "$TOTAL_STEPS" "$TOTAL_STEPS"
management_address=$(printf '%s\n' "$HOST_ADDRESSES_INITIAL" | head -n 1)
[[ -n $management_address ]] || management_address=127.0.0.1
if [[ $management_address == *:* ]]; then management_address="[$management_address]"; fi
printf '管理地址:http://%s:%s\n' "$management_address" "$API_PORT"
if [[ $MENU_PROFILES == *xiaoya-control* ]]; then
if print_controller_info; then
:
else
printf '控制器连接信息暂时不可读取,请安装完成后通过菜单 6 查看。\n'
fi
fi
if (( LOCAL_DB )); then
print_database_info
else
printf 'PostgreSQL 地址:%s:%s\nPostgreSQL 数据库:%s\nPostgreSQL 用户:%s\n' "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER"
fi
printf '安装日志:%s/install.log\n' "$INSTALL_DIR"
exit 0