From 710540fc40ffd57223355a43eb1367994f36f902 Mon Sep 17 00:00:00 2001 From: soulis-1256 Date: Thu, 13 Aug 2026 21:50:59 +0300 Subject: [PATCH] Add modular config edit and wait for healthy services Section-based `config edit` (setup vs edit copy), qB Automatic Torrent Management so category folders are used, upgrade --skip-stack/--skip-wire, and a port-forwarding warning instead of a provider allowlist. --- README.md | 5 +- bin/cli.sh | 115 +++++--- bin/setup.sh | 83 ++---- lib/api.sh | 28 +- lib/arr.sh | 182 ++++++++----- lib/config_edit.sh | 477 +++++++++++++++++++++++++++++++++ lib/fzf-tui.sh | 201 ++++++++++---- lib/managed_files.sh | 17 ++ lib/prompts.sh | 52 ++-- lib/services.sh | 14 + lib/ui.sh | 82 ++++++ lib/upgrade.sh | 35 ++- tests/run.sh | 3 + tests/unit/test_config_edit.sh | 65 +++++ tests/unit/test_fzf_tui.sh | 39 +++ tests/unit/test_qbit_prefs.sh | 2 + tests/unit/test_ui.sh | 46 ++++ tests/unit/test_upgrade.sh | 15 ++ 18 files changed, 1189 insertions(+), 272 deletions(-) create mode 100644 lib/config_edit.sh create mode 100644 lib/ui.sh create mode 100644 tests/unit/test_config_edit.sh create mode 100644 tests/unit/test_fzf_tui.sh create mode 100644 tests/unit/test_ui.sh diff --git a/README.md b/README.md index 8a78581..543fdea 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,15 @@ After install, the CLI is on your `PATH` as `assemblrr`. Run `assemblrr /dev/null 2>&1 && service_row_ready "$state" "$health"; then + ready=$((ready + 1)) + elif ! type service_row_ready >/dev/null 2>&1 && [ "$(echo "$state" | tr '[:upper:]' '[:lower:]')" = "running" ]; then + ready=$((ready + 1)) + else + waiting+=("$svc") + fi + done < <(_status_compose_rows) + + if [ "$total" -gt 0 ] && [ "$ready" -eq "$total" ]; then echo - log_success "All $total_services services are up and running!" + log_success "All $total services are ready." return 0 fi @@ -201,12 +216,15 @@ wait_for_services() { if [ $((wait_time % 10)) -eq 0 ]; then echo - echo -n "$running_services/$total_services services running" + echo -n "$ready/$total ready" + if [ "${#waiting[@]}" -gt 0 ] && [ "${#waiting[@]}" -le 6 ]; then + echo -n " (${waiting[*]})" + fi fi done echo - log_error "Not all services started within ${TIMEOUT_SECONDS} seconds ($running_services/$total_services running)" + log_error "Not all services became ready within ${timeout}s ($ready/$total ready${waiting[*]:+: ${waiting[*]}})" } get_ip_with_retries() { @@ -416,9 +434,14 @@ uninstall_app() { rm -f "$HOME/.local/bin/$APP_CLI_NAME" "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null || true fi local _lib_module - for _lib_module in core branding compose vpn managed_files upgrade services; do - rm -f "$HOME/.local/bin/lib/${_lib_module}.sh" 2>/dev/null || true - done + if type list_cli_lib_modules >/dev/null 2>&1; then + while IFS= read -r _lib_module; do + [ -z "$_lib_module" ] && continue + rm -f "$HOME/.local/bin/lib/${_lib_module}.sh" 2>/dev/null || true + done < <(list_cli_lib_modules) + else + rm -f "$HOME/.local/bin/lib/"*.sh 2>/dev/null || true + fi rmdir "$HOME/.local/bin/lib" 2>/dev/null || true # Setup writes this cheat-sheet under $HOME (outside the install dir) @@ -707,37 +730,34 @@ show_config_help() { echo echo "Subcommands:" printf " %-12s %s\n" "show" "Show current configuration" - printf " %-12s %s\n" "edit" "Re-run the setup wizard (current values as defaults)" + printf " %-12s %s\n" "edit" "Change one first-setup section (picker if omitted)" echo echo "Examples:" - echo " ${APP_CLI_NAME} config # List config options" - echo " ${APP_CLI_NAME} config show # Show configuration" - echo " ${APP_CLI_NAME} config edit # Change install choices" + echo " ${APP_CLI_NAME} config # List config options" + echo " ${APP_CLI_NAME} config show # Show configuration" + echo " ${APP_CLI_NAME} config edit # Pick a section (indexers, profile, …)" + echo " ${APP_CLI_NAME} config edit indexers # Reopen the Prowlarr indexer fzf" + echo " ${APP_CLI_NAME} config edit all # Re-run the full setup wizard" + echo + echo "Run '${APP_CLI_NAME} config edit --help' for every section." } -# Re-run setup wizard (write files, restart stack, wire apps) -config_edit() { - echo "Re-running ${APP_DISPLAY_NAME} setup wizard..." - echo "Current configuration will be used as defaults." - echo - - local setup_script - if [ -f "$INSTALL_DIR/setup.sh" ]; then - setup_script="$INSTALL_DIR/setup.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 - local tmp_dir - tmp_dir=$(mktemp -d) - if ! git clone --depth=1 "${APP_REPO_URL}" "$tmp_dir/assemblrr" 2>/dev/null; then - log_error "Failed to clone ${APP_REPO_URL}. Check your internet connection and repository access." - fi - setup_script="$tmp_dir/assemblrr/bin/setup.sh" + log_error "config_edit.sh not found. Run '${APP_CLI_NAME} upgrade' to install modular config edit." fi - - bash "$setup_script" + # shellcheck source=/dev/null + source "$src" } config_cmd() { local sub=${1:-} + shift || true case "$sub" in "") show_config_help @@ -746,7 +766,8 @@ config_cmd() { show_config ;; edit) - config_edit + _load_config_edit + config_edit_run "${1:-}" ;; --help|-h|help) show_config_help @@ -866,11 +887,17 @@ update_cli() { fi mkdir -p "$HOME/.local/bin/lib" - for _m in core branding compose vpn managed_files upgrade services; do + if [ -f "$tmp_dir/assemblrr/lib/managed_files.sh" ]; then + # shellcheck source=/dev/null + source "$tmp_dir/assemblrr/lib/managed_files.sh" + fi + local _m + while IFS= read -r _m; do + [ -z "$_m" ] && continue if [ -f "$tmp_dir/assemblrr/lib/${_m}.sh" ]; then cp "$tmp_dir/assemblrr/lib/${_m}.sh" "$HOME/.local/bin/lib/${_m}.sh" fi - done + done < <(list_cli_lib_modules) cp "$tmp_dir/assemblrr/bin/cli.sh" "$HOME/.local/bin/$APP_CLI_NAME" && chmod +x "$HOME/.local/bin/$APP_CLI_NAME" # Remove old system-wide install if it exists [ -n "${APP_CLI_NAME:-}" ] && rm -f "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null @@ -938,7 +965,7 @@ main() { check_health ;; config) - config_cmd "${2:-}" + 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" diff --git a/bin/setup.sh b/bin/setup.sh index c12886f..7885e58 100644 --- a/bin/setup.sh +++ b/bin/setup.sh @@ -219,86 +219,40 @@ check_dependencies() { # --- Setup prompts (sourced from lib/prompts.sh) --- # Requires: lib/core.sh already sourced, constants defined above # fzf-tui helpers (fzf_single_select, fzf_multi_select) must be sourced before prompts +source "$APP_ROOT/lib/managed_files.sh" source "$APP_ROOT/lib/fzf-tui.sh" source "$APP_ROOT/lib/services.sh" source "$APP_ROOT/lib/prompts.sh" copy_configuration_files() { - local -A files=( - ["lib/core.sh"]="lib/core.sh" - ["lib/branding.sh"]="lib/branding.sh" - ["lib/compose.sh"]="lib/compose.sh" - ["lib/vpn.sh"]="lib/vpn.sh" - ["lib/api.sh"]="lib/api.sh" - ["lib/arr.sh"]="lib/arr.sh" - ["lib/jellyfin.sh"]="lib/jellyfin.sh" - ["lib/seerr.sh"]="lib/seerr.sh" - ["lib/bazarr.sh"]="lib/bazarr.sh" - ["lib/prompts.sh"]="lib/prompts.sh" - ["lib/fzf-tui.sh"]="lib/fzf-tui.sh" - ["lib/managed_files.sh"]="lib/managed_files.sh" - ["lib/upgrade.sh"]="lib/upgrade.sh" - ["lib/services.sh"]="lib/services.sh" - ["compose/base.yaml"]="compose/base.yaml" - ["compose/direct-access.yaml"]="compose/direct-access.yaml" - ["compose/vpn.yaml"]="compose/vpn.yaml" - ["compose/examples/custom.yaml.example"]="compose/examples/custom.yaml.example" - [".env.example"]=".env.example" - ["templates/recyclarr/recyclarr.yml"]="templates/recyclarr/recyclarr.yml" - ["templates/recyclarr/includes/radarr-hd.yml"]="templates/recyclarr/includes/radarr-hd.yml" - ["templates/recyclarr/includes/radarr-uhd.yml"]="templates/recyclarr/includes/radarr-uhd.yml" - ["templates/recyclarr/includes/radarr-soft-cfs.yml"]="templates/recyclarr/includes/radarr-soft-cfs.yml" - ["templates/recyclarr/includes/sonarr-web-1080.yml"]="templates/recyclarr/includes/sonarr-web-1080.yml" - ["templates/recyclarr/includes/sonarr-web-2160.yml"]="templates/recyclarr/includes/sonarr-web-2160.yml" - ["templates/recyclarr/includes/sonarr-soft-cfs.yml"]="templates/recyclarr/includes/sonarr-soft-cfs.yml" - ["branding.conf"]="branding.conf" - ["bin/cli.sh"]="cli.sh" - ["bin/config.sh"]="config.sh" - ["bin/setup.sh"]="setup.sh" - ["bin/docker-install.sh"]="docker-install.sh" - ["scripts/jellyfin-refresh.sh"]="scripts/jellyfin-refresh.sh" - ["scripts/vpn-watchdog.sh"]="scripts/vpn-watchdog.sh" - ["scripts/media-purge.sh"]="scripts/media-purge.sh" - ["scripts/arr-purge-hook.sh"]="scripts/arr-purge-hook.sh" - ["scripts/media-purge-watch.sh"]="scripts/media-purge-watch.sh" - ["scripts/seerr-gateway.py"]="scripts/seerr-gateway.py" - ["compose/sidecars/vpn-watchdog.Dockerfile"]="compose/sidecars/vpn-watchdog.Dockerfile" - ["compose/sidecars/media-purge-watch.Dockerfile"]="compose/sidecars/media-purge-watch.Dockerfile" - ["docs/seerr-delete-request.md"]="docs/seerr-delete-request.md" - ) - - local copied=0 + local copied=0 src dest src_path dest_path dest_dir log_debug "Copying install tree into $install_directory" - for src in "${!files[@]}"; do - local dest="$install_directory/${files[$src]}" - local dest_dir - local src_path="" - dest_dir=$(dirname "$dest") - - if [ ! -d "$dest_dir" ]; then - mkdir -p "$dest_dir" - fi + UPGRADE_SOURCE_ROOT="${UPGRADE_SOURCE_ROOT:-$APP_ROOT}" + while IFS='|' read -r src dest; do + [ -z "$src" ] && continue + [[ "$src" =~ ^# ]] && continue + dest_path="$install_directory/$dest" + dest_dir=$(dirname "$dest_path") + [ -d "$dest_dir" ] || mkdir -p "$dest_dir" if ! src_path=$(resolve_project_file "$src"); then - if [ "$src" = "compose/examples/custom.yaml.example" ]; then + if [[ "$src" == compose/examples/* ]]; then log_debug "Optional example file not found: $src" continue fi log_error "Failed to locate source file: $src" fi - log_debug " $src -> $dest" - if cp "$src_path" "$dest"; then - # Custom scripts mounted into *arr containers must be executable; - # Radarr/Sonarr validate CustomScript paths by exec'ing them on save. + log_debug " $src -> $dest_path" + if cp "$src_path" "$dest_path"; then case "$src" in - scripts/*) chmod +x "$dest" ;; + scripts/*|bin/cli.sh|bin/config.sh|bin/setup.sh) chmod +x "$dest_path" ;; esac copied=$((copied + 1)) else - log_error "Failed to copy $src to $dest. Check permissions" + log_error "Failed to copy $src to $dest_path. Check permissions" fi - done + done < <(list_managed_files) log_debug "Copied ${copied} file(s) into $install_directory" } @@ -580,12 +534,13 @@ install_cli() { fi mkdir -p "$HOME/.local/bin/lib" - # Copy lib modules (CLI sources them from lib/ subdirectory) - for _lib_module in core branding compose vpn managed_files upgrade services; do + local _lib_module + while IFS= read -r _lib_module; do + [ -z "$_lib_module" ] && continue if [ -f "$install_directory/lib/${_lib_module}.sh" ]; then cp "$install_directory/lib/${_lib_module}.sh" "$HOME/.local/bin/lib/${_lib_module}.sh" fi - done + done < <(list_cli_lib_modules) cp "$cli_source" "$HOME/.local/bin/$APP_CLI_NAME" && chmod +x "$HOME/.local/bin/$APP_CLI_NAME" # Always on PATH for this process (and future shells via profile/fish config) export PATH="$HOME/.local/bin:$PATH" diff --git a/lib/api.sh b/lib/api.sh index a5be783..61bb419 100644 --- a/lib/api.sh +++ b/lib/api.sh @@ -111,6 +111,15 @@ qbit_cookie_sid() { # --- Wait for service API to be ready --- +_api_ready() { + local port="$1" + local apikey="$2" + local api_path="$3" + local response + response=$(curl -s --connect-timeout 3 "http://${API_HOST}:${port}${api_path}?apikey=${apikey}" 2>/dev/null || echo "") + [ -n "$response" ] && [ -n "$(jq_json_get "$response" "version")" ] +} + wait_for_api() { local name="$1" local port="$2" @@ -119,19 +128,18 @@ wait_for_api() { local max_wait="${API_READY_TIMEOUT_SECONDS:-120}" local wait_time=0 + # Happy path: already up — stay quiet (config edit / re-wire). + if _api_ready "$port" "$apikey" "$api_path"; then + return 0 + fi + echo -n "Waiting for $name API" >&2 while [ $wait_time -lt $max_wait ]; do - dot_inline - local response - response=$(curl -s --connect-timeout 3 "http://${API_HOST}:${port}${api_path}?apikey=${apikey}" 2>/dev/null || echo "") - if [ -n "$response" ]; then - local version - version=$(jq_json_get "$response" "version") - if [ -n "$version" ]; then - echo >&2 - return 0 - fi + if _api_ready "$port" "$apikey" "$api_path"; then + echo >&2 + return 0 fi + dot_inline sleep 2 wait_time=$((wait_time + 2)) done diff --git a/lib/arr.sh b/lib/arr.sh index 58dfdaf..ac65045 100644 --- a/lib/arr.sh +++ b/lib/arr.sh @@ -1,7 +1,8 @@ #!/bin/bash # assemblrr *arr service configuration — sourced by config.sh # Provides: set_arr_auth, qbit helpers, configure_arr_service, profile lookups, -# configure_radarr, configure_sonarr, configure_prowlarr +# configure_radarr, configure_sonarr, configure_prowlarr, +# apply_selected_indexers, sync_selected_indexers # Requires: lib/core.sh (logging), lib/api.sh (api_get/post/put/delete helpers), # config.sh (_cfg_log_info, log_step, log_step_fail, AUTH_USERNAME, etc.) @@ -60,7 +61,9 @@ qbit_vpn_enabled() { esac } -# setPreferences JSON: save paths, and tun0 when VPN is on. +# setPreferences JSON: save paths, Automatic Torrent Management, and tun0 when VPN is on. +# ATM makes category save paths real: Radarr → /data/torrents/movies, Sonarr → /data/torrents/tv. +# Per-torrent TMM is left alone, so already-downloaded torrents stay where they are. # qB 5.x + WireGuard does not bind the POINTOPOINT tun by itself; "Any" # listens on eth0 and Gluetun's kill switch then drops tracker/DHT packets # ("Operation not permitted" → stalledDL). @@ -72,7 +75,7 @@ qbit_core_prefs_json() { if qbit_vpn_enabled; then iface=',"current_network_interface":"tun0"' fi - printf '%s' "{\"save_path\":\"/data/torrents\",\"temp_path\":\"/data/torrents/incomplete\",\"temp_path_enabled\":true${iface}${extras}}" + printf '%s' "{\"save_path\":\"/data/torrents\",\"temp_path\":\"/data/torrents/incomplete\",\"temp_path_enabled\":true,\"auto_tmm_enabled\":true${iface}${extras}}" } qbit_apply_prefs() { @@ -728,6 +731,106 @@ wait_for_arr_indexer_sync() { return 1 } +# POST indexers named in SELECTED_INDEXERS into Prowlarr (skip existing). +# Used by first-run wiring and `assemblrr config edit indexers`. +apply_selected_indexers() { + local apikey="$1" + local indexer_schemas + indexer_schemas=$(api_get "9696" "/api/v1/indexer/schema" "$apikey") + + if [ -z "$indexer_schemas" ]; then + log_step_fail "Prowlarr: failed to fetch indexer schemas" + return 1 + fi + + local selected_indexers=() + if [ -n "${SELECTED_INDEXERS+x}" ] && [ "${#SELECTED_INDEXERS[@]}" -gt 0 ]; then + selected_indexers=("${SELECTED_INDEXERS[@]}") + echo "Applying ${#selected_indexers[@]} selected Prowlarr indexer(s)" >&2 + fi + + local existing_indexers + existing_indexers=$(api_get "9696" "/api/v1/indexer" "$apikey") + local existing_names + existing_names=$(jq_json_list_values "$existing_indexers" "name") + + local indexer_name + for indexer_name in "${selected_indexers[@]+"${selected_indexers[@]}"}"; do + if echo "$existing_names" | grep -q -F -x "$indexer_name"; then + log_step "Prowlarr: ${indexer_name} indexer already exists" + continue + fi + + # Cardigann indexers match on 'name'. Empty field values use definition defaults. + local indexer_payload + indexer_payload=$(echo "$indexer_schemas" | jq --arg idx "$indexer_name" ' + [.[] | select(.name == $idx or (.name | ascii_downcase | startswith($idx | ascii_downcase)))][0] | + .fields = [.fields[] | select(.value != "") | {name: .name, value: .value}] | + .enable = true | .enableAutoSearch = true | .appProfileId = 1 | .priority = (.priority // 25) + ' 2>/dev/null || echo "") + + if [ -z "$indexer_payload" ]; then + log_step_fail "Prowlarr: ${indexer_name} schema not found" + continue + fi + + local idx_result + idx_result=$(curl -s --connect-timeout 5 -X POST \ + -H "Content-Type: application/json" \ + -d "$indexer_payload" \ + "http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null) + if jq_json_has_key "$idx_result" "id"; then + log_step "Prowlarr: added ${indexer_name} indexer" + else + local disabled_payload + disabled_payload=$(echo "$indexer_payload" | jq -c '.enable = false' 2>/dev/null || echo "") + idx_result=$(curl -s --connect-timeout 5 -X POST \ + -H "Content-Type: application/json" \ + -d "$disabled_payload" \ + "http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null) + if jq_json_has_key "$idx_result" "id"; then + log_step "Prowlarr: added ${indexer_name} indexer (disabled — needs FlareSolverr for Cloudflare)" + else + log_step_fail "Prowlarr: failed to add ${indexer_name} indexer" + fi + fi + done + if [ "${#selected_indexers[@]}" -gt 0 ]; then + echo >&2 + log_success "Prowlarr indexer configuration finished (check markers above for any failures)" >&2 + fi + return 0 +} + +# Make Prowlarr match SELECTED_INDEXERS: add missing, delete unselected. +# Esc/empty selection is a no-op (caller should skip). Confirm = desired set. +sync_selected_indexers() { + local apikey="$1" + apply_selected_indexers "$apikey" || return 1 + + local existing_indexers + existing_indexers=$(api_get "9696" "/api/v1/indexer" "$apikey") + if [ -z "$existing_indexers" ] || [ "$existing_indexers" = "[]" ]; then + return 0 + fi + + local id name + while IFS=$'\t' read -r id name; do + [ -n "$id" ] && [ -n "$name" ] || continue + if printf '%s\n' "${SELECTED_INDEXERS[@]+"${SELECTED_INDEXERS[@]}"}" | grep -q -F -x "$name"; then + continue + fi + local code + code=$(api_delete "9696" "/api/v1/indexer/${id}" "$apikey") + if [ "$code" = "200" ] || [ "$code" = "204" ]; then + log_step "Prowlarr: removed ${name} indexer" + else + log_step_fail "Prowlarr: failed to remove ${name} indexer (HTTP ${code:-?})" + fi + done < <(echo "$existing_indexers" | jq -r '.[] | "\(.id)\t\(.name)"' 2>/dev/null) + return 0 +} + configure_prowlarr() { local apikey="$1" local radarr_apikey="$2" @@ -805,77 +908,8 @@ configure_prowlarr() { fi fi - # 2. Selected indexers (optional; can add later in UI) - local indexer_schemas - indexer_schemas=$(api_get "9696" "/api/v1/indexer/schema" "$apikey") - - if [ -z "$indexer_schemas" ]; then - log_step_fail "Prowlarr: failed to fetch indexer schemas" - else - local selected_indexers=() - if [ -n "${SELECTED_INDEXERS+x}" ] && [ "${#SELECTED_INDEXERS[@]}" -gt 0 ]; then - selected_indexers=("${SELECTED_INDEXERS[@]}") - echo "Applying ${#selected_indexers[@]} selected Prowlarr indexer(s)" >&2 - fi - - # Get existing indexers to avoid duplicates - local existing_indexers - existing_indexers=$(api_get "9696" "/api/v1/indexer" "$apikey") - local existing_names - existing_names=$(jq_json_list_values "$existing_indexers" "name") - - local indexer_name - for indexer_name in "${selected_indexers[@]+"${selected_indexers[@]}"}"; do - # Skip if already added - if echo "$existing_names" | grep -q -F -x "$indexer_name"; then - log_step "Prowlarr: ${indexer_name} indexer already exists" - continue - fi - - # Build the indexer payload using jq to safely extract from schema - # Cardigann indexers use 'name' field to match, not implementationName - # Skip empty field values — Prowlarr uses Cardigann definition defaults - local indexer_payload - indexer_payload=$(echo "$indexer_schemas" | jq --arg idx "$indexer_name" ' - [.[] | select(.name == $idx or (.name | ascii_downcase | startswith($idx | ascii_downcase)))][0] | - .fields = [.fields[] | select(.value != "") | {name: .name, value: .value}] | - .enable = true | .enableAutoSearch = true | .appProfileId = 1 | .priority = (.priority // 25) - ' 2>/dev/null || echo "") - - if [ -z "$indexer_payload" ]; then - log_step_fail "Prowlarr: ${indexer_name} schema not found" - continue - fi - - # Try adding with forceSave to bypass connectivity validation - # If that fails (e.g. Cloudflare), retry with enable=false - local idx_result - idx_result=$(curl -s --connect-timeout 5 -X POST \ - -H "Content-Type: application/json" \ - -d "$indexer_payload" \ - "http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null) - if jq_json_has_key "$idx_result" "id"; then - log_step "Prowlarr: added ${indexer_name} indexer" - else - # Retry with enable=false - local disabled_payload - disabled_payload=$(echo "$indexer_payload" | jq -c '.enable = false' 2>/dev/null || echo "") - idx_result=$(curl -s --connect-timeout 5 -X POST \ - -H "Content-Type: application/json" \ - -d "$disabled_payload" \ - "http://${API_HOST}:9696/api/v1/indexer?apikey=${apikey}&forceSave=true" 2>/dev/null) - if jq_json_has_key "$idx_result" "id"; then - log_step "Prowlarr: added ${indexer_name} indexer (disabled — needs FlareSolverr for Cloudflare)" - else - log_step_fail "Prowlarr: failed to add ${indexer_name} indexer" - fi - fi - done - if [ "${#selected_indexers[@]}" -gt 0 ]; then - echo >&2 - log_success "Prowlarr indexer configuration finished (check markers above for any failures)" >&2 - fi - fi + # 2. Selected indexers (optional; can add later via `config edit indexers`) + apply_selected_indexers "$apikey" # 3) After fullSync, set min seeders on *arr indexers (best-effort) wait_for_arr_indexer_sync "$radarr_apikey" || true diff --git a/lib/config_edit.sh b/lib/config_edit.sh new file mode 100644 index 0000000..69a0c08 --- /dev/null +++ b/lib/config_edit.sh @@ -0,0 +1,477 @@ +#!/bin/bash +# Modular config edit — shared prompt/picker engines, post-install copy. +# Sourced by the CLI. Does not run on source. +# Provides: config_edit_run, config_edit_usage, config_edit_section_ids, config_set_kv + +if [ -n "${_ASSEMBLRR_CONFIG_EDIT_SOURCED:-}" ]; then + return 0 +fi +readonly _ASSEMBLRR_CONFIG_EDIT_SOURCED=1 + +_config_edit_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +[ -f "$_config_edit_dir/ui.sh" ] && source "$_config_edit_dir/ui.sh" + +# id|aliases|description (pipe so empty aliases stay empty; bash IFS tab collapses) +_config_edit_catalog() { + cat <<'EOF' +indexers||Prowlarr indexers (fzf) +providers|subtitles,subtitle-providers|Bazarr subtitle providers (fzf) +language|subtitle-language|Preferred subtitle language (fzf) +profile|quality,seerr-profile|Seerr default quality profile (movies) +opensubtitles|os|OpenSubtitles.com credentials +auth|login,credentials|Service login (Radarr / Sonarr / Prowlarr / qB) +timezone|tz|Container timezone +vpn||VPN on/off, provider, and credentials (restarts stack) +media|media-service|Jellyfin / Emby / Plex (restarts stack) +all|setup,wizard|Re-run the full setup wizard +EOF +} + +config_edit_section_ids() { + _config_edit_catalog | cut -d'|' -f1 +} + +config_edit_usage() { + local cli="${APP_CLI_NAME:-assemblrr}" + echo "Usage: ${cli} config edit [section]" + echo + echo "Change one install setting without re-running the whole wizard." + echo "No argument opens a picker (fzf when available)." + echo + echo "Sections:" + local id aliases desc + while IFS='|' read -r id aliases desc; do + [ -z "$id" ] && continue + if [ -n "$aliases" ]; then + printf " %-14s %s\n" "$id" "$desc" + printf " %-14s (aliases: %s)\n" "" "$aliases" + else + printf " %-14s %s\n" "$id" "$desc" + fi + done < <(_config_edit_catalog) + echo + echo "Examples:" + echo " ${cli} config edit # pick a section" + echo " ${cli} config edit indexers # reopen Prowlarr indexer fzf" + echo " ${cli} config edit profile # change Seerr movie default" + echo " ${cli} config edit all # full setup wizard" +} + +# Resolve user input (id or alias) to a canonical section id. Empty → "". +config_edit_resolve() { + local want="${1:-}" + want=$(echo "$want" | tr '[:upper:]' '[:lower:]') + [ -z "$want" ] && return 1 + local id aliases + while IFS='|' read -r id aliases _; do + [ -z "$id" ] && continue + if [ "$want" = "$id" ]; then + echo "$id" + return 0 + fi + local a + local -a _aliases=() + IFS=',' read -ra _aliases <<< "$aliases" + for a in "${_aliases[@]}"; do + if [ "$want" = "$a" ]; then + echo "$id" + return 0 + fi + done + done < <(_config_edit_catalog) + return 1 +} + +# Update or append KEY=value in a dotenv / runtime-config file. +# $4 = quoted (1, default) writes KEY="value"; 0 writes KEY=value +config_set_kv() { + local file="$1" + local key="$2" + local value="$3" + local quoted="${4:-1}" + local tmp line_out + + [ -n "$file" ] && [ -n "$key" ] || return 1 + mkdir -p "$(dirname "$file")" + tmp=$(mktemp) + if [ -f "$file" ]; then + grep -v "^${key}=" "$file" > "$tmp" || true + fi + if [ "$quoted" = "1" ]; then + printf '%s="%s"\n' "$key" "$value" >> "$tmp" + else + printf '%s=%s\n' "$key" "$value" >> "$tmp" + fi + mv "$tmp" "$file" +} + +# --- load install + *arr libs (idempotent) --- + +_config_edit_loaded=0 + +_config_edit_load() { + if [ "${_config_edit_loaded}" = "1" ]; then + return 0 + fi + + if [ -z "${INSTALL_DIR:-}" ] || [ ! -d "$INSTALL_DIR" ]; then + log_error "Could not find installation. Run setup first." + fi + + safe_source "$INSTALL_DIR/.assemblrr-config" + if [ -f "$INSTALL_DIR/.env" ]; then + set -a + safe_source "$INSTALL_DIR/.env" + set +a + fi + load_branding "$INSTALL_DIR" 2>/dev/null || true + + API_HOST="${API_HOST:-127.0.0.1}" + RADARR_DOCKER_URL="${RADARR_DOCKER_URL:-http://radarr:7878}" + SONARR_DOCKER_URL="${SONARR_DOCKER_URL:-http://sonarr:8989}" + PROWLARR_DOCKER_URL="${PROWLARR_DOCKER_URL:-http://prowlarr:9696}" + if [ "${VPN_ENABLED:-n}" = "y" ]; then + QBITTORRENT_HOST="${QBITTORRENT_HOST:-gluetun}" + else + QBITTORRENT_HOST="${QBITTORRENT_HOST:-qbittorrent}" + fi + AUTH_USERNAME=$(cat "$INSTALL_DIR/secrets/auth_username.txt" 2>/dev/null || echo "${AUTH_USERNAME:-$USER}") + AUTH_PASSWORD=$(cat "$INSTALL_DIR/secrets/auth_password.txt" 2>/dev/null || echo "${AUTH_PASSWORD:-}") + + # Section editors always use the interactive prompts, never express defaults. + ui_set_mode edit + SETUP_MODE="manual" + if [ -z "${SUPPORTED_MEDIA_SERVICES+x}" ]; then + SUPPORTED_MEDIA_SERVICES=("jellyfin" "emby" "plex") + fi + + CONFIGURE_LOG="${CONFIGURE_LOG:-/tmp/${APP_NAME:-assemblrr}-config-edit-$(date '+%Y%m%d-%H%M%S').log}" + if ! type _cfg_log_info >/dev/null 2>&1; then + _cfg_log_info() { echo " $1" | tee -a "$CONFIGURE_LOG"; } + fi + if ! type log_step >/dev/null 2>&1; then + _configure_total=0 + _configure_ok=0 + _configure_fail=0 + log_step() { echo -e " ${GREEN}✓${NC} $1" | tee -a "$CONFIGURE_LOG"; _configure_ok=$((_configure_ok + 1)); _configure_total=$((_configure_total + 1)); } + log_step_fail() { echo -e " ${RED}✗${NC} $1" | tee -a "$CONFIGURE_LOG"; _configure_fail=$((_configure_fail + 1)); _configure_total=$((_configure_total + 1)); } + fi + + local lib="${INSTALL_DIR}/lib" + # shellcheck source=/dev/null + [ -f "$lib/ui.sh" ] && source "$lib/ui.sh" + # shellcheck source=/dev/null + [ -f "$lib/fzf-tui.sh" ] && source "$lib/fzf-tui.sh" + # shellcheck source=/dev/null + [ -f "$lib/api.sh" ] && source "$lib/api.sh" + # shellcheck source=/dev/null + [ -f "$lib/arr.sh" ] && source "$lib/arr.sh" + # shellcheck source=/dev/null + [ -f "$lib/jellyfin.sh" ] && source "$lib/jellyfin.sh" + # shellcheck source=/dev/null + [ -f "$lib/seerr.sh" ] && source "$lib/seerr.sh" + # shellcheck source=/dev/null + [ -f "$lib/bazarr.sh" ] && source "$lib/bazarr.sh" + # shellcheck source=/dev/null + [ -f "$lib/prompts.sh" ] && source "$lib/prompts.sh" + + _config_edit_load_keys + _config_edit_loaded=1 +} + +_config_edit_load_keys() { + RADARR_API_KEY="${RADARR_API_KEY:-}" + SONARR_API_KEY="${SONARR_API_KEY:-}" + PROWLARR_API_KEY="${PROWLARR_API_KEY:-}" + if [ -z "$RADARR_API_KEY" ] && type read_api_key >/dev/null 2>&1; then + RADARR_API_KEY=$(read_api_key "radarr" 2>/dev/null || echo "") + fi + if [ -z "$SONARR_API_KEY" ] && type read_api_key >/dev/null 2>&1; then + SONARR_API_KEY=$(read_api_key "sonarr" 2>/dev/null || echo "") + fi + if [ -z "$PROWLARR_API_KEY" ] && type read_api_key >/dev/null 2>&1; then + PROWLARR_API_KEY=$(read_api_key "prowlarr" 2>/dev/null || echo "") + fi +} + +_config_edit_runtime_file() { + echo "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config" +} + +_config_edit_restart_stack() { + log_info "Restarting stack to apply compose/env changes..." + build_compose_args "$INSTALL_DIR" "${VPN_ENABLED:-n}" + if ! run_docker compose "${COMPOSE_ARGS[@]}" --profile "${MEDIA_SERVICE:-jellyfin}" up -d --remove-orphans; then + log_error "docker compose up failed" + fi + log_success "Stack restarted" +} + +_config_edit_pick() { + local data selected + data=$(_config_edit_catalog | awk -F'|' '{printf "%s\t%s\n", $1, $3}') + if type _fzf_can_render >/dev/null 2>&1 && _fzf_can_render && _fzf_has_tty; then + selected=$(echo "$data" | fzf \ + --prompt="Edit which setting> " \ + --delimiter='\t' \ + --with-nth=1,2 \ + --header='↑↓=navigate Enter=confirm Esc=cancel' \ + --height=60% \ + --layout=reverse \ + --border \ + 2>/dev/null | cut -f1 || true) + echo "$selected" + return 0 + fi + echo + log_info "What do you want to change?" + local i=1 id desc + while IFS='|' read -r id _ desc; do + printf " %2d) %-14s %s\n" "$i" "$id" "$desc" + i=$((i + 1)) + done < <(_config_edit_catalog) + local choice + read -p "Choose a section [1]: " choice || true + choice=${choice:-1} + if [[ "$choice" =~ ^[0-9]+$ ]]; then + sed -n "${choice}p" < <(config_edit_section_ids) + return 0 + fi + echo "$choice" +} + +# --- section appliers --- + +_config_edit_indexers() { + _config_edit_load + if [ -z "${PROWLARR_API_KEY:-}" ]; then + log_error "Prowlarr API key not available. Is Prowlarr running?" + fi + if ! wait_for_api "Prowlarr" "9696" "$PROWLARR_API_KEY" "/api/v1/system/status"; then + log_error "Prowlarr is not responding on port 9696" + fi + SELECTED_INDEXERS=() + configure_indexers "$PROWLARR_API_KEY" + if ui_picker_cancelled; then + return 0 + fi + sync_selected_indexers "$PROWLARR_API_KEY" + wait_for_arr_indexer_sync "$RADARR_API_KEY" || true + arr_set_indexer_min_seeders "Radarr" "7878" "$RADARR_API_KEY" || true + arr_set_indexer_min_seeders "Sonarr" "8989" "$SONARR_API_KEY" || true +} + +_config_edit_providers() { + _config_edit_load + SELECTED_SUBTITLE_PROVIDERS=() + configure_subtitle_providers + if ui_picker_cancelled; then + return 0 + fi + configure_bazarr +} + +_config_edit_language() { + _config_edit_load + configure_subtitle_language + config_set_kv "$(_config_edit_runtime_file)" "SUBTITLE_LANGUAGE" "${subtitle_language:-}" + SUBTITLE_LANGUAGE="${subtitle_language:-}" + configure_bazarr || true + if [ "${MEDIA_SERVICE:-}" = "jellyfin" ]; then + configure_jellyfin || true + fi +} + +_config_edit_profile() { + _config_edit_load + configure_seerr_profile + config_set_kv "$(_config_edit_runtime_file)" "SEERR_DEFAULT_PROFILE" "${seerr_default_profile:-1}" + config_set_kv "$(_config_edit_runtime_file)" "SEERR_IS_4K" "${seerr_is_4k:-false}" + SEERR_DEFAULT_PROFILE="${seerr_default_profile:-1}" + SEERR_IS_4K="${seerr_is_4k:-false}" + if [ -n "${RADARR_API_KEY:-}" ]; then + set_default_quality_profile "Radarr" "7878" "$RADARR_API_KEY" "lookup_radarr_profile" || true + fi + if [ -n "${SONARR_API_KEY:-}" ]; then + set_default_quality_profile "Sonarr" "8989" "$SONARR_API_KEY" "lookup_sonarr_profile" || true + fi + configure_seerr || true +} + +_config_edit_opensubtitles() { + _config_edit_load + configure_opensubtitles + local secrets_dir="$INSTALL_DIR/secrets" + mkdir -p "$secrets_dir" + if [ "${opensubtitles_enabled:-n}" = "y" ] && [ -n "${opensubtitles_username:-}" ]; then + echo -n "${opensubtitles_username}" > "$secrets_dir/opensubtitles_username.txt" + echo -n "${opensubtitles_password:-}" > "$secrets_dir/opensubtitles_password.txt" + chmod 600 "$secrets_dir/opensubtitles_username.txt" "$secrets_dir/opensubtitles_password.txt" + log_success "OpenSubtitles.com credentials written to $secrets_dir" + else + rm -f "$secrets_dir/opensubtitles_username.txt" "$secrets_dir/opensubtitles_password.txt" 2>/dev/null || true + log_info "OpenSubtitles.com credentials cleared" + fi + configure_bazarr || true +} + +_config_edit_auth() { + _config_edit_load + configure_auth + local secrets_dir="$INSTALL_DIR/secrets" + mkdir -p "$secrets_dir" + echo -n "${auth_username:-admin}" > "$secrets_dir/auth_username.txt" + echo -n "${auth_password:-}" > "$secrets_dir/auth_password.txt" + chmod 600 "$secrets_dir/auth_username.txt" "$secrets_dir/auth_password.txt" + AUTH_USERNAME="${auth_username:-admin}" + AUTH_PASSWORD="${auth_password:-}" + log_success "Auth credentials written to $secrets_dir" + qbit_set_credentials || true + if [ -n "${RADARR_API_KEY:-}" ]; then + set_arr_auth "Radarr" "7878" "$RADARR_API_KEY" "v3" || true + fi + if [ -n "${SONARR_API_KEY:-}" ]; then + set_arr_auth "Sonarr" "8989" "$SONARR_API_KEY" "v3" || true + fi + if [ -n "${PROWLARR_API_KEY:-}" ]; then + set_arr_auth "Prowlarr" "9696" "$PROWLARR_API_KEY" "v1" || true + fi +} + +_config_edit_timezone() { + _config_edit_load + configure_timezone + config_set_kv "$(_config_edit_runtime_file)" "TZ" "${tz:-UTC}" + config_set_kv "$INSTALL_DIR/.env" "TZ" "${tz:-UTC}" 0 + TZ="${tz:-UTC}" + _config_edit_restart_stack +} + +_config_edit_vpn() { + _config_edit_load + configure_vpn + local enabled="n" + [ "${setup_vpn,,}" = "y" ] && enabled="y" + VPN_ENABLED="$enabled" + VPN_TYPE="${vpn_type:-openvpn}" + VPN_SERVICE="${vpn_service:-}" + config_set_kv "$(_config_edit_runtime_file)" "VPN_ENABLED" "$VPN_ENABLED" + config_set_kv "$(_config_edit_runtime_file)" "VPN_TYPE" "$VPN_TYPE" + config_set_kv "$INSTALL_DIR/.env" "VPN_ENABLED" "$VPN_ENABLED" 0 + config_set_kv "$INSTALL_DIR/.env" "VPN_TYPE" "$VPN_TYPE" 0 + config_set_kv "$INSTALL_DIR/.env" "VPN_SERVICE" "$VPN_SERVICE" 0 + config_set_kv "$INSTALL_DIR/.env" "WIREGUARD_ADDRESSES" "${wireguard_addresses:-}" 0 + if [ "${enable_port_forwarding:-n}" = "y" ]; then + config_set_kv "$INSTALL_DIR/.env" "PORT_FORWARD_ONLY" "on" 0 + config_set_kv "$INSTALL_DIR/.env" "VPN_PORT_FORWARDING" "on" 0 + else + config_set_kv "$INSTALL_DIR/.env" "PORT_FORWARD_ONLY" "off" 0 + config_set_kv "$INSTALL_DIR/.env" "VPN_PORT_FORWARDING" "off" 0 + fi + + local secrets_dir="$INSTALL_DIR/secrets" + mkdir -p "$secrets_dir" + if [ "$enabled" = "y" ]; then + if [ "${vpn_type:-}" = "openvpn" ]; then + echo -n "${vpn_user:-}" > "$secrets_dir/openvpn_user.txt" + echo -n "${vpn_password:-}" > "$secrets_dir/openvpn_password.txt" + : > "$secrets_dir/wireguard_private_key.txt" + else + echo -n "${wireguard_private_key:-}" > "$secrets_dir/wireguard_private_key.txt" + : > "$secrets_dir/openvpn_user.txt" + : > "$secrets_dir/openvpn_password.txt" + fi + chmod 600 "$secrets_dir"/*.txt 2>/dev/null || true + log_success "VPN secrets written to $secrets_dir" + fi + if [ "${VPN_ENABLED:-n}" = "y" ]; then + QBITTORRENT_HOST="gluetun" + else + QBITTORRENT_HOST="qbittorrent" + fi + _config_edit_restart_stack + qbit_set_credentials || true +} + +_config_edit_media() { + _config_edit_load + local previous="${MEDIA_SERVICE:-jellyfin}" + configure_media_service + MEDIA_SERVICE="$media_service" + config_set_kv "$(_config_edit_runtime_file)" "MEDIA_SERVICE" "$MEDIA_SERVICE" + config_set_kv "$INSTALL_DIR/.env" "MEDIA_SERVICE" "$MEDIA_SERVICE" 0 + _config_edit_restart_stack + if [ "$MEDIA_SERVICE" = "jellyfin" ]; then + configure_jellyfin || true + configure_jellyfin_notifications || true + elif [ "$previous" != "$MEDIA_SERVICE" ]; then + log_info "Switched media server to ${MEDIA_SERVICE}. Finish any first-run UI setup, then re-run: ${APP_CLI_NAME:-assemblrr} config edit all" + fi +} + +_config_edit_all() { + ui_set_mode setup + echo "Re-running ${APP_DISPLAY_NAME:-assemblrr} setup wizard..." + echo "Current configuration will be used as defaults." + echo + local setup_script + if [ -f "$INSTALL_DIR/setup.sh" ]; then + setup_script="$INSTALL_DIR/setup.sh" + else + log_error "setup.sh not found in $INSTALL_DIR" + fi + bash "$setup_script" +} + +_config_edit_load_picker() { + local lib="${INSTALL_DIR:-}/lib" + if [ -f "$lib/fzf-tui.sh" ]; then + # shellcheck source=/dev/null + source "$lib/fzf-tui.sh" + elif [ -n "${_lib_dir:-}" ] && [ -f "$_lib_dir/fzf-tui.sh" ]; then + # shellcheck source=/dev/null + source "$_lib_dir/fzf-tui.sh" + fi +} + +config_edit_run() { + local raw="${1:-}" + local section="" + ui_set_mode edit + + if [ -z "$raw" ]; then + _config_edit_load_picker + raw=$(_config_edit_pick) + raw=$(echo "${raw:-}" | head -1 | tr -d '[:space:]') + if [ -z "$raw" ]; then + echo "Cancelled." + return 0 + fi + fi + + case "$raw" in + --help|-h|help) + config_edit_usage + return 0 + ;; + esac + + if ! section=$(config_edit_resolve "$raw"); then + log_error "Unknown config section: $raw\nRun '${APP_CLI_NAME:-assemblrr} config edit --help' for sections" + fi + + case "$section" in + indexers) _config_edit_indexers ;; + providers) _config_edit_providers ;; + language) _config_edit_language ;; + profile) _config_edit_profile ;; + opensubtitles) _config_edit_opensubtitles ;; + auth) _config_edit_auth ;; + timezone) _config_edit_timezone ;; + vpn) _config_edit_vpn ;; + media) _config_edit_media ;; + all) _config_edit_all ;; + *) log_error "Unknown config section: $section" ;; + esac +} diff --git a/lib/fzf-tui.sh b/lib/fzf-tui.sh index afbca5f..8a13a1c 100644 --- a/lib/fzf-tui.sh +++ b/lib/fzf-tui.sh @@ -6,6 +6,10 @@ set -euo pipefail +_fzf_tui_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +[ -f "$_fzf_tui_dir/ui.sh" ] && source "$_fzf_tui_dir/ui.sh" + SELECTED_INDEXERS=() SELECTED_SUBTITLE_PROVIDERS=() @@ -73,21 +77,67 @@ fzf_single_select() { fi } +# Format Prowlarr schema JSON + existing-name JSON array into TSV menu rows: +# namebadgesdescription +# Badges: [already enabled], [P] private (schema enable=false). +format_prowlarr_indexer_menu() { + local schema="$1" + local existing_json="${2:-[]}" + [ -n "$existing_json" ] || existing_json='[]' + echo "$schema" | jq -r --argjson existing "$existing_json" ' + sort_by(.name | ascii_downcase)[] | + . as $i | + (($existing | index($i.name)) != null) as $on | + ($i.enable | not) as $priv | + [ + $i.name, + ([if $on then "[already enabled]" else empty end, if $priv then "[P]" else empty end] | join(" ")), + (($i.description // "") | gsub("\n"; " ") | .[0:120]) + ] | @tsv + ' 2>/dev/null || true +} + +# fzf --bind start:… string that pre-selects rows whose first TSV field is in $2 +# (newline-separated names). Empty if nothing matches. +fzf_preselect_start_bind() { + local data="$1" + local names="$2" + [ -n "$data" ] && [ -n "$names" ] || return 0 + local i=0 name binds="" + while IFS=$'\t' read -r name _; do + i=$((i + 1)) + [ -n "$name" ] || continue + if printf '%s\n' "$names" | grep -q -F -x "$name"; then + if [ -n "$binds" ]; then + binds="${binds}+pos(${i})+select" + else + binds="pos(${i})+select" + fi + fi + done <<< "$data" + [ -n "$binds" ] && echo "start:${binds}" +} + # --- Generic multi-select fzf --- # Args: $1 = tab-separated list lines # $2 = prompt (optional) # $3 = header (optional) # $4 = result array name (optional; default SELECTED_INDEXERS) # $5 = noun for log messages (optional; default "item") +# $6 = newline-separated names to pre-select (optional) +# Sets FZF_SELECT_STATUS=confirmed|cancelled. +# Return 0 on confirm (array may be empty), 1 on cancel (Esc). # Appends selected first-column values into the named array. fzf_multi_select() { local data="$1" local prompt="${2:-Select items> }" - local header="${3:-Enter/TAB=toggle Ctrl-O=confirm Esc=skip}" + local header="${3:-Enter/TAB=toggle Ctrl-O=confirm Esc=cancel}" local result_var="${4:-SELECTED_INDEXERS}" local noun="${5:-item}" + local preselect_names="${6:-}" local has_tty=false local -n _fzf_result_ref="$result_var" + FZF_SELECT_STATUS="cancelled" _fzf_has_tty && has_tty=true @@ -96,50 +146,69 @@ fzf_multi_select() { fi if _fzf_can_render && $has_tty; then - local selected - selected=$(echo "$data" | fzf \ - --multi \ - --prompt="$prompt" \ - --delimiter='\t' \ - --with-nth=1..2 \ - --bind='enter:toggle' \ - --bind='tab:toggle' \ - --bind='ctrl-o:accept' \ - --header="$header" \ - --height=80% \ - --layout=reverse \ - --border \ - 2>/dev/null | cut -f1 || true) - + local selected fzf_rc=0 + local -a fzf_args=( + --multi + --prompt="$prompt" + --delimiter=$'\t' + --with-nth=1..2 + --bind='enter:toggle' + --bind='tab:toggle' + --bind='ctrl-o:accept' + --header="$header" + --height=80% + --layout=reverse + --border + ) + local start_bind + start_bind=$(fzf_preselect_start_bind "$data" "$preselect_names") + if [ -n "$start_bind" ]; then + fzf_args+=(--sync --bind="$start_bind") + fi + selected=$(echo "$data" | fzf "${fzf_args[@]}" 2>/dev/null) || fzf_rc=$? + if [ "$fzf_rc" -ne 0 ]; then + FZF_SELECT_STATUS="cancelled" + return 1 + fi + selected=$(printf '%s\n' "$selected" | cut -f1) + FZF_SELECT_STATUS="confirmed" if [ -n "$selected" ]; then while IFS= read -r idx; do [ -n "$idx" ] && _fzf_result_ref+=("$idx") done <<< "$selected" echo "Selected ${#_fzf_result_ref[@]} ${noun}(s): $(IFS=,; echo "${_fzf_result_ref[*]}")" >&2 - else - echo "No ${noun}s selected." >&2 fi - else - echo "No interactive terminal available — listing available ${noun}s." >&2 - echo "$data" | cut -f1 | head -40 | sed 's/^/ /' >&2 - echo "Enter ${noun} names separated by spaces (or blank to skip):" >&2 - read -r input_line || true - for idx in $input_line; do - _fzf_result_ref+=("$idx") - done + return 0 fi + + echo "No interactive terminal available — listing available ${noun}s." >&2 + echo "$data" | cut -f1 | head -40 | sed 's/^/ /' >&2 + echo "Enter ${noun} names separated by spaces (or blank to cancel):" >&2 + local input_line="" + read -r input_line || true + if [ -z "${input_line:-}" ]; then + FZF_SELECT_STATUS="cancelled" + return 1 + fi + FZF_SELECT_STATUS="confirmed" + for idx in $input_line; do + _fzf_result_ref+=("$idx") + done + return 0 } # --- Prowlarr Indexer Selection --- configure_indexers() { local provided_key="$1" + FZF_SELECT_STATUS="cancelled" if [ "${ASSEMBLRR_NONINTERACTIVE:-0}" = "1" ]; then - echo "Skipping optional Prowlarr indexer selection (non-interactive)." >&2 + log_info "Skipping Prowlarr indexer picker (non-interactive)." return 0 fi - echo >&2 - log_info "Optional: configure Prowlarr indexers now, or skip and use the Prowlarr UI later." >&2 - log_info "Only enable sources you are authorized to use." >&2 + ui_intro \ + "Optional: pick Prowlarr indexers now, or skip and use the Prowlarr UI later." \ + "Add or remove Prowlarr indexers. Esc keeps the current set." + log_info "Only enable sources you are authorized to use." # Try to fetch indexer list from a running Prowlarr instance local prowlarr_key="${provided_key:-}" @@ -158,16 +227,36 @@ configure_indexers() { fi if [ -n "$indexer_data" ] && echo "$indexer_data" | jq -e 'type == "array"' >/dev/null 2>&1; then - local formatted - formatted=$(echo "$indexer_data" | jq -r 'sort_by(.name | ascii_downcase)[] | "\(.name)\t\(if .enable then "" else "[P]" end)\t\(.description // "" | gsub("\n"; " ") | .[0:120])"' 2>/dev/null) + local existing_indexers existing_json formatted preselect_names + existing_indexers=$(curl -s --connect-timeout 3 \ + -H "X-Api-Key: $prowlarr_key" \ + "http://localhost:9696/api/v1/indexer" 2>/dev/null || true) + existing_json=$(echo "${existing_indexers:-}" | jq -c '[.[].name // empty]' 2>/dev/null || echo '[]') + [ -n "$existing_json" ] || existing_json='[]' + formatted=$(format_prowlarr_indexer_menu "$indexer_data" "$existing_json") + preselect_names=$(echo "$existing_json" | jq -r '.[]' 2>/dev/null || true) + local prompt header + if ui_is_edit; then + prompt="Prowlarr indexers> " + header="Enter/TAB=toggle Ctrl-O=save Esc=cancel [P]=private" + else + prompt="Prowlarr indexers (optional)> " + header="Enter/TAB=toggle Ctrl-O=confirm Esc=skip [P]=private" + fi fzf_multi_select "$formatted" \ - "Prowlarr indexers (optional)> " \ - "Enter/TAB=toggle Ctrl-O=confirm Esc=skip [P]=private" \ + "$prompt" \ + "$header" \ SELECTED_INDEXERS \ - "indexer" + "indexer" \ + "$preselect_names" || true + ui_picker_done "indexers" else - echo "Prowlarr is not responding — skipping optional indexer configuration." >&2 - echo "You can configure indexers later in the Prowlarr web UI if needed." >&2 + if ui_is_edit; then + log_info "Prowlarr is not responding — cannot edit indexers." + else + log_info "Prowlarr is not responding — skipping optional indexer setup." + log_info "You can add indexers later with: ${APP_CLI_NAME:-assemblrr} config edit indexers" + fi fi } @@ -199,30 +288,46 @@ _list_bazarr_providers_from_container() { # --- Bazarr subtitle provider selection (live from container, same idea as Prowlarr) --- configure_subtitle_providers() { SELECTED_SUBTITLE_PROVIDERS=() + FZF_SELECT_STATUS="cancelled" if [ "${ASSEMBLRR_NONINTERACTIVE:-0}" = "1" ]; then - echo "Skipping optional Bazarr provider selection (non-interactive)." >&2 + log_info "Skipping Bazarr provider picker (non-interactive)." return 0 fi - echo >&2 - log_info "Optional: configure Bazarr subtitle providers now, or skip and use the Bazarr UI later." >&2 - echo "Catalog comes from the running Bazarr image. Nothing is enabled unless you select it." >&2 - echo "OpenSubtitles.com is enabled automatically only if you saved credentials during setup." >&2 + ui_intro \ + "Optional: pick Bazarr subtitle providers now, or skip and use the Bazarr UI later." \ + "Choose which Bazarr subtitle providers to enable. Esc keeps the current set." + ui_note \ + "Catalog comes from the running Bazarr image. OpenSubtitles.com is enabled automatically only if you saved credentials." \ + "Catalog comes from the running Bazarr image." local names formatted if ! names=$(_list_bazarr_providers_from_container); then - echo "Bazarr container not available — skipping optional provider configuration." >&2 - echo "You can configure providers later in the Bazarr web UI if needed." >&2 + if ui_is_edit; then + log_info "Bazarr is not running — cannot edit providers." + else + log_info "Bazarr is not running — skipping optional provider setup." + log_info "You can set providers later with: ${APP_CLI_NAME:-assemblrr} config edit providers" + fi return 0 fi if [ -z "$names" ]; then - echo "No providers found in Bazarr image — skipping." >&2 + log_info "No providers found in the Bazarr image — skipping." return 0 fi formatted=$(echo "$names" | awk '{print $1 "\t"}') + local prompt header + if ui_is_edit; then + prompt="Bazarr providers> " + header="Enter/TAB=toggle Ctrl-O=save Esc=cancel" + else + prompt="Bazarr providers (optional)> " + header="Enter/TAB=toggle Ctrl-O=confirm Esc=skip (enable none)" + fi fzf_multi_select "$formatted" \ - "Bazarr providers (optional)> " \ - "Enter/TAB=toggle Ctrl-O=confirm Esc=skip (enable none)" \ + "$prompt" \ + "$header" \ SELECTED_SUBTITLE_PROVIDERS \ - "provider" + "provider" || true + ui_picker_done "providers" } diff --git a/lib/managed_files.sh b/lib/managed_files.sh index d1dfc3d..51447ea 100644 --- a/lib/managed_files.sh +++ b/lib/managed_files.sh @@ -25,6 +25,8 @@ lib/fzf-tui.sh|lib/fzf-tui.sh lib/managed_files.sh|lib/managed_files.sh lib/upgrade.sh|lib/upgrade.sh lib/services.sh|lib/services.sh +lib/ui.sh|lib/ui.sh +lib/config_edit.sh|lib/config_edit.sh compose/base.yaml|compose/base.yaml compose/direct-access.yaml|compose/direct-access.yaml compose/vpn.yaml|compose/vpn.yaml @@ -66,3 +68,18 @@ EOF done < <(find "$UPGRADE_SOURCE_ROOT/migrations" -type f -print0 2>/dev/null | sort -z) fi } + +# Lib modules the user-facing CLI copies next to ~/.local/bin/assemblrr. +list_cli_lib_modules() { + cat <<'EOF' +core +branding +compose +vpn +managed_files +upgrade +services +ui +config_edit +EOF +} diff --git a/lib/prompts.sh b/lib/prompts.sh index 69efb83..254a8c3 100644 --- a/lib/prompts.sh +++ b/lib/prompts.sh @@ -7,6 +7,10 @@ set -euo pipefail +_prompts_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=/dev/null +[ -f "$_prompts_dir/ui.sh" ] && source "$_prompts_dir/ui.sh" + configure_media_service() { # Express mode: use Jellyfin by default if [ "${SETUP_MODE:-}" = "express" ]; then @@ -17,9 +21,9 @@ configure_media_service() { return 0 fi - echo - echo - log_info "Time to choose your media service." + ui_intro \ + "Time to choose your media service." \ + "Change the media server. This restarts the stack." echo " 1) Jellyfin (recommended, easier)" echo " 2) Emby" echo " 3) Plex (advanced, uses host networking)" @@ -65,9 +69,9 @@ configure_seerr_profile() { return 0 fi - echo - echo - log_info "What default quality profile would you like to set for Seerr (Movies)?" + ui_intro \ + "What default quality profile would you like to set for Seerr (Movies)?" \ + "Change Seerr's default movie quality profile." echo " 1) Ultra-HD (4K - Optimized for HEVC/x265)" echo " 2) 1080p" echo " 3) Any (Radarr Default)" @@ -94,9 +98,9 @@ configure_subtitle_language() { local iso639_url="https://raw.githubusercontent.com/jellyfin/jellyfin/master/Emby.Server.Implementations/Localization/iso6392.txt" local awk_filter='$4 != "" {print $1 "\t" $4}' - echo - echo - log_info "Select your preferred subtitle language (Jellyfin playback + Bazarr downloads)." + ui_intro \ + "Select your preferred subtitle language (Jellyfin playback + Bazarr downloads)." \ + "Change the preferred subtitle language (Jellyfin + Bazarr)." # Use fzf single-select with fallback to "en" subtitle_language=$(fzf_single_select "$iso639_url" "$awk_filter" "") @@ -177,12 +181,14 @@ configure_opensubtitles() { opensubtitles_username="" opensubtitles_password="" - echo - echo - log_info "Optional: OpenSubtitles.com credentials for Bazarr (https://www.opensubtitles.com/)." + ui_intro \ + "Optional: OpenSubtitles.com credentials for Bazarr (https://www.opensubtitles.com/)." \ + "Update OpenSubtitles.com credentials for Bazarr. Answer n to clear them." log_info "The website login accepts email or username; Bazarr/API require your profile username." log_info "Find it under your OpenSubtitles profile (not the signup email)." - log_info "Other subtitle providers can be configured later in the Bazarr UI or optional picker." + ui_note \ + "Other subtitle providers can be configured later with: ${APP_CLI_NAME:-assemblrr} config edit providers" \ + "" read -p "Do you have OpenSubtitles.com credentials? (y/N) [Default = n]: " opensubtitles_enabled opensubtitles_enabled=${opensubtitles_enabled:-n} @@ -239,9 +245,9 @@ configure_opensubtitles() { } configure_vpn() { - echo - echo - log_info "Time to set up the VPN." + ui_intro \ + "Time to set up the VPN." \ + "Change VPN settings. This restarts the stack and can interrupt downloads." log_info "Supported VPN providers: https://github.com/qdm12/gluetun-wiki" read -p "Configure VPN? (Y/n) [Default = y]: " setup_vpn @@ -315,7 +321,9 @@ configure_vpn() { prompt_vpn_credentials - # Port forwarding + # Port forwarding — we do not maintain a provider allowlist (lists go stale). + log_info "Port forwarding only helps if your provider supports it (and Gluetun can use it)." + log_info "If they do not, leave this off — turning it on can limit which servers Gluetun picks." read -p "Enable port forwarding? (y/N) [Default = n]: " enable_port_forwarding enable_port_forwarding=${enable_port_forwarding:-"n"} @@ -366,10 +374,12 @@ prompt_vpn_credentials() { } configure_auth() { - echo - echo - log_info "Set credentials for your service web UIs." - log_info "These will be used to log into Radarr, Prowlarr, and other services." + ui_intro \ + "Set credentials for your service web UIs." \ + "Change the shared service login (Radarr, Sonarr, Prowlarr, qBittorrent)." + ui_note \ + "These will be used to log into Radarr, Prowlarr, and other services." \ + "Seerr / Jellyfin / Bazarr may still use the previous password until you sign in there or re-run wiring." echo auth_username="" diff --git a/lib/services.sh b/lib/services.sh index d5217a8..a861515 100644 --- a/lib/services.sh +++ b/lib/services.sh @@ -78,6 +78,20 @@ service_ui_url() { fi } +# True when a compose ps row is ready for the operator. +# healthy → ready; starting/unhealthy → not; no healthcheck + running → ready. +service_row_ready() { + local state="${1:-}" + local health="${2:-}" + state=$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]') + health=$(printf '%s' "$health" | tr '[:upper:]' '[:lower:]') + case "$health" in + healthy) return 0 ;; + unhealthy|starting) return 1 ;; + esac + [ "$state" = "running" ] +} + # Human-readable service URLs only (setup summary / scripting) running_services_location() { local host_ip="localhost" diff --git a/lib/ui.sh b/lib/ui.sh new file mode 100644 index 0000000..9084184 --- /dev/null +++ b/lib/ui.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Operator-facing UI mode for shared prompt/picker engines. +# setup = first install / full wizard (optional picks, skip is fine) +# edit = post-install `config edit` (change current state, Esc = no change) +# Does not run on source. + +if [ -n "${_ASSEMBLRR_UI_SOURCED:-}" ]; then + return 0 +fi +readonly _ASSEMBLRR_UI_SOURCED=1 + +ASSEMBLRR_UI_MODE="${ASSEMBLRR_UI_MODE:-setup}" +FZF_SELECT_STATUS="${FZF_SELECT_STATUS:-}" + +ui_mode() { + echo "${ASSEMBLRR_UI_MODE:-setup}" +} + +ui_is_edit() { + [ "${ASSEMBLRR_UI_MODE:-setup}" = "edit" ] +} + +ui_is_setup() { + ! ui_is_edit +} + +ui_set_mode() { + case "${1:-}" in + setup|edit) ASSEMBLRR_UI_MODE="$1" ;; + *) ASSEMBLRR_UI_MODE="setup" ;; + esac +} + +# Two-line intro: setup copy, then edit copy. +ui_intro() { + echo + echo + if ui_is_edit; then + log_info "$2" + else + log_info "$1" + fi +} + +ui_note() { + if ui_is_edit; then + [ -n "${2:-}" ] && log_info "$2" + else + [ -n "${1:-}" ] && log_info "$1" + fi +} + +# Picker wrap-up after fzf_multi_select. Uses FZF_SELECT_STATUS. +# $1 = plural noun (indexers, providers) +ui_picker_done() { + local noun="$1" + case "${FZF_SELECT_STATUS:-}" in + cancelled) + if ui_is_edit; then + log_info "Cancelled — no change." + else + log_info "Skipped ${noun}." + fi + ;; + confirmed) + if ui_is_setup; then + local n=0 + case "$noun" in + indexers) n=${#SELECTED_INDEXERS[@]} ;; + providers) n=${#SELECTED_SUBTITLE_PROVIDERS[@]} ;; + esac + if [ "$n" -eq 0 ]; then + log_info "No ${noun} selected." + fi + fi + ;; + esac +} + +ui_picker_cancelled() { + [ "${FZF_SELECT_STATUS:-}" = "cancelled" ] +} diff --git a/lib/upgrade.sh b/lib/upgrade.sh index 8936bcf..83b81c7 100644 --- a/lib/upgrade.sh +++ b/lib/upgrade.sh @@ -238,11 +238,16 @@ refresh_user_cli() { local install_dir="$1" mkdir -p "$HOME/.local/bin/lib" local m - for m in core branding compose vpn managed_files upgrade services; do + if ! type list_cli_lib_modules >/dev/null 2>&1; then + # shellcheck source=/dev/null + [ -f "$install_dir/lib/managed_files.sh" ] && source "$install_dir/lib/managed_files.sh" + fi + while IFS= read -r m; do + [ -z "$m" ] && continue if [ -f "$install_dir/lib/${m}.sh" ]; then cp "$install_dir/lib/${m}.sh" "$HOME/.local/bin/lib/${m}.sh" fi - done + done < <(list_cli_lib_modules) if [ -f "$install_dir/cli.sh" ]; then cp "$install_dir/cli.sh" "$HOME/.local/bin/${APP_CLI_NAME:-assemblrr}" chmod +x "$HOME/.local/bin/${APP_CLI_NAME:-assemblrr}" @@ -264,10 +269,12 @@ validate_install_compose() { # --- Public entrypoints --- -# upgrade_app [--check] [--from DIR] [--ref REF] [--skip-backup] [-y] +# upgrade_app [--check] [--from DIR] [--ref REF] [--skip-backup] [--skip-stack] [--skip-wire] [-y] upgrade_app() { local check_only=0 local skip_backup=0 + local skip_stack=0 + local skip_wire=0 local assume_yes=0 local from_dir="" local ref="" @@ -277,6 +284,8 @@ upgrade_app() { case "$1" in --check) check_only=1; shift ;; --skip-backup) skip_backup=1; shift ;; + --skip-stack) skip_stack=1; shift ;; + --skip-wire) skip_wire=1; shift ;; -y|--yes) assume_yes=1; shift ;; --from) from_dir="${2:-}" @@ -368,15 +377,21 @@ upgrade_app() { log_error "Compose invalid after upgrade. Restore with: ${APP_CLI_NAME:-assemblrr} restore " fi - log_info "Bringing stack up with updated compose..." - build_compose_args "$INSTALL_DIR" "${VPN_ENABLED:-n}" - # --remove-orphans drops containers for services removed from managed compose - # (e.g. Portainer) while leaving services still defined in custom.yaml alone. - if ! run_docker compose "${COMPOSE_ARGS[@]}" --profile "${MEDIA_SERVICE:-jellyfin}" up -d --build --remove-orphans; then - log_error "docker compose up failed. Restore with: ${APP_CLI_NAME:-assemblrr} restore " + if [ "$skip_stack" = "1" ]; then + log_info "Skipping stack restart (--skip-stack)" + else + log_info "Bringing stack up with updated compose..." + build_compose_args "$INSTALL_DIR" "${VPN_ENABLED:-n}" + # --remove-orphans drops containers for services removed from managed compose + # (e.g. Portainer) while leaving services still defined in custom.yaml alone. + if ! run_docker compose "${COMPOSE_ARGS[@]}" --profile "${MEDIA_SERVICE:-jellyfin}" up -d --build --remove-orphans; then + log_error "docker compose up failed. Restore with: ${APP_CLI_NAME:-assemblrr} restore " + fi fi - if [ -f "$INSTALL_DIR/config.sh" ]; then + if [ "$skip_wire" = "1" ]; then + log_info "Skipping service wiring (--skip-wire)" + elif [ -f "$INSTALL_DIR/config.sh" ]; then log_info "Wiring services..." # Re-wire must not open fzf/read prompts (would hang unattended upgrades # and can wipe Bazarr providers if the picker is skipped). diff --git a/tests/run.sh b/tests/run.sh index 4e45967..6c8a7aa 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -37,13 +37,16 @@ run_unit() { echo "Running unit tests..." for f in \ "$SCRIPT_DIR/unit/test_compose.sh" \ + "$SCRIPT_DIR/unit/test_config_edit.sh" \ "$SCRIPT_DIR/unit/test_core.sh" \ "$SCRIPT_DIR/unit/test_env_example.sh" \ + "$SCRIPT_DIR/unit/test_fzf_tui.sh" \ "$SCRIPT_DIR/unit/test_media_purge_match.sh" \ "$SCRIPT_DIR/unit/test_media_purge_watch.sh" \ "$SCRIPT_DIR/unit/test_prepare_install_dirs.sh" \ "$SCRIPT_DIR/unit/test_qbit_prefs.sh" \ "$SCRIPT_DIR/unit/test_seerr_gateway.sh" \ + "$SCRIPT_DIR/unit/test_ui.sh" \ "$SCRIPT_DIR/unit/test_upgrade.sh" \ "$SCRIPT_DIR/unit/test_vpn_secrets.sh" do diff --git a/tests/unit/test_config_edit.sh b/tests/unit/test_config_edit.sh new file mode 100644 index 0000000..907e2d9 --- /dev/null +++ b/tests/unit/test_config_edit.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Unit tests for modular config edit (no live stack required) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../helpers.sh" +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/core.sh" +stub_log_error_no_exit +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/config_edit.sh" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +test_suite "config_edit_section_ids" +ids=$(config_edit_section_ids) +assert_contains "$ids" "indexers" "lists indexers" +assert_contains "$ids" "providers" "lists providers" +assert_contains "$ids" "profile" "lists profile" +assert_contains "$ids" "vpn" "lists vpn" +assert_contains "$ids" "all" "lists all (full wizard)" + +test_suite "config_edit_resolve" +assert_eq "indexers" "$(config_edit_resolve indexers)" "resolves indexers" +assert_eq "providers" "$(config_edit_resolve subtitles)" "alias subtitles → providers" +assert_eq "profile" "$(config_edit_resolve quality)" "alias quality → profile" +assert_eq "all" "$(config_edit_resolve wizard)" "alias wizard → all" +assert_eq "all" "$(config_edit_resolve ALL)" "case-insensitive" +assert_failure "unknown section" config_edit_resolve not-a-section + +test_suite "config_set_kv" +cfg="$tmp/.assemblrr-config" +cat >"$cfg" <<'EOF' +INSTALL_DIRECTORY="/old" +TZ="UTC" +EOF +config_set_kv "$cfg" "TZ" "Europe/Athens" +config_set_kv "$cfg" "SEERR_IS_4K" "false" +assert_contains "$(cat "$cfg")" 'TZ="Europe/Athens"' "updates existing quoted key" +assert_contains "$(cat "$cfg")" 'SEERR_IS_4K="false"' "appends new quoted key" +assert_contains "$(cat "$cfg")" 'INSTALL_DIRECTORY="/old"' "leaves other keys" +# duplicate TZ lines should not remain +tz_count=$(grep -c '^TZ=' "$cfg" || true) +assert_eq "1" "$tz_count" "only one TZ= line after update" + +envf="$tmp/.env" +cat >"$envf" <<'EOF' +TZ=UTC +VPN_ENABLED=n +EOF +config_set_kv "$envf" "TZ" "Europe/Athens" 0 +config_set_kv "$envf" "VPN_ENABLED" "y" 0 +assert_contains "$(cat "$envf")" "TZ=Europe/Athens" "updates unquoted .env key" +assert_contains "$(cat "$envf")" "VPN_ENABLED=y" "updates VPN_ENABLED" +env_tz=$(grep -c '^TZ=' "$envf" || true) +assert_eq "1" "$env_tz" "only one TZ= line in .env" + +test_suite "config_edit_usage" +usage=$(config_edit_usage) +assert_contains "$usage" "config edit indexers" "usage mentions indexers example" +assert_contains "$usage" "Sections:" "usage lists sections" + +test_summary diff --git a/tests/unit/test_fzf_tui.sh b/tests/unit/test_fzf_tui.sh new file mode 100644 index 0000000..5ce2321 --- /dev/null +++ b/tests/unit/test_fzf_tui.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Unit tests for indexer fzf menu formatting / preselect binds +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../helpers.sh" +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/core.sh" +stub_log_error_no_exit +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/fzf-tui.sh" + +schema='[ + {"name":"Alpha","enable":true,"description":"first\nline"}, + {"name":"Bravo","enable":false,"description":"private tracker"}, + {"name":"Charlie","enable":true,"description":"already added"} +]' + +test_suite "format_prowlarr_indexer_menu" +menu=$(format_prowlarr_indexer_menu "$schema" '["Charlie"]') +assert_contains "$menu" $'Alpha\t\tfirst line' "public unused has empty badge" +assert_contains "$menu" $'Bravo\t[P]\tprivate tracker' "private unused marked [P]" +assert_contains "$menu" $'Charlie\t[already enabled]\talready added' "existing marked [already enabled]" + +both=$(format_prowlarr_indexer_menu "$schema" '["Bravo"]') +assert_contains "$both" $'Bravo\t[already enabled] [P]\tprivate tracker' "existing private gets both badges" + +empty=$(format_prowlarr_indexer_menu "$schema" '[]') +assert_not_contains "$empty" "[already enabled]" "no badge when none installed" + +test_suite "fzf_preselect_start_bind" +data=$'Alpha\t\nBravo\t[P]\nCharlie\t[already enabled]' +assert_eq "start:pos(3)+select" "$(fzf_preselect_start_bind "$data" $'Charlie')" "one existing" +assert_eq "start:pos(1)+select+pos(3)+select" "$(fzf_preselect_start_bind "$data" $'Alpha\nCharlie')" "two existing" +assert_eq "" "$(fzf_preselect_start_bind "$data" $'Nope')" "unknown name → empty" +assert_eq "" "$(fzf_preselect_start_bind "$data" "")" "empty names → empty" + +test_summary diff --git a/tests/unit/test_qbit_prefs.sh b/tests/unit/test_qbit_prefs.sh index 74c574d..2bea031 100644 --- a/tests/unit/test_qbit_prefs.sh +++ b/tests/unit/test_qbit_prefs.sh @@ -18,12 +18,14 @@ json=$(qbit_core_prefs_json) assert_contains "$json" '"current_network_interface":"tun0"' "VPN=y sets tun0" assert_contains "$json" '"save_path":"/data/torrents"' "save path always set" assert_contains "$json" '"temp_path_enabled":true' "incomplete path on" +assert_contains "$json" '"auto_tmm_enabled":true' "ATM on so category paths are used" test_suite "qbit_core_prefs_json VPN off leaves interface unset" VPN_ENABLED=n json=$(qbit_core_prefs_json) assert_not_contains "$json" "current_network_interface" "VPN=n does not force an interface" assert_contains "$json" '"save_path":"/data/torrents"' "save path still set" +assert_contains "$json" '"auto_tmm_enabled":true' "ATM still on without VPN" test_suite "qbit_core_prefs_json extras merge" VPN_ENABLED=y diff --git a/tests/unit/test_ui.sh b/tests/unit/test_ui.sh new file mode 100644 index 0000000..c7107c1 --- /dev/null +++ b/tests/unit/test_ui.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Unit tests for lib/ui.sh (setup vs edit copy) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../helpers.sh" +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/core.sh" +stub_log_error_no_exit +# shellcheck disable=SC1091 +source "$REPO_ROOT/lib/ui.sh" + +test_suite "ui_mode defaults to setup" +assert_eq "setup" "$(ui_mode)" "default mode is setup" +assert_true "ui_is_setup" ui_is_setup +assert_false "not edit by default" ui_is_edit + +test_suite "ui_set_mode" +ui_set_mode edit +assert_eq "edit" "$(ui_mode)" "edit mode" +assert_true "ui_is_edit" ui_is_edit +assert_false "not setup" ui_is_setup +ui_set_mode setup +assert_eq "setup" "$(ui_mode)" "back to setup" +ui_set_mode bogus +assert_eq "setup" "$(ui_mode)" "unknown mode falls back to setup" + +test_suite "ui_intro / ui_picker_done copy" +ui_set_mode setup +FZF_SELECT_STATUS=cancelled +SELECTED_INDEXERS=() +out=$(ui_picker_done indexers) +assert_contains "$out" "Skipped indexers." "setup cancel is skip" +ui_set_mode edit +out=$(ui_picker_done indexers) +assert_contains "$out" "Cancelled — no change." "edit cancel is no change" +ui_set_mode setup +FZF_SELECT_STATUS=confirmed +out=$(ui_picker_done indexers) +assert_contains "$out" "No indexers selected." "setup confirm-empty" +ui_set_mode edit +out=$(ui_picker_done indexers) +assert_eq "" "$out" "edit confirm-empty is silent (applier syncs)" + +test_summary diff --git a/tests/unit/test_upgrade.sh b/tests/unit/test_upgrade.sh index 5698193..e706943 100644 --- a/tests/unit/test_upgrade.sh +++ b/tests/unit/test_upgrade.sh @@ -19,6 +19,8 @@ list=$(list_managed_files) assert_true "has lib/bazarr.sh" "echo \"\$list\" | grep -q 'lib/bazarr.sh|lib/bazarr.sh'" assert_true "has lib/upgrade.sh" "echo \"\$list\" | grep -q 'lib/upgrade.sh|lib/upgrade.sh'" assert_true "has lib/services.sh" "echo \"\$list\" | grep -q 'lib/services.sh|lib/services.sh'" +assert_true "has lib/config_edit.sh" "echo \"\$list\" | grep -q 'lib/config_edit.sh|lib/config_edit.sh'" +assert_true "has lib/ui.sh" "echo \"\$list\" | grep -q 'lib/ui.sh|lib/ui.sh'" assert_true "has compose/base.yaml" "echo \"\$list\" | grep -q 'compose/base.yaml|compose/base.yaml'" assert_true "has vpn-watchdog Dockerfile" "echo \"\$list\" | grep -q 'compose/sidecars/vpn-watchdog.Dockerfile'" assert_true "has media-purge-watch Dockerfile" "echo \"\$list\" | grep -q 'compose/sidecars/media-purge-watch.Dockerfile'" @@ -76,6 +78,14 @@ assert_true "portainer not in catalog" "! echo \"$cat\" | grep -q '^portainer|'" url=$(service_ui_url 6767 / localhost) assert_eq "http://localhost:6767/" "$url" "bazarr url" +test_suite "service_row_ready uses Docker health" +assert_true "healthy is ready" "service_row_ready running healthy" +assert_false "starting is not ready" "service_row_ready running starting" +assert_false "unhealthy is not ready" "service_row_ready running unhealthy" +assert_true "no healthcheck + running is ready" "service_row_ready running ''" +assert_false "exited is not ready" "service_row_ready exited ''" +assert_false "created is not ready" "service_row_ready created ''" + test_suite "migration 002 skips when custom.yaml keeps portainer" tmp3=$(mktemp -d) mkdir -p "$tmp3/compose" @@ -99,5 +109,10 @@ rm -rf "$tmp3" test_suite "upgrade uses --build --remove-orphans and non-interactive wiring" assert_true "upgrade up -d --build --remove-orphans" "grep -q 'up -d --build --remove-orphans' \"$REPO_ROOT/lib/upgrade.sh\"" assert_true "upgrade wires with ASSEMBLRR_NONINTERACTIVE" "grep -q 'ASSEMBLRR_NONINTERACTIVE=1' \"$REPO_ROOT/lib/upgrade.sh\"" +assert_true "upgrade accepts --skip-stack" "grep -q -- '--skip-stack' \"$REPO_ROOT/lib/upgrade.sh\"" +assert_true "upgrade accepts --skip-wire" "grep -q -- '--skip-wire' \"$REPO_ROOT/lib/upgrade.sh\"" +mods=$(list_cli_lib_modules) +assert_contains "$mods" "config_edit" "CLI modules include config_edit" +assert_contains "$mods" "ui" "CLI modules include ui" test_summary