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.
This commit is contained in:
soulis-1256 2026-08-13 21:50:59 +03:00
parent 0696a83e2d
commit 710540fc40
No known key found for this signature in database
18 changed files with 1189 additions and 272 deletions

View file

@ -66,12 +66,15 @@ After install, the CLI is on your `PATH` as `assemblrr`. Run `assemblrr <command
| `logs [service]` | Follow logs (all services, or one) |
| `config` | List config subcommands |
| `config show` | Show current configuration |
| `config edit` | Re-run the setup wizard (current values as defaults) |
| `config edit` | Change one install setting (picker if omitted) |
| `config edit indexers` | Add/remove Prowlarr indexers (fzf) |
| `config edit all` | Re-run the full setup wizard |
| `backup /target/dir` | Snapshot configuration |
| `restore /backup.tar.gz` | Restore from a backup archive |
| `update-containers` | Pull latest images and restart (offers a backup first) |
| `update-cli` | Update the CLI to the latest version |
| `upgrade` | Upgrade from git `main` (or `--from DIR`): files, migrations, stack restart, service wiring |
| `upgrade --skip-stack` | Apply files and CLI without restarting containers |
| `upgrade --check` | Dry-run: show which managed files and migrations would run |
| `check-vpn` | Poll VPN health |
| `uninstall` | Remove everything (asks before deleting data) |

View file

@ -66,7 +66,8 @@ else
DC=(run_docker compose)
COMPOSE_ARGS=()
fi
readonly TIMEOUT_SECONDS=60
# 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"
@ -93,7 +94,7 @@ declare -A COMMANDS=(
["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 options, or run show / edit"
["config"]="configuration: show, or edit one setup section"
)
# log_error_inline — same as log_error but without exit (used by check_health)
@ -129,11 +130,13 @@ show_help() {
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 # Re-run setup wizard"
echo " ${APP_CLI_NAME} config edit # Pick a setup section to change"
echo " ${APP_CLI_NAME} config edit indexers # Reopen Prowlarr indexer fzf"
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"
@ -177,21 +180,33 @@ purge_media() {
wait_for_services() {
local wait_time=0
echo -n "Waiting for services to start"
local timeout="${WAIT_READY_SECONDS:-180}"
local total=0 ready=0
local -a waiting=()
while [ $wait_time -lt $TIMEOUT_SECONDS ]; do
local total_services
local running_services
echo -n "Waiting for services to become ready"
total_services=$("${DC[@]}" ps --format '{{.Name}}' | wc -l)
# grep -c exits 1 when the count is 0; ((wait_time++)) is also 0 on
# the first increment. Both abort the CLI under set -e.
running_services=$("${DC[@]}" ps --format '{{.Status}}' | grep -c "Up" || true)
running_services=${running_services:-0}
while [ $wait_time -lt "$timeout" ]; do
total=0
ready=0
waiting=()
if [ "$total_services" -eq "$running_services" ] && [ "$total_services" -gt 0 ]; then
local svc state health
while IFS=$'\t' read -r svc state health; do
[ -z "$svc" ] && continue
total=$((total + 1))
if type service_row_ready >/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"

View file

@ -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"

View file

@ -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

View file

@ -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

477
lib/config_edit.sh Normal file
View file

@ -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
}

View file

@ -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:
# name<TAB>badges<TAB>description
# 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"
}

View file

@ -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
}

View file

@ -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=""

View file

@ -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"

82
lib/ui.sh Normal file
View file

@ -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" ]
}

View file

@ -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 <backup.tar.gz>"
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 <backup.tar.gz>"
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 <backup.tar.gz>"
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).

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

46
tests/unit/test_ui.sh Normal file
View file

@ -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

View file

@ -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