Files
Bose-SoundTouch/scripts/raspberry-pi/install.sh
T
Tobias GesellchenandClaude Opus 4.8 ea7f6f36ef feat(install): default installers to the latest release via releases/latest
The on-device and Raspberry Pi installers hardcoded the release version, which
had to be bumped on every release. Default VERSION to empty and resolve the
newest tag by following GitHub's documented stable redirect
(https://github.com/<repo>/releases/latest -> .../releases/tag/vX.Y.Z), reading
the effective URL. This avoids the GitHub API rate limit and needs no jq.

An explicit version (positional arg / VERSION= / --version) still pins a
release. If the lookup fails (offline, rate-limited, or a curl without -w
support), each script falls back to a pinned FALLBACK_VERSION so installs still
work. The Pi self_update path runs after resolution, so it fetches the resolved
tag's installer.

Docs updated to state the default installs the latest release; the pinned-version
examples remain as illustrations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:47:19 +02:00

419 lines
12 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Bose-SoundTouch soundtouch-service installer (systemd, headless)
#
# Usage:
# sudo bash install.sh [vX.Y.Z]
#
# Examples (override defaults via env vars):
#
# sudo \
# VERSION=v0.111.3 \
# HOSTNAME_FQDN=soundtouch.local \
# HTTP_PORT=80 \
# HTTPS_PORT=443 \
# DATA_DIR=/var/lib/soundtouch-service \
# bash install.sh
#
# Or with a version argument to perform an update:
# sudo bash install.sh v0.111.3
#
# Notes:
# - This script downloads a release binary for your CPU (auto-detects armv7/arm64/amd64).
# - It installs a systemd unit that can bind privileged ports (80/443) using:
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# so you do NOT need setcap and do NOT need to run as root.
# - Safe to re-run; it will update binary/config/unit and restart the service.
# ==============================================================================
# Release to install. Empty means "resolve the latest release" (see
# resolve_version). Pass a tag/number as $1 or VERSION=... to pin a release.
VERSION="${1:-${VERSION:-}}"
# Normalize version prefix for an explicitly provided version.
if [[ -n "$VERSION" && ! "$VERSION" =~ ^v ]]; then
VERSION="v${VERSION}"
fi
GH_REPO="${GH_REPO:-gesellix/Bose-SoundTouch}"
# Used only when the latest-release lookup fails (offline / rate-limited).
FALLBACK_VERSION="${FALLBACK_VERSION:-v0.111.3}"
SERVICE_NAME="${SERVICE_NAME:-soundtouch-service}"
BIN_PATH="${BIN_PATH:-/usr/local/bin/soundtouch-service}"
CONFIG_DIR="${CONFIG_DIR:-/etc/soundtouch-service}"
ENV_FILE="${ENV_FILE:-$CONFIG_DIR/soundtouch-service.env}"
DATA_DIR="${DATA_DIR:-/var/lib/soundtouch-service}"
SERVICE_USER="${SERVICE_USER:-soundtouch}"
SERVICE_GROUP="${SERVICE_GROUP:-soundtouch}"
# Ports
HTTP_PORT="${HTTP_PORT:-80}"
HTTPS_PORT="${HTTPS_PORT:-443}"
# URLs (default uses current hostname + .local)
HOSTNAME_FQDN="${HOSTNAME_FQDN:-$(hostname).local}"
SERVER_URL="${SERVER_URL:-http://${HOSTNAME_FQDN}}"
HTTPS_SERVER_URL="${HTTPS_SERVER_URL:-https://${HOSTNAME_FQDN}}"
# Additional env vars (mirrors the project's docker-compose.yml)
LOG_PROXY_BODY="${LOG_PROXY_BODY:-false}"
REDACT_PROXY_LOGS="${REDACT_PROXY_LOGS:-true}"
RECORD_INTERACTIONS="${RECORD_INTERACTIONS:-true}"
DISCOVERY_INTERVAL="${DISCOVERY_INTERVAL:-5m}"
# Spotify OAuth config (optional)
SPOTIFY_CLIENT_ID="${SPOTIFY_CLIENT_ID:-}"
SPOTIFY_CLIENT_SECRET="${SPOTIFY_CLIENT_SECRET:-}"
SPOTIFY_REDIRECT_URI="${SPOTIFY_REDIRECT_URI:-}"
# Management API credentials
MGMT_USERNAME="${MGMT_USERNAME:-admin}"
MGMT_PASSWORD="${MGMT_PASSWORD:-change_me!}"
# Override if you want to force a specific asset suffix:
# ARCH_ASSET=linux-armv7|linux-arm64|linux-amd64
ARCH_ASSET="${ARCH_ASSET:-}"
# Internal variables
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || echo "$0")"
IS_SELF_UPDATE="${IS_SELF_UPDATE:-false}"
log() { printf "\n==> %s\n" "$*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
need_root() {
[[ "${EUID}" -eq 0 ]] || die "Please run as root (e.g. sudo bash $0)."
}
ensure_cmd() {
command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}
apt_install_if_missing() {
log "Installing dependencies: $*"
apt-get update -y
apt-get install -y --no-install-recommends "$@"
}
detect_arch_asset() {
# Upstream release naming expects: linux-armv7, linux-arm64, linux-amd64
# Map uname -m to those.
local m
m="$(uname -m)"
case "$m" in
armv7l|armv6l)
echo "linux-armv7"
;;
aarch64)
echo "linux-arm64"
;;
x86_64|amd64)
echo "linux-amd64"
;;
*)
die "Unsupported architecture from uname -m: $m (set ARCH_ASSET manually)"
;;
esac
}
download_url_for() {
local asset="$1"
# Release asset pattern used by you earlier:
# soundtouch-service-v0.111.3-linux-armv7
echo "https://github.com/gesellix/Bose-SoundTouch/releases/download/${VERSION}/soundtouch-service-${VERSION}-${asset}"
}
ensure_user_group() {
log "Ensuring service user/group exist: ${SERVICE_USER}:${SERVICE_GROUP}"
if ! getent group "${SERVICE_GROUP}" >/dev/null; then
groupadd --system "${SERVICE_GROUP}"
fi
if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then
useradd --system \
--home "${DATA_DIR}" \
--create-home \
--shell /usr/sbin/nologin \
--gid "${SERVICE_GROUP}" \
"${SERVICE_USER}"
fi
}
ensure_dirs() {
log "Creating directories"
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
# Optimized ownership check: only chown if not already owned by service user
if [[ "$(stat -c '%U:%G' "${DATA_DIR}")" != "${SERVICE_USER}:${SERVICE_GROUP}" ]]; then
log "Adjusting ownership of ${DATA_DIR} to ${SERVICE_USER}:${SERVICE_GROUP}"
chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}"
fi
chmod 0755 "${CONFIG_DIR}" "${DATA_DIR}"
}
download_binary() {
local asset url tmp=""
asset="${ARCH_ASSET:-$(detect_arch_asset)}"
url="$(download_url_for "$asset")"
log "Downloading binary for ${asset}: ${url}"
tmp="$(mktemp -d)"
trap 'rm -rf "${tmp}"' EXIT
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "${tmp}/soundtouch-service" "${url}"
else
wget -qO "${tmp}/soundtouch-service" "${url}"
fi
chmod +x "${tmp}/soundtouch-service"
# Backup existing binary if it exists
if [[ -f "${BIN_PATH}" ]]; then
log "Backing up existing binary to ${BIN_PATH}.old"
cp -p "${BIN_PATH}" "${BIN_PATH}.old"
fi
install -m 0755 "${tmp}/soundtouch-service" "${BIN_PATH}"
log "Installed binary to ${BIN_PATH}"
}
resolve_version() {
# When no explicit version was given, resolve the latest release tag by
# following the documented stable redirect:
# https://github.com/<owner>/<repo>/releases/latest
# which 302-redirects to .../releases/tag/vX.Y.Z. We read the final URL and
# take the tag from it. Falls back to FALLBACK_VERSION on any failure
# (offline, rate-limited, no usable curl/wget).
if [[ -n "$VERSION" ]]; then
return
fi
local latest_url="https://github.com/${GH_REPO}/releases/latest"
log "Resolving latest release via ${latest_url}"
local effective="" tag=""
if command -v curl >/dev/null 2>&1; then
effective="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null)" || true
else
# wget: don't follow the redirect, read the Location header instead.
effective="$(wget -S --max-redirect=0 -O /dev/null "$latest_url" 2>&1 \
| awk 'tolower($1) ~ /location:/ {print $2}' | tr -d '\r' | tail -1)" || true
fi
tag="${effective##*/}"
if [[ "$tag" =~ ^v?[0-9]+\.[0-9]+ ]]; then
[[ "$tag" =~ ^v ]] || tag="v${tag}"
VERSION="$tag"
log "Latest release is ${VERSION}"
else
VERSION="$FALLBACK_VERSION"
log "⚠️ Could not resolve latest release; falling back to ${VERSION}"
fi
}
self_update() {
# If we are already a self-update re-exec, don't do it again
if [[ "$IS_SELF_UPDATE" == "true" ]]; then
return
fi
local url="https://raw.githubusercontent.com/gesellix/Bose-SoundTouch/${VERSION}/scripts/raspberry-pi/install.sh"
local tmp_script="/tmp/soundtouch-install-${VERSION}.sh"
log "Checking for installer updates for ${VERSION}..."
log "URL: ${url}"
if command -v curl >/dev/null 2>&1; then
if ! curl -fsSL -o "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
else
if ! wget -qO "${tmp_script}" "${url}"; then
log "⚠️ Could not fetch installer for ${VERSION}, continuing with current script."
return
fi
fi
# Compare scripts to see if we actually need to re-exec
if diff -q "${SCRIPT_PATH}" "${tmp_script}" >/dev/null 2>&1; then
log "Installer is already up to date."
rm -f "${tmp_script}"
return
fi
log "Newer installer found for ${VERSION}. Updating ${SCRIPT_PATH} and re-executing..."
install -m 0755 "${tmp_script}" "${SCRIPT_PATH}"
rm -f "${tmp_script}"
# Export current env vars to the new script
export IS_SELF_UPDATE="true"
export VERSION HOSTNAME_FQDN HTTP_PORT HTTPS_PORT DATA_DIR BIN_PATH CONFIG_DIR ENV_FILE SERVICE_USER SERVICE_GROUP
export SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REDIRECT_URI MGMT_USERNAME MGMT_PASSWORD
exec "${SCRIPT_PATH}" "$@"
}
write_env_file() {
log "Updating env file: ${ENV_FILE}"
# 1. Start with a list of all variables we want to manage
local vars=(
"PORT=${HTTP_PORT}"
"HTTPS_PORT=${HTTPS_PORT}"
"DATA_DIR=${DATA_DIR}"
"LOG_PROXY_BODY=${LOG_PROXY_BODY}"
"REDACT_PROXY_LOGS=${REDACT_PROXY_LOGS}"
"RECORD_INTERACTIONS=${RECORD_INTERACTIONS}"
"DISCOVERY_INTERVAL=${DISCOVERY_INTERVAL}"
"SERVER_URL=${SERVER_URL}"
"HTTPS_SERVER_URL=${HTTPS_SERVER_URL}"
"SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}"
"SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}"
"SPOTIFY_REDIRECT_URI=${SPOTIFY_REDIRECT_URI}"
"MGMT_USERNAME=${MGMT_USERNAME}"
"MGMT_PASSWORD=${MGMT_PASSWORD}"
)
if [[ ! -f "${ENV_FILE}" ]]; then
for entry in "${vars[@]}"; do
echo "${entry}" >> "${ENV_FILE}"
done
else
for entry in "${vars[@]}"; do
local key="${entry%%=*}"
local val="${entry#*=}"
if ! grep -q "^${key}=" "${ENV_FILE}"; then
echo "${key}=${val}" >> "${ENV_FILE}"
fi
done
fi
chmod 0640 "${ENV_FILE}"
# group-readable so you can add yourself to the group if desired
chown root:"${SERVICE_GROUP}" "${ENV_FILE}" || true
}
write_systemd_unit() {
log "Writing systemd unit: /etc/systemd/system/${SERVICE_NAME}.service"
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=Bose SoundTouch Service
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
EnvironmentFile=${ENV_FILE}
WorkingDirectory=${DATA_DIR}
ExecStart=${BIN_PATH}
# Allow binding to privileged ports (80/443) without running as root
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Restart=on-failure
RestartSec=2
# Sensible hardening (compatible with privileged-port binding)
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=${DATA_DIR}
[Install]
WantedBy=multi-user.target
EOF
}
reload_enable_start() {
log "Reloading systemd, enabling and starting service"
systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"
systemctl restart "${SERVICE_NAME}.service"
log "Verifying service health..."
local health_url="http://localhost:${HTTP_PORT}/health"
local max_retries=5
local count=0
local success=false
while [[ $count -lt $max_retries ]]; do
if curl -fs "$health_url" >/dev/null 2>&1; then
success=true
break
fi
echo "Waiting for service to respond at $health_url... ($((count+1))/$max_retries)"
sleep 2
count=$((count+1))
done
if [[ "$success" = true ]]; then
log "✅ Service is healthy and responding!"
else
log "⚠️ Service started but did not respond to health check at $health_url within timeout."
log "Check logs with: journalctl -u ${SERVICE_NAME}.service -n 50"
fi
}
show_status() {
log "Service status"
systemctl --no-pager --full status "${SERVICE_NAME}.service" || true
log "Listening sockets (${HTTP_PORT}/${HTTPS_PORT})"
ss -tulpn | grep -E ":((${HTTP_PORT})|(${HTTPS_PORT}))\b" || true
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
log "Firewall check (UFW is active)"
if ! ufw status | grep -qE "${HTTP_PORT}.*ALLOW|${HTTPS_PORT}.*ALLOW"; then
log "⚠️ UFW is active but ports ${HTTP_PORT}/${HTTPS_PORT} might be blocked."
log "Run: sudo ufw allow ${HTTP_PORT}/tcp && sudo ufw allow ${HTTPS_PORT}/tcp"
else
log "✅ UFW rules for service ports appear to be in place."
fi
fi
cat <<EOF
Try from another machine:
${SERVER_URL}
${HTTPS_SERVER_URL}
If mDNS doesn't work, use the Pi's IP:
http://<pi-ip>/
https://<pi-ip>/
Logs:
journalctl -u ${SERVICE_NAME}.service -e --no-pager
EOF
}
main() {
need_root
ensure_cmd systemctl
ensure_cmd ss
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
apt_install_if_missing curl
fi
resolve_version
self_update "$@"
ensure_user_group
ensure_dirs
download_binary
write_env_file
write_systemd_unit
reload_enable_start
show_status
}
main "$@"