#!/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} SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd -P) INSTALL_DIR=/opt/xymediavault 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} die() { printf 'xymediavault installer: %s\n' "$*" >&2; exit 1; } usage() { sed -n '1,20p' "$0"; } while (($#)); do case "$1" in --install-dir) INSTALL_DIR=${2:?missing value}; shift 2;; --postgres-host) PG_HOST=${2:?missing value}; shift 2;; --postgres-port) PG_PORT=${2:?missing value}; shift 2;; --postgres-db) PG_DB=${2:?missing value}; shift 2;; --postgres-user) PG_USER=${2:?missing value}; shift 2;; --postgres-password-file) PG_PASSWORD_FILE=${2:?missing value}; shift 2;; --skip-components) SKIP_COMPONENTS=1; shift;; --existing-db) EXISTING_DB=1; shift;; -h|--help) usage; exit 0;; *) die "unknown option: $1";; esac done [[ $INSTALL_DIR = /* && $INSTALL_DIR != / ]] || die 'install directory must be an absolute non-root path' command -v docker >/dev/null || die 'Docker is required' if docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose); elif command -v docker-compose >/dev/null; then COMPOSE=(docker-compose); else die 'Docker Compose is required'; fi docker info >/dev/null 2>&1 || die 'cannot connect to Docker' command -v curl >/dev/null || die 'curl is required' command -v zstd >/dev/null || die 'zstd is required for release archives' command -v python3 >/dev/null || die 'python3 is required for safe archive extraction' if [[ $PG_HOST != postgres && $EXISTING_DB != 1 ]]; then die 'an external database requires --existing-db confirmation'; fi if ! [[ $API_PORT =~ ^[0-9]+$ && $WEBDAV_PORT =~ ^[0-9]+$ && $TVBOX_PORT =~ ^[0-9]+$ ]]; then die 'ports must be numeric'; fi if ! [[ $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 mkdir -p "$INSTALL_DIR" "$INSTALL_DIR"/{data/postgres,data,state,components,releases,secrets,config} chmod 700 "$INSTALL_DIR/secrets" if [[ -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/postgres-password" PG_PASSWORD_FILE="$INSTALL_DIR/secrets/postgres-password" fi else PG_PASSWORD_FILE="$INSTALL_DIR/secrets/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 chmod 600 "$PG_PASSWORD_FILE" TEMPLATE_DIR="$INSTALL_DIR/bootstrap" mkdir -p "$TEMPLATE_DIR" template_file() { local name=$1 local_file="$SCRIPT_DIR/$1" downloaded="$TEMPLATE_DIR/$1" if [[ -f $local_file ]]; then printf '%s\n' "$local_file" return 0 fi curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$RELEASE_BASE/$name" -o "$downloaded" printf '%s\n' "$downloaded" } CONFIG_TEMPLATE=$(template_file config.yaml) COMPOSE_TEMPLATE=$(template_file compose.yaml) DOCKERFILE_TEMPLATE=$(template_file Dockerfile.bootstrap) umask 077 printf 'XYMEDIA_INSTALL_DIR=%s\nXYMEDIA_POSTGRES_HOST=%s\nXYMEDIA_POSTGRES_PORT=%s\nXYMEDIA_POSTGRES_DATABASE=%s\nXYMEDIA_POSTGRES_USER=%s\nXYMEDIA_API_PORT=%s\nXYMEDIA_WEBDAV_PORT=%s\nXYMEDIA_TVBOX_PORT=%s\n' "$INSTALL_DIR" "$PG_HOST" "$PG_PORT" "$PG_DB" "$PG_USER" "$API_PORT" "$WEBDAV_PORT" "$TVBOX_PORT" >"$INSTALL_DIR/.env" curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$CATALOG_URL" -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"): 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 version=$2 url=$3 target=$4 archive="$INSTALL_DIR/state/$component-$version.archive" curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 "$url" -o "$archive" 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" } IFS=$'\t' read -r APP_VERSION APP_URL < <(artifact app "$PLATFORM") APP_STAGE="$INSTALL_DIR/state/app-$APP_VERSION" 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" 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 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=$(find "$INSTALL_DIR/state/$component-$version" -mindepth 1 -maxdepth 1 -type d | head -n 1) [[ -n $component_root ]] || die "$component archive has no root directory" 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 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"], } content = template.read_text() for placeholder, value in values.items(): content = content.replace(placeholder, value) output.write_text(content) PY cp "$COMPOSE_TEMPLATE" "$DOCKERFILE_TEMPLATE" "$INSTALL_DIR/" if [[ ! -f "$INSTALL_DIR/.bootstrap-dockerfile.sha256" || "$(sha256sum "$INSTALL_DIR/Dockerfile.bootstrap" | cut -d' ' -f1)" != "$(<"$INSTALL_DIR/.bootstrap-dockerfile.sha256")" ]]; then "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" build app sha256sum "$INSTALL_DIR/Dockerfile.bootstrap" | cut -d' ' -f1 >"$INSTALL_DIR/.bootstrap-dockerfile.sha256" fi if [[ $PG_HOST == postgres ]]; then "${COMPOSE[@]}" --profile local-db --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d postgres; fi if [[ $PG_HOST == postgres ]]; then for _ in {1..60}; do "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" exec -T postgres pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1 && break; sleep 2; done; fi if (( EXISTING_DB )); then export XYMEDIA_EXTERNAL_MIGRATION_CONFIRM=YES; "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" run --rm --no-deps app-migrate migrate --config /app/config.yaml; else "${COMPOSE[@]}" --profile migration --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" run --rm --no-deps app-migrate migrate --new --config /app/config.yaml; fi "${COMPOSE[@]}" --project-directory "$INSTALL_DIR" --env-file "$INSTALL_DIR/.env" -f "$INSTALL_DIR/compose.yaml" up -d app for _ in {1..60}; do curl --fail --silent "http://127.0.0.1:$API_PORT/api/health" >/dev/null && printf 'XyMediaVault is ready at http://127.0.0.1:%s\n' "$API_PORT" && exit 0; sleep 2; done