assemblrr/bin/cli.sh

1028 lines
36 KiB
Bash

#!/bin/bash
set -euo pipefail
# assemblrr CLI — lives in the install tree (or repo bin/). PATH is a wrapper.
_cli_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Leftover full copies in ~/.local/bin must not run; exec the install-tree CLI.
if [ "$_cli_self" = "$HOME/.local/bin" ] || [ "$_cli_self" = "/usr/local/bin" ]; then
_wrap_dir="${ASSEMBLRR_DIR:-}"
if [ -z "$_wrap_dir" ]; then
for _wrap_f in \
/opt/assemblrr/.assemblrr-config \
"$HOME/.assemblrr-config" \
"$HOME/assemblrr/.assemblrr-config"
do
if [ -f "$_wrap_f" ]; then
_wrap_dir=$(grep -E '^INSTALL_DIRECTORY=' "$_wrap_f" | head -1 || true)
_wrap_dir="${_wrap_dir#INSTALL_DIRECTORY=}"
_wrap_dir="${_wrap_dir%$'\r'}"
_wrap_dir="${_wrap_dir#\"}"
_wrap_dir="${_wrap_dir%\"}"
[ -n "$_wrap_dir" ] && break
fi
done
fi
if [ -n "${_wrap_dir:-}" ] && [ -f "$_wrap_dir/cli.sh" ]; then
exec bash "$_wrap_dir/cli.sh" "$@"
fi
echo "assemblrr: could not find the install. Run setup, or set ASSEMBLRR_DIR." >&2
exit 1
fi
# Locate lib (repo: sibling of bin/; install: lib/ next to cli.sh)
if [ -f "$_cli_self/../lib/core.sh" ]; then
_lib_dir="$_cli_self/../lib"
elif [ -f "$_cli_self/lib/core.sh" ]; then
_lib_dir="$_cli_self/lib"
else
echo -e "\033[0;31mError: lib/core.sh not found. Re-run setup.\033[0m" >&2
exit 1
fi
source "$_lib_dir/core.sh"
source "$_lib_dir/branding.sh"
source "$_lib_dir/compose.sh"
if [ -f "$_lib_dir/quality.sh" ]; then
source "$_lib_dir/quality.sh"
fi
source "$_lib_dir/vpn.sh"
if [ -f "$_lib_dir/services.sh" ]; then
source "$_lib_dir/services.sh"
fi
# Upgrade engine (optional on older installs until first upgrade copies these)
if [ -f "$_lib_dir/managed_files.sh" ]; then
source "$_lib_dir/managed_files.sh"
fi
if [ -f "$_lib_dir/upgrade.sh" ]; then
source "$_lib_dir/upgrade.sh"
fi
INSTALL_DIR=$(find_install_directory)
if [ -z "$INSTALL_DIR" ]; then
log_error "Could not find ${APP_NAME:-assemblrr} installation. Run setup first."
fi
# Pointer first — has MEDIA_DIRECTORY even when the install tree never finished
# (path picker chose /mnt/e, then copy hung). Install-dir config overrides.
load_install_pointer || true
if [ -f "$INSTALL_DIR/.assemblrr-config" ]; then
safe_source "$INSTALL_DIR/.assemblrr-config"
elif [ -f "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config" ]; then
safe_source "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config"
fi
VPN_ENABLED="${VPN_ENABLED:-n}"
VPN_TYPE="${VPN_TYPE:-openvpn}"
MEDIA_SERVICE="${MEDIA_SERVICE:-jellyfin}"
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-${HOME}/assemblrr-media}"
TZ="${TZ:-UTC}"
# Source branding from install directory
load_branding "$INSTALL_DIR" 2>/dev/null || true
# Build compose command dynamically (uses lib/compose.sh build_compose_args + adds profile)
if [ -f "$INSTALL_DIR/compose/base.yaml" ]; then
build_compose_args "$INSTALL_DIR" "${VPN_ENABLED:-n}"
DC=(run_docker compose "${COMPOSE_ARGS[@]}" "--profile" "${MEDIA_SERVICE:-jellyfin}")
else
# Mid-setup / incomplete tree — uninstall can still remove dirs and CLI
DC=(run_docker compose)
COMPOSE_ARGS=()
fi
# Cold start: Jellyfin/Bazarr start_period is 60s, then the first healthcheck.
readonly WAIT_READY_SECONDS=180
readonly IP_ENDPOINTS=(
"https://ipinfo.io/ip"
"https://api.ipify.org"
"https://checkip.amazonaws.com"
"https://tnedi.me"
"https://api.myip.la"
"https://wtfismyip.com/text"
)
# Commands
declare -A COMMANDS=(
["--help"]="displays this help message"
["restart"]="restarts services (can specify service names)"
["stop"]="stops all services (can specify service names)"
["start"]="starts services (can specify service names)"
["status"]="service dashboard (URLs + health); --docker for raw compose ps"
["destroy"]="destroys services so you can start from scratch (can specify service names)"
["uninstall"]="completely removes the stack (keeps media unless confirmed) — see docs/uninstall.md"
["check-vpn"]="checks if the VPN is working as expected"
["backup"]="backs up to the destination location"
["restore"]="restores from a backup archive"
["update-containers"]="updates all containers"
["upgrade"]="upgrade install files from git (or --from DIR); runs migrations"
["logs"]="shows container logs (optionally specify service name)"
["health"]="checks health status of all services"
["config"]="configuration: show, edit (picker), or apply (re-wire)"
["purge"]="delete one title from *arr, disk, and qBittorrent"
)
# log_error_inline — same as log_error but without exit (used by check_health)
log_error_inline() { echo -e "${RED}$1${NC}"; }
show_help() {
echo "${APP_DISPLAY_NAME} - ${APP_DESCRIPTION:-Your one-click media server}"
echo
echo "Usage: ${APP_CLI_NAME} [command] [options]"
echo
echo "Commands:"
for cmd in "${!COMMANDS[@]}"; do
printf "%-25s %s\n" "$cmd" "${COMMANDS[$cmd]}"
done
echo
echo "Examples:"
echo " ${APP_CLI_NAME} start # Start all services"
echo " ${APP_CLI_NAME} start jellyfin # Start the 'jellyfin' service"
echo " ${APP_CLI_NAME} stop jellyfin # Stop the 'jellyfin' service"
echo " ${APP_CLI_NAME} restart jellyfin # Restart the 'jellyfin' service"
echo " ${APP_CLI_NAME} destroy jellyfin # Destroy the 'jellyfin' service"
echo " ${APP_CLI_NAME} backup /path/to/backup # Backup to specified directory"
echo " ${APP_CLI_NAME} restore /path/to/backup.tar.gz # Restore from backup"
echo " ${APP_CLI_NAME} uninstall # Remove everything (asks before deleting data)"
echo " ${APP_CLI_NAME} uninstall --force # Remove without prompts (keeps media)"
echo " ${APP_CLI_NAME} uninstall --force --media # Remove everything including media"
echo " ${APP_CLI_NAME} logs # View all container logs"
echo " ${APP_CLI_NAME} logs jellyfin # View specific service logs"
echo " ${APP_CLI_NAME} health # Check health of all services"
echo " ${APP_CLI_NAME} upgrade # Upgrade from git main (backup first)"
echo " ${APP_CLI_NAME} upgrade --from DIR # Upgrade from a local source tree"
echo " ${APP_CLI_NAME} upgrade --check # Dry-run upgrade plan"
echo " ${APP_CLI_NAME} upgrade --skip-stack # Files + CLI only (no container restart)"
echo " ${APP_CLI_NAME} status # URLs + health for all services"
echo " ${APP_CLI_NAME} status --docker # Raw docker compose ps"
echo " ${APP_CLI_NAME} config # List config subcommands"
echo " ${APP_CLI_NAME} config show # Show current configuration"
echo " ${APP_CLI_NAME} config edit # Pick a setting to change"
echo " ${APP_CLI_NAME} purge --movie-id N # Full delete (*arr + disk + qB)"
echo " ${APP_CLI_NAME} purge --path PATH # Full delete by library path"
echo " ${APP_CLI_NAME} purge --tmdb ID # Full delete by TMDB id"
}
# Full media purge (*arr + qB + hardlinks). Thin wrapper around scripts/media-purge.sh
purge_media() {
local install_dir script
install_dir=$(find_install_directory)
[ -n "$install_dir" ] || log_error "Could not find installation directory"
script="$install_dir/scripts/media-purge.sh"
if [ ! -f "$script" ]; then
# Dev tree: repo scripts/
local self_dir
self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -f "$self_dir/../scripts/media-purge.sh" ]; then
script="$self_dir/../scripts/media-purge.sh"
fi
fi
[ -f "$script" ] || log_error "media-purge.sh not found — run upgrade or re-setup"
chmod +x "$script" 2>/dev/null || true
set -a
# shellcheck disable=SC1091
[ -f "$install_dir/.env" ] && . "$install_dir/.env"
set +a
export API_HOST="${API_HOST:-127.0.0.1}"
export CONFIG_DIR="$install_dir/config"
export INSTALL_DIR="$install_dir"
export MEDIA_ROOT="${MEDIA_DIRECTORY:-/data}"
if [ -f "$install_dir/secrets/auth_username.txt" ]; then
export AUTH_USERNAME_FILE="$install_dir/secrets/auth_username.txt"
export AUTH_PASSWORD_FILE="$install_dir/secrets/auth_password.txt"
fi
export QBITTORRENT_URL="${QBITTORRENT_URL:-http://127.0.0.1:8081}"
export RADARR_URL="${RADARR_URL:-http://127.0.0.1:7878}"
export SONARR_URL="${SONARR_URL:-http://127.0.0.1:8989}"
bash "$script" "$@"
}
wait_for_services() {
local wait_time=0
local timeout="${WAIT_READY_SECONDS:-180}"
local total=0 ready=0
local -a waiting=()
while [ $wait_time -lt "$timeout" ]; do
total=0
ready=0
waiting=()
local svc state health label port path kind
declare -A st_state=()
declare -A st_health=()
while IFS=$'\t' read -r svc state health; do
[ -z "$svc" ] && continue
st_state["$svc"]="$state"
st_health["$svc"]="$health"
done < <(_status_compose_rows)
if type list_service_catalog >/dev/null 2>&1; then
while IFS='|' read -r svc label port path kind; do
[ -z "$svc" ] && continue
total=$((total + 1))
state="${st_state[$svc]:-}"
health="${st_health[$svc]:-}"
if type service_row_ready >/dev/null 2>&1 && service_row_ready "$state" "$health"; then
ready=$((ready + 1))
else
waiting+=("$svc")
fi
done < <(list_service_catalog)
else
for svc in "${!st_state[@]}"; do
total=$((total + 1))
if [ "$(echo "${st_state[$svc]}" | tr '[:upper:]' '[:lower:]')" = "running" ]; then
ready=$((ready + 1))
else
waiting+=("$svc")
fi
done
fi
if [ "$total" -gt 0 ] && [ "$ready" -eq "$total" ]; then
printf '\r\033[K' >&2
log_success "All $total services are ready."
return 0
fi
if [ "${#waiting[@]}" -gt 0 ] && [ "${#waiting[@]}" -le 6 ]; then
wait_inline "Waiting for services ($ready/$total ready: ${waiting[*]})" "$wait_time"
else
wait_inline "Waiting for services ($ready/$total ready)" "$wait_time"
fi
sleep 1
wait_time=$((wait_time + 1))
done
printf '\r\033[K' >&2
log_error "Not all services became ready within ${timeout}s ($ready/$total ready${waiting[*]:+: ${waiting[*]}})"
}
get_ip_with_retries() {
local context=$1
local cmd_prefix=""
if [ "$context" = "qbittorrent" ]; then
cmd_prefix="run_docker exec qbittorrent"
fi
for endpoint in "${IP_ENDPOINTS[@]}"; do
local ip
if [ "$context" = "local" ]; then
ip=$(curl -s --connect-timeout 5 "$endpoint")
else
ip=$($cmd_prefix curl -s --connect-timeout 5 "$endpoint")
fi
if [ -n "$ip" ] && [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "$ip"
return 0
fi
done
return 1
}
check_vpn() {
echo "Getting your IP..."
local your_ip
your_ip=$(get_ip_with_retries "local") || log_error "Failed to get your IP address from any endpoint"
echo "Your IP: $your_ip"
local country
country=$(curl -s --connect-timeout 5 "https://am.i.mullvad.net/country") || log_warning "Couldn't determine your country"
[ -n "$country" ] && echo "Your local IP country is $country"
echo -e "\nGetting your qBittorrent IP..."
local qbit_ip
qbit_ip=$(get_ip_with_retries "qbittorrent") || log_error "Failed to get qBittorrent IP from any endpoint"
echo "qBittorrent IP: $qbit_ip"
local qbit_country
qbit_country=$(run_docker exec qbittorrent curl -s --connect-timeout 5 "https://am.i.mullvad.net/country") || log_warning "Couldn't determine qBittorrent country"
[ -n "$qbit_country" ] && echo "qBittorrent country is $qbit_country"
if [ "$qbit_ip" == "$your_ip" ]; then
log_error "WARNING: Your IPs are the same! qBittorrent is exposing your IP!"
else
log_success "Success: Your IPs are different. qBittorrent is masking your IP!"
fi
}
backup_app() {
local destination=$1
local backup_date
backup_date=$(date '+%Y-%m-%d-%s')
local backup_file="$destination/${APP_BACKUP_PREFIX}-$backup_date.tar.gz"
# Verify destination exists or create it
if [ ! -d "$destination" ]; then
log_info "Backup destination \"$destination\" does not exist. Creating it..."
mkdir -p "$destination" || log_error "Failed to create backup destination directory"
fi
echo "Stopping ${APP_DISPLAY_NAME} services..."
"${DC[@]}" stop > /dev/null 2>&1 || log_error "Failed to stop services"
echo -e "\nBacking up ${APP_DISPLAY_NAME} to $destination..."
# Run tar inside an alpine container bound to the INSTALL_DIR and the destination (excluding temp/cache and sockets)
if ! wait_while "Creating backup archive" run_docker run --rm \
-v "$INSTALL_DIR:/source" \
-v "$(dirname "$backup_file"):/backup" \
alpine tar --exclude='./transcoding-temp' --exclude='./config/jellyfin/cache' --exclude='ipc-socket' -czf "/backup/$(basename "$backup_file")" -C /source .; then
log_error "Failed to create backup archive"
fi
echo -e "\nStarting ${APP_DISPLAY_NAME} services..."
"${DC[@]}" start > /dev/null 2>&1 || log_warning "Failed to restart services"
log_success "Backup completed successfully!"
echo "Backup file: $backup_file"
}
destroy_app() {
if [ "$#" -eq 0 ]; then
echo -e "\nWARNING: This will destroy all your services, containers, volumes, and the custom network!"
echo "This is not recoverable!"
while true; do
read -p "Are you sure you want to continue? [y/N]: " -r
local reply=${REPLY:-n}
if [[ ${reply,,} =~ ^y$ ]]; then
"${DC[@]}" down || log_error "Failed to destroy services"
docker network rm "$APP_NETWORK_NAME" 2>/dev/null || log_warning "Failed to remove $APP_NETWORK_NAME. It might not exist or was already removed."
echo -e "\n${APP_DISPLAY_NAME} services were destroyed. To restart, run: ${APP_CLI_NAME} start"
break
elif [[ ${reply,,} =~ ^n$ ]]; then
log_info "Destroy cancelled."
break
else
log_warning "Please answer y or n."
fi
done
else
local target_label="$*"
echo -e "\nWARNING: This will destroy the service(s): \"${target_label}\". This includes their associated containers and volumes!"
echo "This is not recoverable!"
while true; do
read -p "Are you sure you want to continue? [y/N]: " -r
local reply=${REPLY:-n}
if [[ ${reply,,} =~ ^y$ ]]; then
"${DC[@]}" stop "$@" || log_error "Failed to stop services"
"${DC[@]}" rm -f -v "$@" || log_error "Failed to destroy services"
echo -e "\n${APP_DISPLAY_NAME} service was destroyed. To restart, run: ${APP_CLI_NAME} start"
break
elif [[ ${reply,,} =~ ^n$ ]]; then
log_info "Destroy cancelled."
break
else
log_warning "Please answer y or n."
fi
done
fi
}
uninstall_app() {
local force=false
local delete_media=false
local removed_config=false
local removed_media=false
local -a extra_removed=()
local -a extra_kept=()
for arg in "$@"; do
case "$arg" in
-f|--force) force=true ;;
-m|--media) delete_media=true ;;
esac
done
poke_wsl_automounts 2>/dev/null || true
# --force already opts out of confirmation; skip the inventory and preamble.
if [ "$force" = false ]; then
echo
print_detected_locations "Detected installation:"
echo
echo "WARNING: This will COMPLETELY remove ${APP_DISPLAY_NAME}!"
echo "This includes running containers, the Docker network, the CLI, and the config path above."
if [ "$delete_media" = true ]; then
echo "Media at $MEDIA_DIRECTORY will also be deleted."
else
echo "Media at $MEDIA_DIRECTORY is kept unless you confirm below (or pass --media)."
fi
echo "Tip: run '${APP_CLI_NAME} backup <target-dir>' first if you want to keep your configuration."
echo
echo "This is not recoverable!"
read -p "Are you ABSOLUTELY sure? Type 'yes' to confirm: " -r
if [[ "$REPLY" != "yes" ]]; then
echo "Uninstall cancelled."
return 0
fi
fi
"${DC[@]}" down -v 2>/dev/null || log_warning "Failed to stop some services"
if run_docker network inspect "$APP_NETWORK_NAME" &>/dev/null; then
run_docker network rm "$APP_NETWORK_NAME" 2>/dev/null || log_warning "Failed to remove Docker network $APP_NETWORK_NAME"
fi
if [ "$force" = true ]; then
safe_rm_rf "$INSTALL_DIR"
removed_config=true
else
read -p "Also delete config at $INSTALL_DIR? Type the full path to confirm: " -r
if [[ "$REPLY" = "$INSTALL_DIR" ]]; then
safe_rm_rf "$INSTALL_DIR"
removed_config=true
fi
fi
if [ "$delete_media" = true ]; then
safe_rm_rf "$MEDIA_DIRECTORY"
removed_media=true
elif [ "$force" = false ]; then
read -p "Also delete media at $MEDIA_DIRECTORY? Type the full path to confirm: " -r
if [[ "$REPLY" = "$MEDIA_DIRECTORY" ]]; then
safe_rm_rf "$MEDIA_DIRECTORY"
removed_media=true
fi
fi
# Path picker / hung setup can leave assemblrr trees on other mounts
# (e.g. /mnt/e) that the pointer never recorded.
local leftover_kind leftover_path
while IFS=$'\t' read -r leftover_kind leftover_path; do
[ -n "$leftover_path" ] || continue
case "$leftover_kind" in
install)
safe_rm_rf "$leftover_path"
extra_removed+=("$leftover_path")
;;
media)
if [ "$delete_media" = true ]; then
safe_rm_rf "$leftover_path"
extra_removed+=("$leftover_path")
else
extra_kept+=("$leftover_path")
fi
;;
esac
done < <(list_assemblrr_leftovers "$INSTALL_DIR" "$MEDIA_DIRECTORY")
if [ -n "${APP_CLI_NAME:-}" ]; then
rm -f "$HOME/.local/bin/$APP_CLI_NAME" "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null || true
fi
if type remove_stale_user_cli_libs >/dev/null 2>&1; then
remove_stale_user_cli_libs
else
rm -f "$HOME/.local/bin/lib/"*.sh 2>/dev/null || true
rmdir "$HOME/.local/bin/lib" 2>/dev/null || true
fi
# Setup writes this cheat-sheet under $HOME (outside the install dir)
# Legacy setup wrote ~/assemblrr_services.txt — remove if present
rm -f "$HOME/assemblrr_services.txt" 2>/dev/null || true
# Install discovery pointer (may live outside the install dir)
rm -f "$HOME/.assemblrr-config" "$HOME/.${APP_NAME:-assemblrr}-config" 2>/dev/null || true
log_success "${APP_DISPLAY_NAME} uninstalled."
if [ "$removed_config" = true ]; then
echo " Config removed: $INSTALL_DIR"
else
echo " Config kept: $INSTALL_DIR"
fi
if [ "$removed_media" = true ]; then
echo " Media removed: $MEDIA_DIRECTORY"
else
echo " Media kept: $MEDIA_DIRECTORY"
fi
local p
if [ "${#extra_removed[@]}" -gt 0 ]; then
for p in "${extra_removed[@]}"; do
echo " Leftover removed: $p"
done
fi
if [ "${#extra_kept[@]}" -gt 0 ]; then
for p in "${extra_kept[@]}"; do
echo " Leftover kept: $p"
done
fi
}
# Load PUID/PGID from install .env when present (for prepare_install_dirs)
_load_install_ids() {
local env_file="$INSTALL_DIR/.env"
if [ -f "$env_file" ]; then
# shellcheck disable=SC1090
set -a
safe_source "$env_file" 2>/dev/null || true
set +a
fi
_HOST_UID="${PUID:-$(id -u)}"
_HOST_GID="${PGID:-$(id -g)}"
}
start_app() {
_load_install_ids
prepare_install_dirs "$INSTALL_DIR" "$_HOST_UID" "$_HOST_GID"
# Validate VPN config before starting (if VPN is enabled)
if [ "${VPN_ENABLED:-n}" = "y" ]; then
if ! validate_vpn_secrets "$INSTALL_DIR" "${VPN_ENABLED:-n}" "${VPN_TYPE:-openvpn}"; then
log_error "Set VPN credentials in $INSTALL_DIR/secrets before starting services"
fi
# Test VPN connection by starting Gluetun first
if ! test_vpn_connection_shared "${DC[@]}"; then
log_error "VPN startup failed. Fix credentials in $INSTALL_DIR/secrets (and .env if needed), then retry."
fi
fi
if [ "$#" -eq 0 ]; then
compose_up_stack "${MEDIA_SERVICE:-jellyfin}" || log_error "Failed to start services"
wait_for_services
echo
check_status || true
else
compose_up_stack "${MEDIA_SERVICE:-jellyfin}" "$@" || log_error "Failed to start services"
fi
}
restart_app() {
_load_install_ids
prepare_install_dirs "$INSTALL_DIR" "$_HOST_UID" "$_HOST_GID"
if [ "$#" -eq 0 ]; then
wait_while "Stopping services" "${DC[@]}" stop || log_warning "Some services failed to stop"
compose_up_stack "${MEDIA_SERVICE:-jellyfin}" || log_error "Failed to start services"
wait_for_services
echo
check_status || true
else
"${DC[@]}" stop "$@" || log_warning "Some services failed to stop"
compose_up_stack "${MEDIA_SERVICE:-jellyfin}" "$@" || log_error "Failed to start services"
fi
}
stop_app() {
if [ $# -eq 0 ]; then
"${DC[@]}" stop || log_error "Failed to stop services"
else
"${DC[@]}" stop "$@" || log_error "Failed to stop services: $*"
fi
log_success "Services stopped successfully"
}
# Parse compose ps into associative-ish lines: service -> "state|health"
# Uses Service name (compose key), not container name.
_status_compose_rows() {
# format: service<TAB>state<TAB>health (health may be empty)
"${DC[@]}" ps -a --format '{{.Service}}\t{{.State}}\t{{.Health}}' 2>/dev/null
}
_status_normalize() {
# stdin: state, health → stdout: short token + color category
local state="${1:-}" health="${2:-}"
state=$(echo "$state" | tr '[:upper:]' '[:lower:]')
health=$(echo "$health" | tr '[:upper:]' '[:lower:]')
if [ "$health" = "healthy" ]; then
echo "healthy"
elif [ "$health" = "unhealthy" ]; then
echo "unhealthy"
elif [ "$health" = "starting" ]; then
echo "starting"
elif [ "$state" = "running" ]; then
echo "running"
elif [ -z "$state" ]; then
echo "missing"
else
echo "$state"
fi
}
check_status() {
local raw_docker=0
local arg
for arg in "$@"; do
case "$arg" in
--docker|-d) raw_docker=1 ;;
-h|--help)
echo "Usage: ${APP_CLI_NAME} status [--docker]"
echo " Default: operator dashboard (name, state, URL)."
echo " --docker: raw docker compose ps table."
return 0
;;
esac
done
if [ "$raw_docker" = "1" ]; then
"${DC[@]}" ps || log_error "Failed to check services"
return 0
fi
if ! type list_service_catalog >/dev/null 2>&1; then
log_warning "Service catalog not loaded — falling back to docker compose ps"
"${DC[@]}" ps || log_error "Failed to check services"
return 0
fi
# Load live state: service -> state|health
declare -A st_state=()
declare -A st_health=()
local svc state health line
while IFS=$'\t' read -r svc state health; do
[ -z "$svc" ] && continue
st_state["$svc"]="$state"
st_health["$svc"]="$health"
done < <(_status_compose_rows)
local media="${MEDIA_SERVICE:-jellyfin}"
local vpn="${VPN_ENABLED:-n}"
local vpn_label="off"
[ "$vpn" = "y" ] && vpn_label="on"
echo "${APP_DISPLAY_NAME} · media=${media} · VPN ${vpn_label}"
print_detected_locations
echo
printf " %-14s %-12s %s\n" "SERVICE" "STATE" "URL / NOTE"
printf " %-14s %-12s %s\n" "--------------" "------------" "---------------------------"
local label port path kind token url note
local seen=""
local unhealthy=0
local total=0
while IFS='|' read -r svc label port path kind; do
[ -z "$svc" ] && continue
total=$((total + 1))
seen="${seen}|${svc}|"
state="${st_state[$svc]:-}"
health="${st_health[$svc]:-}"
token=$(_status_normalize "$state" "$health")
if [ "$token" = "unhealthy" ] || [ "$token" = "missing" ] || [ "$token" = "exited" ] || [ "$token" = "dead" ]; then
unhealthy=$((unhealthy + 1))
fi
url=""
note=""
if [ "$kind" = "ui" ]; then
url=$(service_ui_url "$port" "$path" "localhost")
if [ "$token" = "missing" ] || [ "$token" = "exited" ]; then
note="(down)"
fi
else
note="(internal)"
fi
# STATE column without color for width, then rewrite — use plain token padded + color on same width
printf " %-14s " "$label"
case "$token" in
healthy) printf "${GREEN}%-12s${NC}" "healthy" ;;
running) printf "${GREEN}%-12s${NC}" "running" ;;
starting) printf "${YELLOW}%-12s${NC}" "starting" ;;
unhealthy) printf "${RED}%-12s${NC}" "unhealthy" ;;
missing) printf "${RED}%-12s${NC}" "missing" ;;
exited) printf "${RED}%-12s${NC}" "exited" ;;
*) printf "${YELLOW}%-12s${NC}" "$token" ;;
esac
if [ -n "$url" ]; then
printf " %s" "$url"
[ -n "$note" ] && printf " %s" "$note"
printf "\n"
else
printf " %s\n" "${note:-(no UI)}"
fi
done < <(list_service_catalog)
# Any compose services not in the catalog (custom overlays)
while IFS=$'\t' read -r svc state health; do
[ -z "$svc" ] && continue
[[ "$seen" == *"|${svc}|"* ]] && continue
total=$((total + 1))
token=$(_status_normalize "$state" "$health")
if [ "$token" = "unhealthy" ] || [ "$token" = "missing" ] || [ "$token" = "exited" ] || [ "$token" = "dead" ]; then
unhealthy=$((unhealthy + 1))
fi
printf " %-14s " "$svc"
case "$token" in
healthy) printf "${GREEN}%-12s${NC}" "healthy" ;;
running) printf "${GREEN}%-12s${NC}" "running" ;;
starting) printf "${YELLOW}%-12s${NC}" "starting" ;;
unhealthy) printf "${RED}%-12s${NC}" "unhealthy" ;;
*) printf "${YELLOW}%-12s${NC}" "$token" ;;
esac
printf " (custom / unlisted)\n"
done < <(_status_compose_rows)
echo
if [ "$unhealthy" -gt 0 ]; then
log_warning "${unhealthy} service(s) need attention (${total} listed)."
return 1
fi
log_success "All ${total} listed services look good."
return 0
}
update_containers() {
local skip_backup=false
local assume_yes=false
for arg in "$@"; do
case "$arg" in
--skip-backup) skip_backup=true ;;
-y|--yes) assume_yes=true ;;
esac
done
echo -e "\nWARNING: Updating containers may cause data loss or compatibility issues if new versions have breaking changes!"
if [ "$skip_backup" = false ] && [ "$assume_yes" = false ]; then
read -p "Would you like to back up your current configuration first? [Y/n]: " -r
if [[ ! ${REPLY,,} =~ ^n$ ]]; then
local default_backup_dir="$HOME/assemblrr-backups"
echo -e "\nStarting automatic backup to $default_backup_dir..."
backup_app "$default_backup_dir"
echo -e "\nBackup complete. Proceeding with container update..."
fi
fi
if [ "$assume_yes" = false ]; then
read -p "Are you sure you want to update all containers? [Y/n]: " -r
if [[ ${REPLY,,} =~ ^n$ ]]; then
echo "Update aborted."
return 0
fi
fi
echo "Pulling latest container images..."
"${DC[@]}" pull || log_error "Failed to pull containers"
echo "Restarting ${APP_DISPLAY_NAME} services..."
restart_app
log_success "Containers updated successfully!"
}
show_config() {
echo "${APP_DISPLAY_NAME} Configuration:"
print_detected_locations
echo " Media service: ${MEDIA_SERVICE:-jellyfin}"
echo " VPN enabled: ${VPN_ENABLED:-n}"
echo " VPN type: ${VPN_TYPE:-openvpn}"
echo " Timezone: ${TZ:-UTC}"
if type quality_tier >/dev/null 2>&1; then
if [ "$(quality_tier)" = "any" ]; then
echo " Quality: any"
else
echo " Quality: $(quality_tier) / $(quality_source)"
fi
fi
if [ "${VPN_ENABLED:-n}" = "y" ]; then
echo " VPN secrets: $INSTALL_DIR/secrets/"
fi
echo
echo "Compose files:"
local _prev="" _arg
for _arg in "${COMPOSE_ARGS[@]}"; do
if [ "$_prev" = "-f" ]; then
echo " $_arg"
fi
_prev="$_arg"
done
}
show_config_help() {
echo "Usage: ${APP_CLI_NAME} config [subcommand]"
echo
echo "Subcommands:"
printf " %-12s %s\n" "show" "Show current configuration"
printf " %-12s %s\n" "edit" "Pick a setting to change"
printf " %-12s %s\n" "apply" "Re-wire running services (no compose replace)"
echo
echo "Examples:"
echo " ${APP_CLI_NAME} config # List config options"
echo " ${APP_CLI_NAME} config show # Show configuration"
echo " ${APP_CLI_NAME} config apply # Re-run service wiring only"
echo " ${APP_CLI_NAME} config edit # Pick a setting to change"
}
config_apply() {
if [ ! -f "$INSTALL_DIR/config.sh" ]; then
log_error "config.sh not found in $INSTALL_DIR. Run '${APP_CLI_NAME} upgrade' first."
fi
log_info "Re-wiring services (no compose replace)..."
ASSEMBLRR_NONINTERACTIVE=1 bash "$INSTALL_DIR/config.sh"
}
_load_config_edit() {
local src=""
if [ -f "$INSTALL_DIR/lib/config_edit.sh" ]; then
src="$INSTALL_DIR/lib/config_edit.sh"
elif [ -f "$_lib_dir/config_edit.sh" ]; then
src="$_lib_dir/config_edit.sh"
else
log_error "config_edit.sh not found. Run '${APP_CLI_NAME} upgrade' to install modular config edit."
fi
# shellcheck source=/dev/null
source "$src"
}
config_cmd() {
local sub=${1:-}
shift || true
case "$sub" in
"")
show_config_help
;;
show)
show_config
;;
apply|wire)
config_apply
;;
edit)
_load_config_edit
config_edit_run "${1:-}"
;;
--help|-h|help)
show_config_help
;;
*)
log_error "Unknown config subcommand: $sub\nRun '${APP_CLI_NAME} config' for usage"
;;
esac
}
restore_app() {
local backup_file="$1"
if [ ! -f "$backup_file" ]; then
log_error "Backup file not found: $backup_file"
fi
echo -e "\nWARNING: This will stop all services and overwrite the current installation!"
echo "Backup file: $backup_file"
read -p "Are you sure you want to restore? [y/N]: " -r
if [[ ${REPLY,,} != "y" ]]; then
echo "Restore cancelled."
return 0
fi
echo "Stopping ${APP_DISPLAY_NAME} services..."
"${DC[@]}" down 2>/dev/null || log_warning "Failed to stop services"
echo "Restoring from backup..."
# Safely clear the current configuration instead of blindly overwriting
local timestamp=$(date +%Y%m%d_%H%M%S)
local backup_dir="${INSTALL_DIR}_backup_${timestamp}"
echo "Creating safety snapshot of current state at ${backup_dir}..."
wait_while "Creating safety snapshot" run_docker run --rm \
-v "$(dirname "$INSTALL_DIR"):/target_parent" \
alpine cp -r "/target_parent/$(basename "$INSTALL_DIR")" "/target_parent/$(basename "$backup_dir")" \
|| log_warning "Failed to create safety snapshot"
wait_while "Clearing install directory" run_docker run --rm \
-v "$INSTALL_DIR:/target" \
alpine sh -c 'find /target -mindepth 1 -maxdepth 1 ! -name "media" ! -name "downloads" -exec rm -rf {} +'
local abs_backup_file; abs_backup_file=$(realpath "$backup_file")
if ! wait_while "Restoring from backup" run_docker run --rm \
-v "$INSTALL_DIR:/target" \
-v "$(dirname "$abs_backup_file"):/backup" \
alpine tar -xzf "/backup/$(basename "$abs_backup_file")" -C /target; then
log_error "Failed to extract backup"
fi
echo "Starting ${APP_DISPLAY_NAME} services..."
"${DC[@]}" up -d --remove-orphans || log_warning "Failed to start services"
echo "Cleaning up old safety snapshots (keeping last 3)..."
# Keep only the 3 most recent backups, remove the rest
ls -dt "${INSTALL_DIR}_backup_"* 2>/dev/null | tail -n +4 | while read -r old_backup; do
rm -rf "$old_backup" 2>/dev/null || true
done
log_success "Restore completed successfully!"
echo "Safety snapshot kept at: $backup_dir"
}
show_logs() {
if [ "${2:-}" ]; then
"${DC[@]}" logs -f "${@:2}"
else
"${DC[@]}" logs -f
fi
}
check_health() {
# Compact pass/fail over compose health (exit 1 if any unhealthy/missing expected UI service)
echo "${APP_DISPLAY_NAME} Health Check:"
echo
local unhealthy=0
local svc state health token
while IFS=$'\t' read -r svc state health; do
[ -z "$svc" ] && continue
token=$(_status_normalize "$state" "$health")
case "$token" in
healthy)
log_success " $svc: healthy"
;;
running)
log_warning " $svc: running (no healthcheck)"
;;
starting)
log_warning " $svc: starting..."
;;
*)
log_error_inline " $svc: $token"
unhealthy=$((unhealthy + 1))
;;
esac
done < <(_status_compose_rows)
echo
if [ "$unhealthy" -gt 0 ]; then
log_warning "$unhealthy service(s) are unhealthy"
return 1
fi
log_success "All services are healthy"
return 0
}
main() {
local command=${1:-"--help"}
local destination=${2:-.}
if [ "$command" = "backup" ]; then
destination=$(realpath "$destination" 2>/dev/null || echo "$destination") || log_error "Invalid backup destination path"
fi
case "$command" in
--help)
show_help
;;
restart)
restart_app "${@:2}"
;;
stop)
stop_app "${@:2}"
;;
start)
start_app "${@:2}"
;;
status)
check_status "${@:2}"
;;
check-vpn)
check_vpn
;;
destroy)
destroy_app "${@:2}"
;;
uninstall)
uninstall_app "${@:2}"
;;
backup)
backup_app "$destination"
;;
restore)
[ -z "${2:-}" ] && log_error "Usage: ${APP_CLI_NAME} restore /path/to/backup.tar.gz"
restore_app "${2}"
;;
update-containers)
update_containers "${@:2}"
;;
update-cli)
log_info "The CLI is ${INSTALL_DIR}/cli.sh. Update with: ${APP_CLI_NAME} upgrade"
;;
upgrade)
if ! type upgrade_app >/dev/null 2>&1; then
log_error "Upgrade engine not found. Update CLI from a current checkout: bash /path/to/repo/bin/cli.sh upgrade --from /path/to/repo"
fi
upgrade_app "${@:2}"
;;
logs)
show_logs "$@"
;;
health)
check_health
;;
config)
config_cmd "${@:2}"
;;
purge)
[ $# -lt 2 ] && log_error "Usage: ${APP_CLI_NAME} purge --movie-id N | --series-id N | --path PATH | --tmdb ID | --tvdb ID"
purge_media "${@:2}"
;;
*)
log_error "Unknown command: $command\nRun '${APP_CLI_NAME} --help' for usage information"
;;
esac
}
main "$@"