Add Proxmox LXC installer script
Bash executable script that installs SoulSync and all dependencies (slskd, Python, ffmpeg, etc.) inside a Debian/Ubuntu Proxmox LXC container. Features include: - Interactive setup for Soulseek credentials, Spotify API, Tidal, and media server (Plex/Jellyfin/Navidrome) configuration - Automatic slskd download, configuration, and systemd service - Python venv with all webui requirements - Proxmox community scripts-style UX (spinner, color-coded messages, emoji icons, section headers) - Auto-generated slskd API key wired into SoulSync config - MOTD banner with access URLs and service management commands - Existing installation detection with reinstall option - Full error handling with install log at /var/log/soulsync-install.log https://claude.ai/code/session_013gJJD3cxafQSVmyn9pXcdn
This commit is contained in:
parent
6a8c32329b
commit
4e490e8771
1 changed files with 861 additions and 0 deletions
861
scripts/proxmox/soulsync-install.sh
Executable file
861
scripts/proxmox/soulsync-install.sh
Executable file
|
|
@ -0,0 +1,861 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# ============================================================================
|
||||
# SoulSync Installer for Proxmox LXC
|
||||
# ============================================================================
|
||||
# Installs SoulSync and all dependencies (slskd, Python, ffmpeg, etc.)
|
||||
# inside a Debian/Ubuntu-based Proxmox LXC container.
|
||||
#
|
||||
# Usage:
|
||||
# bash soulsync-install.sh
|
||||
#
|
||||
# Requirements:
|
||||
# - Debian 12+ or Ubuntu 22.04+ LXC container
|
||||
# - Root privileges
|
||||
# - Network connectivity
|
||||
# - x86_64 / amd64 architecture
|
||||
# ============================================================================
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
# -- Constants ----------------------------------------------------------------
|
||||
|
||||
APP="SoulSync"
|
||||
SOULSYNC_REPO="https://github.com/tanujdargan/SoulSync.git"
|
||||
SOULSYNC_DIR="/opt/soulsync"
|
||||
SLSKD_DIR="/opt/slskd"
|
||||
SOULSYNC_USER="soulsync"
|
||||
SOULSYNC_GROUP="soulsync"
|
||||
LOGFILE="/var/log/soulsync-install.log"
|
||||
|
||||
SOULSYNC_PORT=8008
|
||||
SLSKD_PORT=5030
|
||||
SPOTIFY_CB_PORT=8888
|
||||
TIDAL_CB_PORT=8889
|
||||
|
||||
# -- Colors & Formatting (Proxmox community scripts style) --------------------
|
||||
|
||||
RD='\033[01;31m'
|
||||
GN='\033[1;92m'
|
||||
YW='\033[33m'
|
||||
YWB='\033[93m'
|
||||
BL='\033[36m'
|
||||
BGN='\033[4;92m'
|
||||
DGN='\033[32m'
|
||||
CL='\033[m'
|
||||
BOLD='\033[1m'
|
||||
DIM='\033[2m'
|
||||
ITALIC='\033[3m'
|
||||
TAB=' '
|
||||
BFR="\\r\\033[K"
|
||||
|
||||
CM="${TAB}✔️${TAB}${CL}"
|
||||
CROSS="${TAB}✖️${TAB}${CL}"
|
||||
INFO="${TAB}💡${TAB}${CL}"
|
||||
NETWORK="${TAB}📡${TAB}${CL}"
|
||||
CREATING="${TAB}🚀${TAB}${CL}"
|
||||
GEAR="${TAB}⚙️${TAB}${CL}"
|
||||
LOCK="${TAB}🔒${TAB}${CL}"
|
||||
MUSIC="${TAB}🎵${TAB}${CL}"
|
||||
HOURGLASS="${TAB}⏳${TAB}${CL}"
|
||||
|
||||
SPINNER_PID=""
|
||||
|
||||
# -- Ensure log file exists ---------------------------------------------------
|
||||
|
||||
touch "$LOGFILE"
|
||||
|
||||
# -- Spinner & Message Functions -----------------------------------------------
|
||||
|
||||
spinner() {
|
||||
local chars="⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
local i=0
|
||||
while true; do
|
||||
printf "\r${TAB}${YWB}%s${CL} %s" "${chars:i++%${#chars}:1}" "$1" >&2
|
||||
sleep 0.1
|
||||
done
|
||||
}
|
||||
|
||||
stop_spinner() {
|
||||
if [[ -n "${SPINNER_PID}" ]]; then
|
||||
kill "$SPINNER_PID" 2>/dev/null
|
||||
wait "$SPINNER_PID" 2>/dev/null || true
|
||||
SPINNER_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
msg_info() {
|
||||
stop_spinner
|
||||
spinner "$1" &
|
||||
SPINNER_PID=$!
|
||||
}
|
||||
|
||||
msg_ok() {
|
||||
stop_spinner
|
||||
printf "${BFR}${CM}${GN}%s${CL}\n" "$1" >&2
|
||||
}
|
||||
|
||||
msg_error() {
|
||||
stop_spinner
|
||||
printf "${BFR}${CROSS}${RD}%s${CL}\n" "$1" >&2
|
||||
}
|
||||
|
||||
msg_warn() {
|
||||
stop_spinner
|
||||
printf "${INFO}${YWB}%s${CL}\n" "$1" >&2
|
||||
}
|
||||
|
||||
# -- Cleanup on exit -----------------------------------------------------------
|
||||
|
||||
cleanup() {
|
||||
stop_spinner
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# -- Error handler -------------------------------------------------------------
|
||||
|
||||
error_handler() {
|
||||
local exit_code=$?
|
||||
local line_number=${1:-unknown}
|
||||
stop_spinner
|
||||
msg_error "Installation failed on line ${line_number} (exit code: ${exit_code})"
|
||||
echo -e "\n${TAB}${GEAR}${DIM} Check the log for details: ${LOGFILE}${CL}\n" >&2
|
||||
exit "$exit_code"
|
||||
}
|
||||
trap 'error_handler ${LINENO}' ERR
|
||||
|
||||
# -- Helpers -------------------------------------------------------------------
|
||||
|
||||
# Escape special characters for JSON string values
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
s="${s//$'\r'/\\r}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
generate_api_key() {
|
||||
openssl rand -hex 24 2>/dev/null || head -c 48 /dev/urandom | od -An -tx1 | tr -d ' \n' | head -c 48
|
||||
}
|
||||
|
||||
get_ip() {
|
||||
hostname -I 2>/dev/null | awk '{print $1}' || ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1 || echo "localhost"
|
||||
}
|
||||
|
||||
# -- Header --------------------------------------------------------------------
|
||||
|
||||
header_info() {
|
||||
clear
|
||||
echo ""
|
||||
cat <<"BANNER"
|
||||
____ _ ____
|
||||
/ ___| ___ _ _| |/ ___| _ _ _ __ ___
|
||||
\___ \ / _ \| | | | |\___ \| | | | '_ \ / __|
|
||||
___) | (_) | |_| | | ___) | |_| | | | | (__
|
||||
|____/ \___/ \__,_|_||____/ \__, |_| |_|\___|
|
||||
|___/
|
||||
BANNER
|
||||
echo ""
|
||||
echo -e " ${BL}${BOLD}Proxmox LXC Installer${CL}"
|
||||
echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# -- Prerequisite Checks ------------------------------------------------------
|
||||
|
||||
root_check() {
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
msg_error "This script must be run as root"
|
||||
echo -e "\n${TAB}Run with: ${GN}sudo bash $(basename "$0")${CL}\n" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
os_check() {
|
||||
if [[ ! -f /etc/os-release ]]; then
|
||||
msg_error "Cannot detect operating system"
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck source=/dev/null
|
||||
source /etc/os-release
|
||||
if [[ "$ID" != "debian" && "$ID" != "ubuntu" ]]; then
|
||||
msg_error "Unsupported OS: ${ID}. This script requires Debian or Ubuntu."
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "Operating System: ${ID^} ${VERSION_ID}"
|
||||
}
|
||||
|
||||
arch_check() {
|
||||
local arch
|
||||
arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
||||
if [[ "$arch" != "amd64" && "$arch" != "x86_64" ]]; then
|
||||
msg_error "Unsupported architecture: ${arch}. This script requires amd64/x86_64."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
network_check() {
|
||||
msg_info "Checking network connectivity"
|
||||
local tries=0
|
||||
while ! ping -c 1 -W 3 github.com &>/dev/null; do
|
||||
((tries++))
|
||||
if [[ $tries -ge 3 ]]; then
|
||||
msg_error "No network connectivity (cannot reach github.com)"
|
||||
echo -e "${TAB}${DIM}Ensure your LXC has a working network configuration.${CL}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
msg_ok "Network connectivity verified"
|
||||
}
|
||||
|
||||
existing_install_check() {
|
||||
if [[ -f /etc/systemd/system/soulsync.service ]]; then
|
||||
echo ""
|
||||
msg_warn "An existing SoulSync installation was detected!"
|
||||
echo ""
|
||||
if prompt_confirm "Do you want to reinstall? This will overwrite the current installation" "n"; then
|
||||
msg_info "Stopping existing services"
|
||||
systemctl stop soulsync 2>/dev/null || true
|
||||
systemctl stop slskd 2>/dev/null || true
|
||||
msg_ok "Stopped existing services"
|
||||
else
|
||||
echo -e "\n${TAB}${DIM}Installation cancelled.${CL}\n" >&2
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# -- Prompt Functions ----------------------------------------------------------
|
||||
|
||||
prompt_input() {
|
||||
local prompt="$1"
|
||||
local default="${2:-}"
|
||||
local result
|
||||
|
||||
if [[ -n "$default" ]]; then
|
||||
printf "\n${TAB}${GEAR} ${YW}%s ${DIM}[%s]${CL}: " "$prompt" "$default" >&2
|
||||
else
|
||||
printf "\n${TAB}${GEAR} ${YW}%s${CL}: " "$prompt" >&2
|
||||
fi
|
||||
read -r result
|
||||
echo "${result:-$default}"
|
||||
}
|
||||
|
||||
prompt_password() {
|
||||
local prompt="$1"
|
||||
local result
|
||||
|
||||
printf "\n${TAB}${LOCK} ${YW}%s${CL}: " "$prompt" >&2
|
||||
read -rs result
|
||||
echo "" >&2
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
prompt_confirm() {
|
||||
local prompt="$1"
|
||||
local default="${2:-y}"
|
||||
local hint result
|
||||
|
||||
if [[ "$default" == "y" ]]; then
|
||||
hint="Y/n"
|
||||
else
|
||||
hint="y/N"
|
||||
fi
|
||||
|
||||
printf "\n${TAB}${INFO} ${YW}%s ${DIM}[%s]${CL}: " "$prompt" "$hint" >&2
|
||||
read -r result
|
||||
result="${result:-$default}"
|
||||
[[ "${result,,}" =~ ^(y|yes)$ ]]
|
||||
}
|
||||
|
||||
prompt_select() {
|
||||
local prompt="$1"
|
||||
shift
|
||||
local options=("$@")
|
||||
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${GEAR} ${YW}${prompt}${CL}" >&2
|
||||
local i=1
|
||||
for opt in "${options[@]}"; do
|
||||
echo -e "${TAB}${TAB}${GN}${i})${CL} ${opt}" >&2
|
||||
((i++))
|
||||
done
|
||||
|
||||
local result
|
||||
while true; do
|
||||
printf "${TAB}${TAB}${YW}Select [1-%d]${CL}: " "${#options[@]}" >&2
|
||||
read -r result
|
||||
if [[ "$result" =~ ^[0-9]+$ ]] && ((result >= 1 && result <= ${#options[@]})); then
|
||||
echo "$result"
|
||||
return
|
||||
fi
|
||||
echo -e "${TAB}${TAB}${RD}Invalid selection, try again${CL}" >&2
|
||||
done
|
||||
}
|
||||
|
||||
# -- Installation Steps -------------------------------------------------------
|
||||
|
||||
update_os() {
|
||||
msg_info "Updating system packages"
|
||||
apt-get update -qq >>"$LOGFILE" 2>&1
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y -qq dist-upgrade >>"$LOGFILE" 2>&1
|
||||
msg_ok "System packages updated"
|
||||
}
|
||||
|
||||
install_dependencies() {
|
||||
msg_info "Installing system dependencies"
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
unzip \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
libffi-dev \
|
||||
libssl-dev \
|
||||
openssl \
|
||||
ffmpeg \
|
||||
libchromaprint-tools \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-dev \
|
||||
>>"$LOGFILE" 2>&1
|
||||
msg_ok "Installed system dependencies"
|
||||
}
|
||||
|
||||
create_user_and_dirs() {
|
||||
msg_info "Creating SoulSync user and directories"
|
||||
|
||||
if ! id -u "$SOULSYNC_USER" &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -d "$SOULSYNC_DIR" "$SOULSYNC_USER" >>"$LOGFILE" 2>&1
|
||||
fi
|
||||
|
||||
mkdir -p \
|
||||
"$SOULSYNC_DIR"/{config,data,logs,downloads,Transfer,Staging} \
|
||||
"$SLSKD_DIR"
|
||||
|
||||
msg_ok "Created user '${SOULSYNC_USER}' and directories"
|
||||
}
|
||||
|
||||
install_slskd() {
|
||||
msg_info "Fetching latest slskd release info"
|
||||
local slskd_version download_url
|
||||
|
||||
slskd_version=$(curl -fsSL "https://api.github.com/repos/slskd/slskd/releases/latest" \
|
||||
| grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
|
||||
if [[ -z "$slskd_version" ]]; then
|
||||
msg_error "Failed to determine latest slskd version"
|
||||
exit 1
|
||||
fi
|
||||
msg_ok "Latest slskd release: ${slskd_version}"
|
||||
|
||||
msg_info "Downloading slskd ${slskd_version}"
|
||||
download_url="https://github.com/slskd/slskd/releases/download/${slskd_version}/slskd-${slskd_version}-linux-x64.zip"
|
||||
wget -q "$download_url" -O /tmp/slskd.zip >>"$LOGFILE" 2>&1
|
||||
msg_ok "Downloaded slskd ${slskd_version}"
|
||||
|
||||
msg_info "Extracting slskd"
|
||||
unzip -o -q /tmp/slskd.zip -d "$SLSKD_DIR" >>"$LOGFILE" 2>&1
|
||||
chmod +x "$SLSKD_DIR/slskd"
|
||||
rm -f /tmp/slskd.zip
|
||||
msg_ok "Installed slskd to ${SLSKD_DIR}"
|
||||
}
|
||||
|
||||
configure_slskd() {
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${MUSIC} ${BOLD}${BL}Soulseek Configuration${CL}" >&2
|
||||
echo -e "${TAB}${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
echo -e "${TAB}${DIM}These credentials connect slskd to the Soulseek P2P network.${CL}" >&2
|
||||
echo -e "${TAB}${DIM}Use your existing Soulseek account or create a new one.${CL}" >&2
|
||||
|
||||
local slskd_user slskd_pass
|
||||
|
||||
slskd_user=$(prompt_input "Soulseek username")
|
||||
while [[ -z "$slskd_user" ]]; do
|
||||
msg_error "Username cannot be empty"
|
||||
slskd_user=$(prompt_input "Soulseek username")
|
||||
done
|
||||
|
||||
slskd_pass=$(prompt_password "Soulseek password")
|
||||
while [[ -z "$slskd_pass" ]]; do
|
||||
msg_error "Password cannot be empty"
|
||||
slskd_pass=$(prompt_password "Soulseek password")
|
||||
done
|
||||
|
||||
SLSKD_API_KEY=$(generate_api_key)
|
||||
|
||||
echo "" >&2
|
||||
msg_info "Writing slskd configuration"
|
||||
|
||||
# Escape values for YAML (double-quoted strings)
|
||||
local esc_user esc_pass esc_key
|
||||
esc_user=$(json_escape "$slskd_user")
|
||||
esc_pass=$(json_escape "$slskd_pass")
|
||||
esc_key=$(json_escape "$SLSKD_API_KEY")
|
||||
|
||||
cat >"$SLSKD_DIR/slskd.yml" <<YAML
|
||||
soulseek:
|
||||
username: "${esc_user}"
|
||||
password: "${esc_pass}"
|
||||
listen_port: 50300
|
||||
|
||||
web:
|
||||
port: ${SLSKD_PORT}
|
||||
authentication:
|
||||
api_keys:
|
||||
soulsync:
|
||||
key: "${esc_key}"
|
||||
role: administrator
|
||||
cidr: "0.0.0.0/0"
|
||||
|
||||
directories:
|
||||
downloads: ${SOULSYNC_DIR}/downloads
|
||||
incomplete: ${SLSKD_DIR}/incomplete
|
||||
|
||||
shares:
|
||||
directories:
|
||||
- ${SOULSYNC_DIR}/downloads
|
||||
|
||||
flags:
|
||||
no_logo: true
|
||||
YAML
|
||||
|
||||
mkdir -p "$SLSKD_DIR/incomplete"
|
||||
chown -R "$SOULSYNC_USER:$SOULSYNC_GROUP" "$SLSKD_DIR"
|
||||
chmod 600 "$SLSKD_DIR/slskd.yml"
|
||||
|
||||
msg_ok "Configured slskd (API key generated automatically)"
|
||||
}
|
||||
|
||||
install_soulsync() {
|
||||
msg_info "Cloning SoulSync repository"
|
||||
|
||||
if [[ -d "${SOULSYNC_DIR}/.git" ]]; then
|
||||
git -C "$SOULSYNC_DIR" pull -q origin main >>"$LOGFILE" 2>&1
|
||||
else
|
||||
git clone -q "$SOULSYNC_REPO" /tmp/soulsync-src >>"$LOGFILE" 2>&1
|
||||
# Copy repo contents into install dir (preserving our created subdirs)
|
||||
cp -a /tmp/soulsync-src/. "$SOULSYNC_DIR/"
|
||||
rm -rf /tmp/soulsync-src
|
||||
fi
|
||||
msg_ok "Cloned SoulSync repository"
|
||||
|
||||
msg_info "Creating Python virtual environment"
|
||||
python3 -m venv "$SOULSYNC_DIR/venv" >>"$LOGFILE" 2>&1
|
||||
msg_ok "Created Python virtual environment"
|
||||
|
||||
msg_info "Installing Python dependencies (this may take a minute)"
|
||||
"$SOULSYNC_DIR/venv/bin/pip" install --upgrade pip >>"$LOGFILE" 2>&1
|
||||
"$SOULSYNC_DIR/venv/bin/pip" install -r "$SOULSYNC_DIR/requirements-webui.txt" >>"$LOGFILE" 2>&1
|
||||
msg_ok "Installed Python dependencies"
|
||||
}
|
||||
|
||||
configure_soulsync() {
|
||||
local ip
|
||||
ip=$(get_ip)
|
||||
|
||||
# ── Spotify ──────────────────────────────────────────────────
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${MUSIC} ${BOLD}${BL}Spotify Configuration${CL}" >&2
|
||||
echo -e "${TAB}${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
echo -e "${TAB}${DIM}Create a Spotify app at:${CL}" >&2
|
||||
echo -e "${TAB} ${BGN}https://developer.spotify.com/dashboard${CL}" >&2
|
||||
echo -e "${TAB}${DIM}Set the redirect URI in your Spotify app to:${CL}" >&2
|
||||
echo -e "${TAB} ${BGN}http://${ip}:${SPOTIFY_CB_PORT}/callback${CL}" >&2
|
||||
|
||||
local spotify_id="SpotifyClientID"
|
||||
local spotify_secret="SpotifyClientSecret"
|
||||
local spotify_redirect="http://${ip}:${SPOTIFY_CB_PORT}/callback"
|
||||
|
||||
if prompt_confirm "Configure Spotify now?"; then
|
||||
spotify_id=$(prompt_input "Spotify Client ID")
|
||||
while [[ -z "$spotify_id" ]]; do
|
||||
msg_error "Client ID cannot be empty"
|
||||
spotify_id=$(prompt_input "Spotify Client ID")
|
||||
done
|
||||
spotify_secret=$(prompt_password "Spotify Client Secret")
|
||||
while [[ -z "$spotify_secret" ]]; do
|
||||
msg_error "Client Secret cannot be empty"
|
||||
spotify_secret=$(prompt_password "Spotify Client Secret")
|
||||
done
|
||||
else
|
||||
msg_warn "Skipped — configure Spotify later via the web UI"
|
||||
fi
|
||||
|
||||
# ── Tidal (optional) ─────────────────────────────────────────
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${MUSIC} ${BOLD}${BL}Tidal Configuration ${DIM}(optional)${CL}" >&2
|
||||
echo -e "${TAB}${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
|
||||
local tidal_id="TidalClientID"
|
||||
local tidal_secret="TidalClientSecret"
|
||||
local tidal_redirect="http://${ip}:${TIDAL_CB_PORT}/tidal/callback"
|
||||
|
||||
if prompt_confirm "Configure Tidal now?" "n"; then
|
||||
tidal_id=$(prompt_input "Tidal Client ID")
|
||||
tidal_secret=$(prompt_password "Tidal Client Secret")
|
||||
else
|
||||
msg_warn "Skipped — configure Tidal later via the web UI"
|
||||
fi
|
||||
|
||||
# ── Media Server ─────────────────────────────────────────────
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${MUSIC} ${BOLD}${BL}Media Server Configuration${CL}" >&2
|
||||
echo -e "${TAB}${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
|
||||
local media_server="plex"
|
||||
local plex_url="http://localhost:32400"
|
||||
local plex_token="PLEX_API_TOKEN"
|
||||
local jellyfin_url="http://localhost:8096"
|
||||
local jellyfin_key="JELLYFIN_API_KEY"
|
||||
local navidrome_url="http://localhost:4533"
|
||||
local navidrome_user="NAVIDROME_USERNAME"
|
||||
local navidrome_pass="NAVIDROME_PASSWORD"
|
||||
|
||||
local ms_choice
|
||||
ms_choice=$(prompt_select "Which media server do you use?" "Plex" "Jellyfin" "Navidrome" "Skip (configure later)")
|
||||
|
||||
case "$ms_choice" in
|
||||
1)
|
||||
media_server="plex"
|
||||
plex_url=$(prompt_input "Plex server URL" "http://localhost:32400")
|
||||
plex_token=$(prompt_input "Plex API token")
|
||||
while [[ -z "$plex_token" ]]; do
|
||||
msg_error "Token cannot be empty"
|
||||
plex_token=$(prompt_input "Plex API token")
|
||||
done
|
||||
;;
|
||||
2)
|
||||
media_server="jellyfin"
|
||||
jellyfin_url=$(prompt_input "Jellyfin server URL" "http://localhost:8096")
|
||||
jellyfin_key=$(prompt_input "Jellyfin API key")
|
||||
while [[ -z "$jellyfin_key" ]]; do
|
||||
msg_error "API key cannot be empty"
|
||||
jellyfin_key=$(prompt_input "Jellyfin API key")
|
||||
done
|
||||
;;
|
||||
3)
|
||||
media_server="navidrome"
|
||||
navidrome_url=$(prompt_input "Navidrome server URL" "http://localhost:4533")
|
||||
navidrome_user=$(prompt_input "Navidrome username" "admin")
|
||||
navidrome_pass=$(prompt_password "Navidrome password")
|
||||
while [[ -z "$navidrome_pass" ]]; do
|
||||
msg_error "Password cannot be empty"
|
||||
navidrome_pass=$(prompt_password "Navidrome password")
|
||||
done
|
||||
;;
|
||||
4)
|
||||
msg_warn "Skipped — configure your media server later via the web UI"
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Timezone ─────────────────────────────────────────────────
|
||||
echo "" >&2
|
||||
echo -e "${TAB}${GEAR} ${BOLD}${BL}General Settings${CL}" >&2
|
||||
echo -e "${TAB}${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
|
||||
local timezone
|
||||
timezone=$(prompt_input "Timezone" "America/New_York")
|
||||
|
||||
# ── Write config.json ────────────────────────────────────────
|
||||
echo "" >&2
|
||||
msg_info "Writing SoulSync configuration"
|
||||
|
||||
# Pass all values via environment variables for safety (handles special chars
|
||||
# in passwords, API keys, etc.) and use a quoted heredoc so bash doesn't
|
||||
# interfere with the Python code.
|
||||
_SS_MEDIA_SERVER="$media_server" \
|
||||
_SS_SPOTIFY_ID="$spotify_id" \
|
||||
_SS_SPOTIFY_SECRET="$spotify_secret" \
|
||||
_SS_SPOTIFY_REDIRECT="$spotify_redirect" \
|
||||
_SS_TIDAL_ID="$tidal_id" \
|
||||
_SS_TIDAL_SECRET="$tidal_secret" \
|
||||
_SS_TIDAL_REDIRECT="$tidal_redirect" \
|
||||
_SS_PLEX_URL="$plex_url" \
|
||||
_SS_PLEX_TOKEN="$plex_token" \
|
||||
_SS_JELLYFIN_URL="$jellyfin_url" \
|
||||
_SS_JELLYFIN_KEY="$jellyfin_key" \
|
||||
_SS_NAVIDROME_URL="$navidrome_url" \
|
||||
_SS_NAVIDROME_USER="$navidrome_user" \
|
||||
_SS_NAVIDROME_PASS="$navidrome_pass" \
|
||||
_SS_SLSKD_PORT="$SLSKD_PORT" \
|
||||
_SS_SLSKD_API_KEY="$SLSKD_API_KEY" \
|
||||
_SS_INSTALL_DIR="$SOULSYNC_DIR" \
|
||||
"$SOULSYNC_DIR/venv/bin/python3" - <<'PYEOF'
|
||||
import json, os
|
||||
|
||||
e = os.environ
|
||||
d = e["_SS_INSTALL_DIR"]
|
||||
|
||||
config = {
|
||||
"active_media_server": e["_SS_MEDIA_SERVER"],
|
||||
"spotify": {
|
||||
"client_id": e["_SS_SPOTIFY_ID"],
|
||||
"client_secret": e["_SS_SPOTIFY_SECRET"],
|
||||
"redirect_uri": e["_SS_SPOTIFY_REDIRECT"]
|
||||
},
|
||||
"tidal": {
|
||||
"client_id": e["_SS_TIDAL_ID"],
|
||||
"client_secret": e["_SS_TIDAL_SECRET"],
|
||||
"redirect_uri": e["_SS_TIDAL_REDIRECT"]
|
||||
},
|
||||
"plex": {
|
||||
"base_url": e["_SS_PLEX_URL"],
|
||||
"token": e["_SS_PLEX_TOKEN"],
|
||||
"auto_detect": True
|
||||
},
|
||||
"jellyfin": {
|
||||
"base_url": e["_SS_JELLYFIN_URL"],
|
||||
"api_key": e["_SS_JELLYFIN_KEY"],
|
||||
"auto_detect": True
|
||||
},
|
||||
"navidrome": {
|
||||
"base_url": e["_SS_NAVIDROME_URL"],
|
||||
"username": e["_SS_NAVIDROME_USER"],
|
||||
"password": e["_SS_NAVIDROME_PASS"],
|
||||
"auto_detect": True
|
||||
},
|
||||
"soulseek": {
|
||||
"slskd_url": f"http://localhost:{e['_SS_SLSKD_PORT']}",
|
||||
"api_key": e["_SS_SLSKD_API_KEY"],
|
||||
"download_path": f"{d}/downloads",
|
||||
"transfer_path": f"{d}/Transfer",
|
||||
"search_timeout": 60,
|
||||
"search_timeout_buffer": 15
|
||||
},
|
||||
"logging": {
|
||||
"path": f"{d}/logs/app.log",
|
||||
"level": "INFO"
|
||||
},
|
||||
"database": {
|
||||
"path": f"{d}/data/music_library.db",
|
||||
"max_workers": 5
|
||||
},
|
||||
"metadata_enhancement": {
|
||||
"enabled": True,
|
||||
"embed_album_art": True
|
||||
},
|
||||
"file_organization": {
|
||||
"enabled": True,
|
||||
"templates": {
|
||||
"album_path": "$albumartist/$albumartist - $album/$track - $title",
|
||||
"single_path": "$artist/$artist - $title/$title",
|
||||
"compilation_path": "Compilations/$album/$track - $artist - $title",
|
||||
"playlist_path": "$playlist/$artist - $title"
|
||||
}
|
||||
},
|
||||
"playlist_sync": {
|
||||
"create_backup": True
|
||||
},
|
||||
"listenbrainz": {
|
||||
"token": "LISTENBRAINZ_TOKEN"
|
||||
},
|
||||
"download_source": {
|
||||
"mode": "soulseek",
|
||||
"hybrid_primary": "soulseek",
|
||||
"youtube_min_confidence": 0.65
|
||||
},
|
||||
"acoustid": {
|
||||
"api_key": "",
|
||||
"enabled": False
|
||||
},
|
||||
"musicbrainz": {
|
||||
"embed_tags": True
|
||||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
},
|
||||
"import": {
|
||||
"staging_path": f"{d}/Staging"
|
||||
}
|
||||
}
|
||||
|
||||
with open(f"{d}/config/config.json", "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
PYEOF
|
||||
|
||||
chown -R "$SOULSYNC_USER:$SOULSYNC_GROUP" "$SOULSYNC_DIR"
|
||||
chmod 600 "$SOULSYNC_DIR/config/config.json"
|
||||
|
||||
# Write timezone to environment
|
||||
echo "TZ=${timezone}" >>"$SOULSYNC_DIR/.env"
|
||||
chown "$SOULSYNC_USER:$SOULSYNC_GROUP" "$SOULSYNC_DIR/.env"
|
||||
|
||||
msg_ok "Configured SoulSync"
|
||||
}
|
||||
|
||||
create_services() {
|
||||
msg_info "Creating systemd services"
|
||||
|
||||
# ── slskd service ──
|
||||
cat >/etc/systemd/system/slskd.service <<UNIT
|
||||
[Unit]
|
||||
Description=slskd - Soulseek Daemon
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SOULSYNC_USER}
|
||||
Group=${SOULSYNC_GROUP}
|
||||
WorkingDirectory=${SLSKD_DIR}
|
||||
ExecStart=${SLSKD_DIR}/slskd --app-dir ${SLSKD_DIR}
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
# ── SoulSync service ──
|
||||
cat >/etc/systemd/system/soulsync.service <<UNIT
|
||||
[Unit]
|
||||
Description=SoulSync - Music Library Manager
|
||||
After=network-online.target slskd.service
|
||||
Wants=network-online.target slskd.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SOULSYNC_USER}
|
||||
Group=${SOULSYNC_GROUP}
|
||||
WorkingDirectory=${SOULSYNC_DIR}
|
||||
EnvironmentFile=${SOULSYNC_DIR}/.env
|
||||
Environment=PYTHONPATH=${SOULSYNC_DIR}
|
||||
Environment=FLASK_APP=web_server.py
|
||||
Environment=FLASK_ENV=production
|
||||
Environment=DATABASE_PATH=${SOULSYNC_DIR}/data/music_library.db
|
||||
Environment=SOULSYNC_CONFIG_PATH=${SOULSYNC_DIR}/config/config.json
|
||||
ExecStart=${SOULSYNC_DIR}/venv/bin/python ${SOULSYNC_DIR}/web_server.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
systemctl daemon-reload >>"$LOGFILE" 2>&1
|
||||
msg_ok "Created systemd services"
|
||||
}
|
||||
|
||||
start_services() {
|
||||
msg_info "Starting slskd"
|
||||
systemctl enable -q slskd >>"$LOGFILE" 2>&1
|
||||
systemctl start slskd >>"$LOGFILE" 2>&1
|
||||
msg_ok "Started slskd"
|
||||
|
||||
msg_info "Starting SoulSync"
|
||||
systemctl enable -q soulsync >>"$LOGFILE" 2>&1
|
||||
systemctl start soulsync >>"$LOGFILE" 2>&1
|
||||
msg_ok "Started SoulSync"
|
||||
}
|
||||
|
||||
setup_motd() {
|
||||
local ip
|
||||
ip=$(get_ip)
|
||||
|
||||
cat >/etc/motd <<MOTD
|
||||
|
||||
____ _ ____
|
||||
/ ___| ___ _ _| |/ ___| _ _ _ __ ___
|
||||
\___ \ / _ \| | | | |\___ \| | | | '_ \ / __|
|
||||
___) | (_) | |_| | | ___) | |_| | | | | (__
|
||||
|____/ \___/ \__,_|_||____/ \__, |_| |_|\___|
|
||||
|___/
|
||||
|
||||
SoulSync: http://${ip}:${SOULSYNC_PORT}
|
||||
slskd: http://${ip}:${SLSKD_PORT}
|
||||
|
||||
Manage services:
|
||||
systemctl {start|stop|restart|status} soulsync
|
||||
systemctl {start|stop|restart|status} slskd
|
||||
|
||||
Config: ${SOULSYNC_DIR}/config/config.json
|
||||
slskd: ${SLSKD_DIR}/slskd.yml
|
||||
Logs: ${SOULSYNC_DIR}/logs/
|
||||
Downloads: ${SOULSYNC_DIR}/downloads/
|
||||
Transfer: ${SOULSYNC_DIR}/Transfer/
|
||||
|
||||
MOTD
|
||||
}
|
||||
|
||||
cleanup_install() {
|
||||
msg_info "Cleaning up"
|
||||
apt-get -y -qq autoremove >>"$LOGFILE" 2>&1
|
||||
apt-get -y -qq autoclean >>"$LOGFILE" 2>&1
|
||||
msg_ok "Cleaned up"
|
||||
}
|
||||
|
||||
# -- Completion Banner ---------------------------------------------------------
|
||||
|
||||
show_completion() {
|
||||
local ip
|
||||
ip=$(get_ip)
|
||||
|
||||
echo "" >&2
|
||||
echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
echo -e " ${CREATING}${GN}${BOLD}SoulSync has been successfully installed!${CL}" >&2
|
||||
echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${CL}" >&2
|
||||
echo "" >&2
|
||||
|
||||
echo -e " ${NETWORK}${YW}SoulSync Web UI${CL} ${BGN}http://${ip}:${SOULSYNC_PORT}${CL}" >&2
|
||||
echo -e " ${NETWORK}${YW}slskd Web UI${CL} ${BGN}http://${ip}:${SLSKD_PORT}${CL}" >&2
|
||||
|
||||
echo "" >&2
|
||||
echo -e " ${GEAR}${BOLD}Paths${CL}" >&2
|
||||
echo -e " ${TAB}${DIM}Config:${CL} ${SOULSYNC_DIR}/config/config.json" >&2
|
||||
echo -e " ${TAB}${DIM}slskd:${CL} ${SLSKD_DIR}/slskd.yml" >&2
|
||||
echo -e " ${TAB}${DIM}Downloads:${CL} ${SOULSYNC_DIR}/downloads/" >&2
|
||||
echo -e " ${TAB}${DIM}Transfer:${CL} ${SOULSYNC_DIR}/Transfer/" >&2
|
||||
echo -e " ${TAB}${DIM}Logs:${CL} ${SOULSYNC_DIR}/logs/" >&2
|
||||
echo -e " ${TAB}${DIM}Install log:${CL} ${LOGFILE}" >&2
|
||||
|
||||
echo "" >&2
|
||||
echo -e " ${GEAR}${BOLD}Service Management${CL}" >&2
|
||||
echo -e " ${TAB}${DIM}systemctl {start|stop|restart|status} soulsync${CL}" >&2
|
||||
echo -e " ${TAB}${DIM}systemctl {start|stop|restart|status} slskd${CL}" >&2
|
||||
|
||||
echo "" >&2
|
||||
echo -e " ${INFO}${YW}Next steps:${CL}" >&2
|
||||
echo -e " ${TAB}1. Open the SoulSync web UI at ${BGN}http://${ip}:${SOULSYNC_PORT}${CL}" >&2
|
||||
echo -e " ${TAB}2. Complete Spotify OAuth by clicking 'Connect' in Settings" >&2
|
||||
echo -e " ${TAB}3. Start syncing your music library!" >&2
|
||||
echo "" >&2
|
||||
}
|
||||
|
||||
# -- Main Execution ------------------------------------------------------------
|
||||
|
||||
main() {
|
||||
header_info
|
||||
root_check
|
||||
arch_check
|
||||
os_check
|
||||
network_check
|
||||
existing_install_check
|
||||
|
||||
echo "" >&2
|
||||
echo -e " ${CREATING}${BOLD}${GN}Starting SoulSync installation${CL}" >&2
|
||||
echo "" >&2
|
||||
|
||||
update_os
|
||||
install_dependencies
|
||||
create_user_and_dirs
|
||||
install_slskd
|
||||
configure_slskd
|
||||
install_soulsync
|
||||
configure_soulsync
|
||||
create_services
|
||||
start_services
|
||||
setup_motd
|
||||
cleanup_install
|
||||
show_completion
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Loading…
Reference in a new issue