Files

299 lines
9.8 KiB
Bash

#!/usr/bin/env bash
# ABOUTME: One-shot installer for sendspin-player on 64-bit Raspberry Pi OS.
# ABOUTME: Fetches the latest arm64 release tarball, installs systemd unit, starts daemon.
set -euo pipefail
on_exit() {
local rc=$?
if [[ "${rc}" -ne 0 ]]; then
printf '\nIf install failed mid-way, re-running the script is safe — it is idempotent.\n' >&2
fi
}
trap on_exit EXIT
readonly REPO_OWNER="Sendspin"
readonly REPO_NAME="sendspin-go"
readonly REPO_URL="https://github.com/${REPO_OWNER}/${REPO_NAME}"
readonly RAW_URL_BASE="https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}"
readonly BINARY_NAME="sendspin-player"
readonly INSTALL_PATH="/usr/local/bin/${BINARY_NAME}"
readonly UNIT_PATH="/etc/systemd/system/${BINARY_NAME}.service"
readonly ENV_PATH="/etc/default/${BINARY_NAME}"
readonly CONFIG_DIR="/etc/sendspin"
readonly CONFIG_PATH="${CONFIG_DIR}/player.yaml"
# Set by parse_args
ARG_NAME=""
ARG_DEVICE=""
ARG_VERSION=""
ARG_UNINSTALL=0
# Resolved by resolve_version
RESOLVED_TAG=""
RESOLVED_REF=""
usage() {
cat <<EOF
Usage: quickstart-pi.sh [--name <s>] [--device <s>] [--version <tag>] [--uninstall]
Installs sendspin-player as a systemd daemon on 64-bit Raspberry Pi OS.
Options:
--name <s> Friendly player name (default: <hostname>-sendspin-player).
--device <s> Exact audio device name. Run 'sendspin-player --list-audio-devices' after
install to discover available names.
--version <tag> Pin to a specific release tag (e.g. v1.6.2). Default: latest.
--uninstall Stop the service and remove the binary and unit file. Config is preserved.
-h, --help Show this help.
Run with sudo:
curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash
curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash -s -- --name "Living Room"
EOF
}
log() { printf '==> %s\n' "$*"; }
warn() { printf 'WARN: %s\n' "$*" >&2; }
die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--name)
[[ $# -ge 2 ]] || die "--name requires a value"
ARG_NAME="$2"
shift 2
;;
--device)
[[ $# -ge 2 ]] || die "--device requires a value"
ARG_DEVICE="$2"
shift 2
;;
--version)
[[ $# -ge 2 ]] || die "--version requires a value (e.g. v1.6.2)"
ARG_VERSION="$2"
shift 2
;;
--uninstall)
ARG_UNINSTALL=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
printf 'Unknown argument: %s\n\n' "$1" >&2
usage >&2
exit 2
;;
esac
done
}
preflight() {
if [[ "${EUID}" -ne 0 ]]; then
die "Root required. Re-run with sudo:
curl -fsSL ${RAW_URL_BASE}/main/scripts/quickstart-pi.sh | sudo bash"
fi
local arch
arch="$(uname -m)"
if [[ "${arch}" != "aarch64" ]]; then
die "Unsupported architecture: ${arch}. This script supports 64-bit
Raspberry Pi OS only (aarch64). For Pi 3 / 4 / 5 / Zero 2 W, install
the 64-bit Pi OS image: https://www.raspberrypi.com/software/operating-systems/
Pi 1 / Zero (v1) / Zero W are not supported (32-bit ARMv6 only)."
fi
if [[ ! -f /etc/debian_version ]]; then
die "Unsupported OS. This script targets Debian-based distros (Pi OS,
Raspberry Pi OS Lite). For other distros see the README install steps:
${REPO_URL}#installation"
fi
if ! command -v systemctl >/dev/null 2>&1; then
die "systemctl not found. The quickstart installs sendspin-player as a
systemd service; non-systemd hosts must follow the manual install steps."
fi
}
do_uninstall() {
log "Uninstalling sendspin-player..."
if systemctl list-unit-files "${BINARY_NAME}.service" >/dev/null 2>&1; then
systemctl disable --now "${BINARY_NAME}.service" 2>/dev/null || true
fi
rm -f "${INSTALL_PATH}"
rm -f "${UNIT_PATH}"
systemctl daemon-reload
log "Uninstall complete."
log "Config preserved at ${CONFIG_DIR}/ and ${ENV_PATH}."
log "Remove manually for a full purge:"
log " sudo rm -rf ${CONFIG_DIR} ${ENV_PATH}"
}
install_apt_deps() {
log "Installing runtime dependencies..."
DEBIAN_FRONTEND=noninteractive apt-get update -qq
# FLAC decode/encode is pure-Go (mewkiz/flac); the released binary is
# built with -tags=nolibopusfile so libopusfile is not linked either.
# apt resolves libasound2 to libasound2t64 automatically on time_t-64
# distros (Trixie, Ubuntu 24.04).
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libopus0 \
libasound2 \
ca-certificates \
curl \
tar
}
resolve_version() {
if [[ -n "${ARG_VERSION}" ]]; then
RESOLVED_TAG="${ARG_VERSION}"
RESOLVED_REF="${ARG_VERSION}"
log "Installing pinned version: ${RESOLVED_TAG}"
else
# Use GitHub's latest-release redirect: no API call, no JSON parsing.
# The redirect target tells us the resolved tag.
local redirect_url
redirect_url="$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
"${REPO_URL}/releases/latest")" \
|| die "Failed to resolve latest release tag from ${REPO_URL}/releases/latest"
RESOLVED_TAG="${redirect_url##*/}"
# The dist/ files at "main" are forward-compatible enough for the
# latest-tagged release; pin them to the same tag for consistency.
RESOLVED_REF="${RESOLVED_TAG}"
log "Installing latest release: ${RESOLVED_TAG}"
fi
}
stop_service() {
if systemctl is-active --quiet "${BINARY_NAME}.service"; then
log "Stopping running ${BINARY_NAME} service..."
systemctl stop "${BINARY_NAME}.service"
fi
}
install_binary() {
local tarball_url tarball_name binary_in_tarball tmpdir
binary_in_tarball="${BINARY_NAME}-linux-arm64"
tarball_name="${binary_in_tarball}.tar.gz"
tarball_url="${REPO_URL}/releases/download/${RESOLVED_TAG}/${tarball_name}"
tmpdir="$(mktemp -d)"
# shellcheck disable=SC2064
trap "rm -rf '${tmpdir}'; on_exit" EXIT
log "Downloading ${tarball_url}..."
curl -fSL "${tarball_url}" -o "${tmpdir}/${tarball_name}" \
|| die "Failed to download release tarball from ${tarball_url}"
log "Extracting..."
tar -xzf "${tmpdir}/${tarball_name}" -C "${tmpdir}"
if [[ ! -f "${tmpdir}/${binary_in_tarball}" ]]; then
die "Tarball did not contain expected binary '${binary_in_tarball}'"
fi
log "Installing ${INSTALL_PATH}..."
install -m 755 "${tmpdir}/${binary_in_tarball}" "${INSTALL_PATH}"
}
install_unit() {
local unit_url
unit_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/systemd/${BINARY_NAME}.service"
log "Installing systemd unit ${UNIT_PATH}..."
curl -fSL "${unit_url}" -o "${UNIT_PATH}" \
|| die "Failed to download unit file from ${unit_url}"
chmod 644 "${UNIT_PATH}"
}
# shell_quote: wrap a string in single quotes, escaping any embedded single
# quotes via the standard '\'' pattern. Safe for arbitrary user input.
shell_quote() {
local s="$1"
s="${s//\'/\'\\\'\'}"
printf "'%s'" "${s}"
}
install_env() {
if [[ -n "${ARG_NAME}" || -n "${ARG_DEVICE}" ]]; then
log "Writing ${ENV_PATH} with --name/--device from flags..."
local opts=""
if [[ -n "${ARG_NAME}" ]]; then
opts+="--name $(shell_quote "${ARG_NAME}") "
fi
if [[ -n "${ARG_DEVICE}" ]]; then
opts+="--audio-device $(shell_quote "${ARG_DEVICE}") "
fi
# Trim trailing space.
opts="${opts% }"
cat >"${ENV_PATH}" <<EOF
# /etc/default/sendspin-player
# Written by quickstart-pi.sh. Edit freely; re-running quickstart with
# --name/--device will overwrite this file.
SENDSPIN_PLAYER_OPTS="${opts}"
EOF
chmod 644 "${ENV_PATH}"
return
fi
if [[ ! -f "${ENV_PATH}" ]]; then
local env_url
env_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/systemd/${BINARY_NAME}.env"
log "Installing example env file ${ENV_PATH}..."
curl -fSL "${env_url}" -o "${ENV_PATH}" \
|| die "Failed to download env file from ${env_url}"
chmod 644 "${ENV_PATH}"
fi
}
install_config() {
if [[ -f "${CONFIG_PATH}" ]]; then
log "Preserving existing ${CONFIG_PATH}"
return
fi
local config_url
config_url="${RAW_URL_BASE}/${RESOLVED_REF}/dist/config/player.example.yaml"
log "Installing example config ${CONFIG_PATH}..."
install -d -m 755 "${CONFIG_DIR}"
curl -fSL "${config_url}" -o "${CONFIG_PATH}" \
|| die "Failed to download config from ${config_url}"
chmod 644 "${CONFIG_PATH}"
}
start_and_verify() {
log "Reloading systemd and starting service..."
systemctl daemon-reload
systemctl enable --now "${BINARY_NAME}.service"
sleep 2
if ! systemctl is-active --quiet "${BINARY_NAME}.service"; then
warn "Service failed to come up. Recent logs:"
journalctl -u "${BINARY_NAME}.service" --no-pager -n 20 || true
die "${BINARY_NAME} service is not active. See logs above."
fi
log ""
log "sendspin-player ${RESOLVED_TAG} installed and running."
log " Binary: ${INSTALL_PATH}"
log " Config: ${CONFIG_PATH}"
log " Env: ${ENV_PATH}"
log " Logs: journalctl -u ${BINARY_NAME} -f"
log " Devices: ${BINARY_NAME} --list-audio-devices"
}
main() {
parse_args "$@"
preflight
if [[ "${ARG_UNINSTALL}" -eq 1 ]]; then
do_uninstall
exit 0
fi
install_apt_deps
resolve_version
stop_service
install_binary
install_unit
install_env
install_config
start_and_verify
}
main "$@"