#!/bin/bash # fzf-based TUI for selections (Prowlarr indexers, Bazarr providers, languages, paths) # Provides reusable fzf selection patterns for assemblrr. # Sets SELECTED_INDEXERS / SELECTED_SUBTITLE_PROVIDERS / SELECTED_SUBTITLE_LANGUAGES arrays. # Usage: source lib/fzf-tui.sh; configure_indexers "$api_key" 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=() SELECTED_SUBTITLE_LANGUAGES=() # Stored picker codes, preferred first. SUBTITLE_LANGUAGES="eng,ell" wins; # otherwise SUBTITLE_LANGUAGE (legacy single value). _subtitle_stored_lang_codes() { local raw="${SUBTITLE_LANGUAGES:-${SUBTITLE_LANGUAGE:-}}" raw=${raw// /} [ -z "$raw" ] && return 0 echo "$raw" | tr ',' '\n' | awk 'NF && !seen[$0]++' } # $1 = preferred code; remaining args = the full set (may include preferred). # Prints comma-separated, preferred first, unique. _subtitle_join_preferred_first() { local preferred="${1:-}" shift || true local -a out=() local c [ -n "$preferred" ] && out+=("$preferred") for c in "$@"; do [ -z "$c" ] && continue [ "$c" = "$preferred" ] && continue out+=("$c") done (IFS=,; echo "${out[*]}") } # --- TTY detection (reused across functions) --- _fzf_has_tty() { [ -t 0 ] && [ -t 2 ] } _fzf_can_render() { command -v fzf &>/dev/null && echo "test" | timeout 1 fzf --height=5 --no-mouse --filter="test" &>/dev/null } # TSV: keytitledetailinstallmedia # Extra args after the two defaults are injected storage roots (tests). format_path_menu() { local def_install="$1" local def_media="$2" shift 2 local app="${APP_NAME:-assemblrr}" local -a roots=() local root title free if [ "$#" -gt 0 ]; then roots=("$@") else mapfile -t roots < <(list_storage_roots) fi printf '%s\t%s\t%s\t%s\t%s\n' \ "home" "Home (default)" "${def_install} + ${def_media}" \ "$def_install" "$def_media" for root in "${roots[@]}"; do [ -n "$root" ] || continue root="${root%/}" [ "$root" = "${HOME:-}" ] && continue title=$(storage_root_label "$root") free=$(storage_free_label "$root") printf '%s\t%s\t%s\t%s\t%s\n' \ "all:${root}" "${title} — everything here" \ "${root}/${app} + ${root}/${app}-media${free}" \ "${root}/${app}" "${root}/${app}-media" printf '%s\t%s\t%s\t%s\t%s\n' \ "media:${root}" "${title} — media only (recommended)" \ "${def_install} + ${root}/${app}-media${free}" \ "$def_install" "${root}/${app}-media" done printf '%s\t%s\t%s\t%s\t%s\n' \ "browse" "Browse for a folder..." \ "Pick a parent, then choose everything vs media-only" "" "" printf '%s\t%s\t%s\t%s\t%s\n' \ "custom" "Type paths..." \ "Enter install and media directories yourself" "" "" } _fzf_select_path_row() { local def_install="${1:-}" local def_media="${2:-}" local data selected="" gen="" poke_wsl_automounts 2>/dev/null || true data=$(format_path_menu "$def_install" "$def_media") if _fzf_can_render && _fzf_has_tty; then gen=$(mktemp) cat > "$gen" </dev/null || true format_path_menu $(printf '%q' "$def_install") $(printf '%q' "$def_media") EOF chmod +x "$gen" selected=$(echo "$data" | fzf \ --delimiter=$'\t' \ --with-nth=2,3 \ --prompt="Install location> " \ --header="↑↓=navigate Enter=confirm r=refresh drives Esc=home default" \ --bind="r:reload($gen)+clear-query" \ --height=60% \ --reverse \ --border \ --scrollbar='│' \ 2>/dev/null) || true rm -f "$gen" echo "$selected" return 0 fi local i=0 line echo "$data" | while IFS=$'\t' read -r _ title detail _; do i=$((i + 1)) printf ' %d) %s\n %s\n' "$i" "$title" "$detail" >&2 done echo "Enter number (default: 1 = home): " >&2 local input="" read -r input || true input="${input:-1}" if [[ "$input" =~ ^[0-9]+$ ]]; then echo "$data" | awk -F '\t' -v n="$input" 'NR==n {print}' fi } _prompt_parent_usage() { local parent="$1" local def_install="$2" local app="${APP_NAME:-assemblrr}" local choice="" echo echo "Use ${parent} for:" echo " 1) Media only (recommended) — config stays in ${def_install}" echo " 2) Everything — ${parent}/${app} + ${parent}/${app}-media" read -p "Choose [1]: " choice choice="${choice:-1}" case "$choice" in 2) install_directory="${parent}/${app}" media_directory="${parent}/${app}-media" ;; *) install_directory="$def_install" media_directory="${parent}/${app}-media" ;; esac } _browse_parent_folder() { local app="${APP_NAME:-assemblrr}" local -a roots=() local r selected="" list list=$(mktemp) mapfile -t roots < <(list_storage_roots) { printf '%s\n' "${HOME:-/}" printf '%s\n' "${roots[@]}" for r in "${HOME:-/}" "${roots[@]}"; do [ -d "$r" ] || continue if [ "$r" = "/mnt/c" ]; then find "$r" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -80 else find "$r" -mindepth 1 -maxdepth 2 -type d 2>/dev/null | head -200 fi done } | sort -u > "$list" if _fzf_can_render && _fzf_has_tty; then selected=$(fzf \ --prompt="Parent folder> " \ --header="This folder will contain ${app} / ${app}-media. Esc=cancel" \ --height=60% --reverse --border \ < "$list" 2>/dev/null) || true else echo "Enter a parent folder path (blank to cancel): " >&2 read -r selected || true fi rm -f "$list" echo "$selected" } _prompt_typed_paths() { local def_install="$1" local def_media="$2" read -p "Installation directory? [${def_install}]: " install_directory install_directory=${install_directory:-$def_install} install_directory=$(expand_path "$install_directory") read -p "Media directory? [${def_media}]: " media_directory media_directory=${media_directory:-$def_media} media_directory=$(expand_path "$media_directory") } # Sets install_directory and media_directory from an fzf (or numbered) menu. pick_storage_paths() { local def_install="${APP_DEFAULT_INSTALL_DIR:-$HOME/${APP_NAME:-assemblrr}}" local def_media="${APP_DEFAULT_MEDIA_DIR:-$HOME/${APP_NAME:-assemblrr}-media}" local data row key parent if [ "${ASSEMBLRR_NONINTERACTIVE:-0}" = "1" ]; then install_directory="$def_install" media_directory="$def_media" return 0 fi while true; do row=$(_fzf_select_path_row "$def_install" "$def_media") if [ -z "$row" ]; then install_directory="$def_install" media_directory="$def_media" return 0 fi key=$(printf '%s\n' "$row" | cut -f1) case "$key" in home) install_directory=$(printf '%s\n' "$row" | cut -f4) media_directory=$(printf '%s\n' "$row" | cut -f5) return 0 ;; all:*|media:*) install_directory=$(printf '%s\n' "$row" | cut -f4) media_directory=$(printf '%s\n' "$row" | cut -f5) return 0 ;; browse) parent=$(_browse_parent_folder) if [ -z "$parent" ]; then continue fi parent=$(expand_path "$parent") if [ ! -d "$parent" ]; then log_warning "Not a directory: $parent" continue fi _prompt_parent_usage "$parent" "$def_install" return 0 ;; custom) _prompt_typed_paths "$def_install" "$def_media" return 0 ;; *) install_directory="$def_install" media_directory="$def_media" return 0 ;; esac done } # Single-select from TSV rows (first column is the value). # Args: $1 = data, $2 = prompt, $3 = header, $4 = default (Esc / empty / no TTY) # Puts the default row first so fzf highlights it. fzf_single_select_data() { local data="$1" local prompt="${2:-Select> }" local header="${3:-↑↓=navigate Enter=confirm Esc=default}" local default="${4:-}" if [ -z "$data" ]; then echo "$default" return 0 fi if [ -n "$default" ]; then local preferred_row rest preferred_row=$(printf '%s\n' "$data" | awk -F'\t' -v d="$default" '$1 == d { print; exit }') rest=$(printf '%s\n' "$data" | awk -F'\t' -v d="$default" '$1 != d { print }') if [ -n "$preferred_row" ]; then data="$preferred_row" [ -n "$rest" ] && data+=$'\n'"$rest" fi fi if ! _fzf_has_tty; then echo "$data" | head -25 >&2 echo "Enter selection (default: ${default:-first}): " >&2 local input="" read -r input || true if [ -z "$input" ]; then if [ -n "$default" ]; then echo "$default" else echo "$data" | head -1 | cut -f1 fi else echo "$input" | cut -f1 fi return 0 fi local selected selected=$(echo "$data" | fzf \ --prompt="$prompt" \ --height=40% \ --reverse \ --border \ --header="$header" \ --delimiter=$'\t' \ --with-nth=1..2 \ --scrollbar='│' \ 2>/dev/null | cut -f1) || true if [ -z "$selected" ]; then echo "$default" else echo "$selected" 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=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 if [ -z "$data" ]; then return 1 fi if _fzf_can_render && $has_tty; then 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 fi 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 log_info "Skipping Prowlarr indexer picker (non-interactive)." return 0 fi 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:-}" local indexer_data="" if [ -z "$prowlarr_key" ]; then if command -v docker &>/dev/null && docker ps --format '{{.Names}}' 2>/dev/null | grep -q prowlarr; then prowlarr_key=$(docker exec prowlarr cat /config/config.xml 2>/dev/null | sed -n 's/.*\(.*\)<\/ApiKey>.*/\1/p' | head -1 || true) fi fi if [ -n "$prowlarr_key" ]; then indexer_data=$(curl -s --connect-timeout 3 \ -H "X-Api-Key: $prowlarr_key" \ "http://localhost:9696/api/v1/indexer/schema" 2>/dev/null || true) fi if [ -n "$indexer_data" ] && echo "$indexer_data" | jq -e 'type == "array"' >/dev/null 2>&1; then 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" \ "$prompt" \ "$header" \ SELECTED_INDEXERS \ "indexer" \ "$preselect_names" || true ui_picker_done "indexers" else 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" fi fi } # List subtitle provider keys from the running Bazarr container (Bazarr's catalog, not ours). # Skips internal modules only (mixins/utils/helpers), not branded providers. _list_bazarr_providers_from_container() { local dirs=( /app/bazarr/bin/custom_libs/subliminal_patch/providers /app/bazarr/custom_libs/subliminal_patch/providers ) local d names="" if ! command -v docker &>/dev/null; then return 1 fi if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'bazarr'; then return 1 fi for d in "${dirs[@]}"; do names=$(docker exec bazarr sh -c \ "ls -1 '${d}'/*.py 2>/dev/null | xargs -n1 basename 2>/dev/null" 2>/dev/null || true) if [ -n "$names" ]; then echo "$names" | sed 's/\.py$//' | grep -vE '^(mixins|utils|avistaz_network|_.*)$' | sort -u return 0 fi done return 1 } # --- 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 log_info "Skipping Bazarr provider picker (non-interactive)." return 0 fi 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 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" fi return 0 fi if [ -z "$names" ]; then log_info "No providers found in the Bazarr image — skipping." return 0 fi formatted=$(echo "$names" | awk '{print $1 "\t"}') local prompt header preselect_names="" if ui_is_edit; then prompt="Bazarr providers> " header="Enter/TAB=toggle Ctrl-O=save Esc=cancel" if type _bazarr_config_file >/dev/null 2>&1 && type _bazarr_extract_apikey >/dev/null 2>&1; then local bcfg bkey bcfg=$(_bazarr_config_file 2>/dev/null || true) if [ -n "$bcfg" ]; then bkey=$(_bazarr_extract_apikey "$bcfg") fi if [ -n "${bkey:-}" ]; then preselect_names=$(curl -s --connect-timeout 5 \ "http://${API_HOST:-127.0.0.1}:${BAZARR_PORT:-6767}/api/system/settings?apikey=${bkey}" \ 2>/dev/null | jq -r '.general.enabled_providers[]? // empty' 2>/dev/null || true) fi fi else prompt="Bazarr providers (optional)> " header="Enter/TAB=toggle Ctrl-O=confirm Esc=skip" fi fzf_multi_select "$formatted" \ "$prompt" \ "$header" \ SELECTED_SUBTITLE_PROVIDERS \ "provider" \ "$preselect_names" || true ui_picker_done "providers" }