Files
allstarr/allstarr.sh
T

439 lines
17 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "${BASH_SOURCE[0]%/*}" && pwd)"
PROFILE_FILE="$ROOT/.allstarr-profiles"
MODE_FILE="$ROOT/.allstarr-mode"
die() { echo "allstarr: $*" >&2; exit 1; }
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; }
profiles() {
local values=(standard)
if [[ -f "$PROFILE_FILE" ]]; then
while IFS= read -r value; do
case "$value" in
spotify|spotify-lyrics) values+=(spotify-lyrics) ;;
apple) values+=("$value") ;;
esac
done < "$PROFILE_FILE"
fi
printf '%s\n' "${values[@]}" | awk '!seen[$0]++'
}
deployment_mode() {
local mode="release"
[[ -f "$MODE_FILE" ]] && read -r mode < "$MODE_FILE"
case "$mode" in release|source) printf '%s\n' "$mode" ;; *) die "invalid deployment mode in .allstarr-mode" ;; esac
}
set_mode() {
local mode="${1:-}"
case "$mode" in release|source) printf '%s\n' "$mode" > "$MODE_FILE" ;; *) die "mode must be release or source" ;; esac
echo "Deployment mode set to $mode. Run: ./allstarr.sh up"
}
compose_args() {
COMPOSE=(-f "$ROOT/docker-compose.yml")
while IFS= read -r profile; do
case "$profile" in
spotify-lyrics) COMPOSE+=(--profile spotify-lyrics) ;;
apple) COMPOSE+=(--profile apple) ;;
esac
done < <(profiles)
}
start_stack() {
docker compose "${COMPOSE[@]}" up -d --remove-orphans --wait --wait-timeout 180
}
remember_profile() {
local wanted="$1" temporary
[[ "$wanted" == spotify ]] && wanted=spotify-lyrics
touch "$PROFILE_FILE"
temporary="$(mktemp)"
awk '{ if ($0 == "spotify") $0 = "spotify-lyrics"; if (!seen[$0]++) print }' "$PROFILE_FILE" > "$temporary"
mv "$temporary" "$PROFILE_FILE"
grep -qxF "$wanted" "$PROFILE_FILE" 2>/dev/null || printf '%s\n' "$wanted" >> "$PROFILE_FILE"
}
forget_profile() {
local unwanted="$1" temporary
[[ "$unwanted" == spotify ]] && unwanted=spotify-lyrics
temporary="$(mktemp)"
if [[ -f "$PROFILE_FILE" ]]; then
if [[ "$unwanted" == spotify-lyrics ]]; then
grep -Ev '^(spotify|spotify-lyrics)$' "$PROFILE_FILE" > "$temporary" || true
else
grep -vxF "$unwanted" "$PROFILE_FILE" > "$temporary" || true
fi
fi
mv "$temporary" "$PROFILE_FILE"
}
init() {
need docker
need openssl
docker compose version >/dev/null
[[ -f "$ROOT/.env" ]] || cp "$ROOT/.env.example" "$ROOT/.env"
install -d -m 700 "$ROOT/secrets"
install -d -m 755 "$ROOT/downloads" "$ROOT/kept" "$ROOT/.apple-provider/incoming"
if [[ ! -s "$ROOT/secrets/postgres-password.txt" ]]; then
umask 077
openssl rand -base64 36 > "$ROOT/secrets/postgres-password.txt"
fi
if [[ ! -s "$ROOT/secrets/allstarr-keyring.json" ]]; then
umask 077
local key
key="$(openssl rand -base64 32)"
printf '{"activeKeyId":"key-1","keys":{"key-1":"%s"}}\n' "$key" > "$ROOT/secrets/allstarr-keyring.json"
fi
chmod 600 "$ROOT/secrets/postgres-password.txt" "$ROOT/secrets/allstarr-keyring.json"
touch "$PROFILE_FILE"
[[ -f "$MODE_FILE" ]] || printf '%s\n' "${1:-release}" > "$MODE_FILE"
deployment_mode >/dev/null
echo "Allstarr is initialized. Edit .env, then run: ./allstarr.sh up"
}
prepare_apple() {
local input="${1:-}" arch="${2:-}" runtime="linux/amd64"
[[ -f "$ROOT/.env" ]] || die "run ./allstarr.sh init before enabling providers"
if [[ "$input" == "x86_64" || "$input" == "arm64-v8a" ]]; then
arch="$input"
input=""
fi
if [[ -z "$arch" ]]; then
case "$(uname -m)" in
x86_64|amd64) arch="x86_64" ;;
arm64|aarch64) arch="arm64-v8a"; runtime="linux/arm64" ;;
*) die "could not detect Apple architecture; pass x86_64 or arm64-v8a explicitly" ;;
esac
fi
if [[ -z "$input" ]]; then
local candidate
for candidate in "$ROOT"/.apple-provider/incoming/*.apk "$ROOT"/.apple-provider/incoming/*.apkm; do
if [[ -f "$candidate" && ( -z "$input" || "$candidate" -nt "$input" ) ]]; then
input="$candidate"
fi
done
fi
[[ -n "$input" && ( -f "$input" || -d "$input" ) ]] || die "no staged Apple package found; upload an .apk/.apkm in Sources > Apple download first"
case "$arch" in
x86_64) ;;
arm64-v8a) runtime=linux/arm64 ;;
*) die "Apple architecture must be x86_64 or arm64-v8a" ;;
esac
if [[ -d "$input" ]]; then
bash "$ROOT/tools/apple-provider/prepare.sh" --staged-libs "$input" --arch "$arch"
else
bash "$ROOT/tools/apple-provider/prepare.sh" --apkm "$input" --arch "$arch"
fi
if grep -q '^APPLE_WRAPPER_TARGET_ARCH=' "$ROOT/.env"; then
sed -i.bak "s|^APPLE_WRAPPER_TARGET_ARCH=.*|APPLE_WRAPPER_TARGET_ARCH=$arch|" "$ROOT/.env"
sed -i.bak "s|^APPLE_WRAPPER_RUNTIME_PLATFORM=.*|APPLE_WRAPPER_RUNTIME_PLATFORM=$runtime|" "$ROOT/.env"
rm -f "$ROOT/.env.bak"
else
printf '\nAPPLE_WRAPPER_TARGET_ARCH=%s\nAPPLE_WRAPPER_RUNTIME_PLATFORM=%s\n' "$arch" "$runtime" >> "$ROOT/.env"
fi
remember_profile apple
compose_args
docker compose "${COMPOSE[@]}" build apple-wrapper apple-gateway
echo "Apple provider source, verified native libraries, and local images are ready. Run: ./allstarr.sh up"
}
install_apple() {
prepare_apple "$@"
up
}
validate_deployment_files() {
local env_file="$ROOT/.env"
[[ -f "$env_file" ]] || die "missing .env; run ./allstarr.sh init first"
awk '
/^[[:space:]]*($|#)/ { next }
!/^[A-Za-z_][A-Za-z0-9_]*=/ {
printf "Invalid .env line %d: expected KEY=value\n", NR > "/dev/stderr"
bad = 1
next
}
{
key = $0
sub(/=.*/, "", key)
if (seen[key]++) {
printf "Duplicate .env key on line %d: %s\n", NR, key > "/dev/stderr"
bad = 1
}
}
END { exit bad ? 1 : 0 }
' "$env_file"
if [[ -f "$PROFILE_FILE" ]]; then
while IFS= read -r profile; do
case "$profile" in
""|apple|spotify|spotify-lyrics) ;;
*) die "unsupported saved profile '$profile'; use apple or spotify-lyrics" ;;
esac
done < "$PROFILE_FILE"
fi
}
up() {
validate_deployment_files
compose_args
docker compose "${COMPOSE[@]}" config --quiet
if [[ "$(deployment_mode)" == source ]]; then
docker compose "${COMPOSE[@]}" build allstarr
fi
start_stack
docker compose "${COMPOSE[@]}" ps
}
update() {
validate_deployment_files
compose_args
docker compose "${COMPOSE[@]}" config --quiet
if [[ "$(deployment_mode)" == release ]]; then
docker compose "${COMPOSE[@]}" pull postgres allstarr
if profiles | grep -qx spotify-lyrics; then
docker compose "${COMPOSE[@]}" pull spotify-lyrics
fi
fi
if [[ "$(deployment_mode)" == source ]]; then
need git
[[ -d "$ROOT/.git" ]] || die "source mode requires a Git checkout"
git diff --quiet && git diff --cached --quiet ||
die "tracked source files have local changes; commit or stash them before updating"
git pull --ff-only
docker compose "${COMPOSE[@]}" build allstarr
if profiles | grep -qx apple; then
docker compose "${COMPOSE[@]}" build apple-gateway
fi
elif profiles | grep -qx apple; then
docker compose "${COMPOSE[@]}" build apple-gateway
fi
start_stack
docker compose "${COMPOSE[@]}" ps
}
create_state_archive() {
local output_dir="$1" staging archive runtime_image host_uid host_gid archive_paths
local -a optional_volume_mounts=()
output_dir="$(mkdir -p "$output_dir" && cd "$output_dir" && pwd)"
chmod 700 "$output_dir"
staging="$(mktemp -d "$output_dir/.allstarr-export.XXXXXX")"
archive="$output_dir/allstarr-upgrade-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
runtime_image="$(docker compose "${COMPOSE[@]}" images -q postgres | head -1)"
[[ -n "$runtime_image" ]] || die "the Postgres image must exist before state can be exported"
host_uid="$(id -u)"
host_gid="$(id -g)"
archive_paths="volume-state volume-cache volume-postgres"
while IFS='|' read -r volume_name archive_path; do
if docker volume inspect "$volume_name" >/dev/null 2>&1; then
optional_volume_mounts+=(-v "$volume_name:/$archive_path:ro")
archive_paths+=" $archive_path"
fi
done <<'EOF'
allstarr_apple-gateway-data|volume-apple-gateway
allstarr_apple-wrapper-session|volume-apple-wrapper-session
EOF
docker run --rm --read-only \
-e HOST_UID="$host_uid" -e HOST_GID="$host_gid" \
-e ARCHIVE_PATHS="$archive_paths" \
-v allstarr_allstarr-state:/volume-state:ro \
-v allstarr_allstarr-cache:/volume-cache:ro \
-v allstarr_postgres-data:/volume-postgres:ro \
"${optional_volume_mounts[@]}" \
-v "$staging:/export" \
"$runtime_image" sh -c '
tar -czf /export/volume-data.tar.gz -C / $ARCHIVE_PATHS &&
chown "$HOST_UID:$HOST_GID" /export/volume-data.tar.gz &&
chmod 600 /export/volume-data.tar.gz
'
tar -cf "$staging/deployment-files.tar" --ignore-failed-read \
.env .allstarr-profiles .allstarr-mode secrets .apple-provider
printf '%s\n' \
'Allstarr portable upgrade export' \
"Created: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'Includes: configuration, encryption keyring, provider profiles, Postgres, mappings, playlist caches, durable application state, and Apple provider/session volumes when present.' \
'Does not include downloaded or kept music; those host folders remain where the user mounted them.' \
> "$staging/README.txt"
tar -czf "$archive" -C "$staging" README.txt deployment-files.tar volume-data.tar.gz
chmod 600 "$archive"
rm -r "$staging"
printf '%s\n' "$archive"
}
backup_state() {
local output_dir="${1:-$ROOT/allstarr-backups}" restart_after="${2:-true}" was_running result archive
compose_args
was_running="$(docker compose "${COMPOSE[@]}" ps --status running -q | head -1)"
echo "Stopping Allstarr briefly so every database and cache file is consistent..."
docker compose "${COMPOSE[@]}" stop
set +e
archive="$(create_state_archive "$output_dir")"
result=$?
set -e
if [[ "$restart_after" == true && -n "$was_running" ]]; then
start_stack
fi
[[ $result -eq 0 ]] || die "state export failed; the stopped services were left unchanged"
echo "Portable upgrade export created: $archive"
}
upgrade() {
backup_state "${1:-$ROOT/allstarr-backups}" false
update
}
validate_restore_archive() {
local archive="$1" staging="$2" entry
[[ -f "$archive" ]] || die "backup archive not found: $archive"
case "$archive" in *.tar.gz|*.tgz) ;; *) die "restore requires an Allstarr .tar.gz backup" ;; esac
while IFS= read -r entry; do
case "$entry" in README.txt|deployment-files.tar|volume-data.tar.gz) ;;
*) die "backup contains an unexpected top-level entry: $entry" ;;
esac
done < <(tar -tzf "$archive")
tar -xzf "$archive" -C "$staging"
[[ -s "$staging/deployment-files.tar" && -s "$staging/volume-data.tar.gz" ]] ||
die "backup is incomplete; deployment-files.tar and volume-data.tar.gz are required"
! tar -tvf "$staging/deployment-files.tar" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }' ||
die "backup deployment files may not contain links"
! tar -tvzf "$staging/volume-data.tar.gz" | awk '$1 ~ /^[lh]/ { found=1 } END { exit !found }' ||
die "backup volume data may not contain links"
while IFS= read -r entry; do
entry="${entry#./}"
case "$entry" in
.env|.allstarr-profiles|.allstarr-mode|secrets|secrets/*|.apple-provider|.apple-provider/*) ;;
*) die "backup contains an unsafe deployment path: $entry" ;;
esac
done < <(tar -tf "$staging/deployment-files.tar")
while IFS= read -r entry; do
entry="${entry#./}"
case "$entry" in
volume-state|volume-state/*|volume-cache|volume-cache/*|volume-postgres|volume-postgres/*|volume-apple-gateway|volume-apple-gateway/*|volume-apple-wrapper-session|volume-apple-wrapper-session/*) ;;
*) die "backup contains an unsafe volume path: $entry" ;;
esac
done < <(tar -tzf "$staging/volume-data.tar.gz")
}
restore_state() {
local archive="${1:-}" confirmation="${2:-}" staging runtime_image was_running rollback_dir restore_paths
local -a optional_volume_mounts=()
[[ -n "$archive" ]] || die "usage: ./allstarr.sh restore BACKUP.tar.gz --confirm-replace"
[[ "$confirmation" == "--confirm-replace" ]] ||
die "restore replaces this installation's config, secrets, database, mappings, and caches; rerun with --confirm-replace"
need docker
need tar
archive="$(cd "$(dirname "$archive")" && pwd)/$(basename "$archive")"
staging="$(mktemp -d)"
trap 'rm -rf "$staging"' EXIT
validate_restore_archive "$archive" "$staging"
compose_args
runtime_image="$(docker compose "${COMPOSE[@]}" images -q postgres | head -1)"
[[ -n "$runtime_image" ]] || die "initialize or pull the Allstarr stack before restoring"
was_running="$(docker compose "${COMPOSE[@]}" ps --status running -q | head -1)"
rollback_dir="$ROOT/allstarr-backups/pre-restore"
echo "Creating a rollback backup of the current installation..."
backup_state "$rollback_dir" false
echo "Restoring configuration, encrypted accounts, databases, mappings, and caches..."
tar -xf "$staging/deployment-files.tar" -C "$ROOT"
chmod 600 "$ROOT/.env" "$ROOT/secrets/postgres-password.txt" "$ROOT/secrets/allstarr-keyring.json" 2>/dev/null || true
restore_paths="/volume-state /volume-cache /volume-postgres"
while IFS='|' read -r archive_path volume_name; do
if tar -tzf "$staging/volume-data.tar.gz" | grep -q "^$archive_path\\(/\\|$\\)"; then
optional_volume_mounts+=(-v "$volume_name:/$archive_path")
restore_paths+=" /$archive_path"
fi
done <<'EOF'
volume-apple-gateway|allstarr_apple-gateway-data
volume-apple-wrapper-session|allstarr_apple-wrapper-session
EOF
docker run --rm --read-only \
-e RESTORE_PATHS="$restore_paths" \
-v allstarr_allstarr-state:/volume-state \
-v allstarr_allstarr-cache:/volume-cache \
-v allstarr_postgres-data:/volume-postgres \
"${optional_volume_mounts[@]}" \
-v "$staging:/restore:ro" \
"$runtime_image" sh -c '
find $RESTORE_PATHS -mindepth 1 -delete &&
tar -xzf /restore/volume-data.tar.gz -C /
'
if [[ -n "$was_running" ]]; then
compose_args
start_stack
fi
echo "Restore complete. A rollback backup of the replaced installation is in: $rollback_dir"
rm -rf "$staging"
trap - EXIT
}
usage() {
cat <<'EOF'
Usage: ./allstarr.sh COMMAND
init [release|source] Create config; default to release images
mode [release|source] Show or change the saved deployment mode
up Start the saved deployment profile
update Pull the saved release/source and safely recreate
upgrade [OUTPUT_DIR] Export all user state, then update and restart
backup [OUTPUT_DIR] Export config, secrets, databases, mappings, and caches
restore BACKUP --confirm-replace Restore a portable backup; saves current state first
status Show containers and the saved profile
logs [service] Follow redacted container logs
enable spotify-lyrics Add an optional saved profile
disable spotify-lyrics|apple Remove an optional profile on next up
prepare-apple [INPUT] [ARCH] Verify an APK/APKM or staged libs; enable Apple
install-apple [ARCH] Build and start Apple from the WebUI-staged package
down Stop containers without deleting data
The deployment mode is saved in .allstarr-mode. Release mode pulls reviewed
images; source mode fast-forwards its tracked branch, then builds the local image.
Optional profiles are saved in .allstarr-profiles. No command deletes volumes,
Postgres data, managed music, provider sessions, or imported settings.
EOF
}
command="${1:-help}"
shift || true
cd "$ROOT"
case "$command" in
init)
case "${1:-release}" in release|source) init "${1:-release}" ;; *) die "init mode must be release or source" ;; esac
;;
mode)
if [[ $# -eq 0 ]]; then deployment_mode; else set_mode "$1"; fi
;;
prepare-apple) prepare_apple "$@" ;;
install-apple) install_apple "$@" ;;
up) up ;;
update) update ;;
upgrade) upgrade "$@" ;;
backup) backup_state "${1:-$ROOT/allstarr-backups}" true ;;
restore) restore_state "$@" ;;
status) compose_args; echo "Mode: $(deployment_mode)"; echo "Profiles: $(profiles | paste -sd, -)"; docker compose "${COMPOSE[@]}" ps ;;
logs) compose_args; docker compose "${COMPOSE[@]}" logs --tail=200 -f "$@" ;;
enable)
case "${1:-}" in
spotify|spotify-lyrics) remember_profile "$1" ;;
apple) die "use prepare-apple for Apple so its libraries are verified first" ;;
*) die "choose spotify-lyrics" ;;
esac
echo "Profile enabled. Run: ./allstarr.sh up"
;;
disable)
case "${1:-}" in spotify|spotify-lyrics|apple) forget_profile "$1" ;; *) die "choose spotify-lyrics or apple" ;; esac
echo "Profile disabled. Run ./allstarr.sh up to apply; stored data is preserved."
;;
down) compose_args; docker compose "${COMPOSE[@]}" down ;;
help|-h|--help) usage ;;
*) usage; exit 2 ;;
esac