#!/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 ' 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: servicestatehealth (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 "$@"