Record external-drive paths early and make uninstall find leftovers

A hung copy onto /mnt/e left the home pointer pointing at ~/assemblrr,
so uninstall --force --media never touched the E: trees. Write
INSTALL_DIRECTORY and MEDIA_DIRECTORY as soon as paths are chosen,
scan mounted drives for leftover assemblrr dirs, time out stuck
USB/WSL I/O, and let r refresh the drive picker (WSL automount).
This commit is contained in:
soulis-1256 2026-08-16 14:15:46 +03:00
parent 229ca00795
commit 843d8dfc61
No known key found for this signature in database
6 changed files with 340 additions and 34 deletions

View file

@ -44,7 +44,9 @@ if [ -z "$INSTALL_DIR" ]; then
log_error "Could not find ${APP_NAME:-assemblrr} installation. Run setup first."
fi
# Source runtime config (may be partial during mid-setup abort)
# Pointer first — has MEDIA_DIRECTORY even when the install tree never finished
# (path picker chose /mnt/e, then copy hung). Install-dir config overrides.
load_install_pointer || true
if [ -f "$INSTALL_DIR/.assemblrr-config" ]; then
safe_source "$INSTALL_DIR/.assemblrr-config"
elif [ -f "$INSTALL_DIR/.${APP_NAME:-assemblrr}-config" ]; then
@ -429,6 +431,30 @@ uninstall_app() {
log_info "Media directory preserved at $MEDIA_DIRECTORY"
fi
# Path picker / hung setup can leave assemblrr trees on other mounts
# (e.g. /mnt/e) that the pointer never recorded.
poke_wsl_automounts 2>/dev/null || true
local leftover_kind leftover_path
while IFS=$'\t' read -r leftover_kind leftover_path; do
[ -n "$leftover_path" ] || continue
case "$leftover_kind" in
install)
log_warning "Found leftover install at $leftover_path"
safe_rm_rf "$leftover_path"
log_warning "Leftover install deleted"
;;
media)
if [ "$delete_media" = true ]; then
log_warning "Found leftover media at $leftover_path"
safe_rm_rf "$leftover_path"
log_warning "Leftover media deleted"
else
log_info "Left leftover media at $leftover_path (pass --media to delete)"
fi
;;
esac
done < <(list_assemblrr_leftovers "$INSTALL_DIR" "$MEDIA_DIRECTORY")
echo "Removing CLI..."
if [ -n "${APP_CLI_NAME:-}" ]; then
rm -f "$HOME/.local/bin/$APP_CLI_NAME" "/usr/local/bin/$APP_CLI_NAME" 2>/dev/null || true

View file

@ -249,16 +249,32 @@ source "$APP_ROOT/lib/fzf-tui.sh"
source "$APP_ROOT/lib/services.sh"
source "$APP_ROOT/lib/prompts.sh"
_copy_stall_error() {
log_error "Timed out writing to ${install_directory} — the drive looks stuck (flaky USB / WSL). From PowerShell: wsl --shutdown — then check the drive in Explorer, or re-run setup and pick Home."
}
copy_configuration_files() {
local copied=0 src dest src_path dest_path dest_dir
log_debug "Copying install tree into $install_directory"
local copied=0 src dest src_path dest_path dest_dir rc
if storage_needs_probe "$install_directory"; then
log_info "Copying install files to ${install_directory}..."
if ! probe_writable_path "$(storage_mount_root "$install_directory")"; then
log_error "Cannot copy install files to ${install_directory}. Pick Home, or fix the drive and re-run setup."
fi
else
log_debug "Copying install tree into $install_directory"
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 [ ! -d "$dest_dir" ]; then
rc=0
run_with_timeout 15 mkdir -p "$dest_dir" || rc=$?
[ "$rc" -eq 124 ] && _copy_stall_error
[ "$rc" -eq 0 ] || log_error "Failed to create $dest_dir"
fi
if ! src_path=$(resolve_project_file "$src"); then
if [[ "$src" == compose/examples/* ]]; then
@ -269,7 +285,11 @@ copy_configuration_files() {
fi
log_debug " $src -> $dest_path"
if cp "$src_path" "$dest_path"; then
rc=0
run_with_timeout 20 cp "$src_path" "$dest_path" || rc=$?
if [ "$rc" -eq 124 ]; then
_copy_stall_error
elif [ "$rc" -eq 0 ]; then
case "$src" in
scripts/*|bin/cli.sh|bin/config.sh|bin/setup.sh) chmod +x "$dest_path" ;;
esac
@ -541,11 +561,7 @@ SUBTITLE_LANGUAGE="${subtitle_language:-}"
SETUP_MODE="${SETUP_MODE:-manual}"
EOF
chmod 600 "$config_file"
# Pointer so CLI discovery works even for non-default install paths
if [ "$config_file" != "$HOME/.${APP_NAME}-config" ]; then
printf 'INSTALL_DIRECTORY="%s"\n' "$install_directory" > "$HOME/.${APP_NAME}-config"
chmod 600 "$HOME/.${APP_NAME}-config"
fi
write_install_pointer "$install_directory" "$media_directory"
log_debug "Runtime config written to $config_file"
}

View file

@ -98,6 +98,78 @@ find_install_directory() {
return 1
}
# Home pointer so the CLI can find a non-default (or mid-setup) install.
# Includes MEDIA_DIRECTORY so uninstall --media can see an external drive
# even if the install tree was never finished.
write_install_pointer() {
local install_dir="$1"
local media_dir="${2:-}"
local pointer="$HOME/.assemblrr-config"
{
printf 'INSTALL_DIRECTORY="%s"\n' "$install_dir"
if [ -n "$media_dir" ]; then
printf 'MEDIA_DIRECTORY="%s"\n' "$media_dir"
fi
} > "$pointer"
chmod 600 "$pointer"
}
load_install_pointer() {
local pointer=""
if [ -f "$HOME/.assemblrr-config" ]; then
pointer="$HOME/.assemblrr-config"
elif [ -f "$HOME/.${APP_NAME:-assemblrr}-config" ]; then
pointer="$HOME/.${APP_NAME:-assemblrr}-config"
else
return 1
fi
safe_source "$pointer"
}
is_assemblrr_install_tree() {
local d="$1"
local name="${APP_NAME:-assemblrr}"
[ -d "$d" ] || return 1
[ "$(basename "$d")" = "$name" ] || return 1
if [ -f "$d/.assemblrr-config" ] || [ -f "$d/.${name}-config" ] || \
[ -f "$d/cli.sh" ] || [ -f "$d/compose/base.yaml" ] || [ -d "$d/lib" ]; then
return 0
fi
# mkdir-only leftover from a setup that died before copying files
[ -z "$(find "$d" -mindepth 1 -maxdepth 1 2>/dev/null | head -1)" ]
}
is_assemblrr_media_tree() {
local d="$1"
local name="${APP_NAME:-assemblrr}"
[ -d "$d" ] || return 1
[ "$(basename "$d")" = "${name}-media" ] || return 1
[ -d "$d/torrents/movies" ] || [ -d "$d/media/movies" ] || [ -d "$d/blackhole" ]
}
# TSV: kind<TAB>path kind is install|media
# Optional $1/$2 are already-known paths to skip.
list_assemblrr_leftovers() {
local skip_install="${1:-}"
local skip_media="${2:-}"
local root d
local -A seen=()
while IFS= read -r root; do
[ -n "$root" ] || continue
[ -n "${seen[$root]:-}" ] && continue
seen[$root]=1
d="${root}/${APP_NAME:-assemblrr}"
if [ -d "$d" ] && [ "$d" != "$skip_install" ] && is_assemblrr_install_tree "$d"; then
printf 'install\t%s\n' "$d"
fi
d="${root}/${APP_NAME:-assemblrr}-media"
if [ -d "$d" ] && [ "$d" != "$skip_media" ] && is_assemblrr_media_tree "$d"; then
printf 'media\t%s\n' "$d"
fi
done < <({ printf '%s\n' "${HOME:-}"; list_storage_roots; })
}
# --- Path utilities ---
# Expand tilde in user input paths (no eval — safe from code injection)
@ -167,6 +239,107 @@ list_storage_roots() {
done
}
# WSL often does not attach a USB letter until something touches /mnt/<letter>.
# Ask Windows which letters exist, then ls each so automount can catch up.
poke_wsl_automounts() {
grep -qi microsoft /proc/version 2>/dev/null || return 0
local letters letter mountpoint
letters=$(timeout 5 cmd.exe /c "wmic logicaldisk get name" 2>/dev/null \
| tr -d '\r' | grep -Eo '[A-Za-z]:' | tr -d ':' | tr '[:upper:]' '[:lower:]' || true)
for letter in $letters; do
mountpoint="/mnt/$letter"
mkdir -p "$mountpoint" 2>/dev/null || true
timeout 2 ls "$mountpoint" >/dev/null 2>&1 || true
if ! grep -Eq "[[:space:]]${mountpoint}[[:space:]]" /proc/mounts 2>/dev/null; then
rmdir "$mountpoint" 2>/dev/null || true
fi
done
for mountpoint in /mnt/[a-z]; do
[ -d "$mountpoint" ] || continue
timeout 2 ls "$mountpoint" >/dev/null 2>&1 || true
done
}
# Mount root for a path we might install onto (/mnt/e/foo → /mnt/e).
storage_mount_root() {
local p="${1%/}"
local rest
case "$p" in
/mnt/[a-z])
echo "$p"
;;
/mnt/[a-z]/*)
rest="${p#/mnt/}"
echo "/mnt/${rest%%/*}"
;;
/run/media/*)
echo "$p" | awk -F/ '{ if (NF >= 5) print "/"$2"/"$3"/"$4"/"$5; else print $0 }'
;;
/media/*)
echo "$p" | awk -F/ '{ if (NF >= 4) print "/"$2"/"$3"/"$4; else print $0 }'
;;
*)
echo "$p"
;;
esac
}
# True for WSL drive letters and typical removable-media mounts.
storage_needs_probe() {
case "${1%/}" in
/mnt/[a-z]|/mnt/[a-z]/*|/media/*|/run/media/*) return 0 ;;
*) return 1 ;;
esac
}
run_with_timeout() {
local secs="$1"
shift
if command -v timeout >/dev/null 2>&1; then
timeout --foreground "$secs" "$@"
else
"$@"
fi
}
# Timed write/read/unlink on the mount. 0 = ok, 1 = fail/timeout (already logged).
probe_writable_path() {
local path="$1"
local secs="${2:-8}"
local root probe start now elapsed rc=0
root=$(storage_mount_root "$path")
[ -n "$root" ] || return 1
probe="$root/.assemblrr-write-test.$$"
start=$(date +%s)
export ASSEMBLRR_PROBE_FILE="$probe"
run_with_timeout "$secs" bash -c \
'printf ok > "$ASSEMBLRR_PROBE_FILE" && grep -qx ok "$ASSEMBLRR_PROBE_FILE" && rm -f "$ASSEMBLRR_PROBE_FILE"' \
|| rc=$?
unset ASSEMBLRR_PROBE_FILE
rm -f "$probe" 2>/dev/null || true
now=$(date +%s)
elapsed=$((now - start))
if [ "$rc" -eq 124 ]; then
log_warning "No response from ${root} after ${secs}s — the drive looks stuck."
log_info "Common on flaky USB / WSL drive letters (Explorer may freeze on that disk too)."
log_info "From PowerShell: wsl --shutdown"
log_info "Then check the drive in Explorer, or pick Home in the list."
return 1
fi
if [ "$rc" -ne 0 ]; then
log_warning "Cannot write to ${root} (disconnected, or no permission)."
return 1
fi
if [ "$elapsed" -ge 3 ]; then
log_warning "${root} is slow (${elapsed}s). USB/WSL mounts can stall setup and Docker later."
fi
return 0
}
# Persist ~/.local/bin on PATH for bash, zsh, and fish.
# Does not create ~/.bash_profile (that would hide ~/.profile on login bash).
_append_path_line() {
@ -255,13 +428,17 @@ safe_rm_rf() {
create_and_verify_directory() {
local dir="$1"
local dir_type="$2"
local rc=0
if [ ! -d "$dir" ]; then
echo "The directory \"$dir\" does not exist. Attempting to create..."
if mkdir -p "$dir"; then
log_success "Directory $dir created"
else
run_with_timeout 15 mkdir -p "$dir" || rc=$?
if [ "$rc" -eq 124 ]; then
log_error "Timed out creating \"$dir\" (15s). The drive looks stuck (flaky USB / WSL). From PowerShell: wsl --shutdown — then check the drive in Explorer, or re-run setup and pick Home."
elif [ "$rc" -ne 0 ]; then
log_error "Failed to create $dir_type directory at \"$dir\". Check permissions"
else
log_success "Directory $dir created"
fi
fi

View file

@ -67,19 +67,36 @@ format_path_menu() {
}
_fzf_select_path_row() {
local data="$1"
local selected=""
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" <<EOF
#!/bin/bash
set -euo pipefail
# shellcheck source=/dev/null
source $(printf '%q' "$_fzf_tui_dir/core.sh")
# shellcheck source=/dev/null
source $(printf '%q' "$_fzf_tui_dir/fzf-tui.sh")
poke_wsl_automounts 2>/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 Esc=home default" \
--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
@ -178,8 +195,7 @@ pick_storage_paths() {
fi
while true; do
data=$(format_path_menu "$def_install" "$def_media")
row=$(_fzf_select_path_row "$data")
row=$(_fzf_select_path_row "$def_install" "$def_media")
if [ -z "$row" ]; then
install_directory="$def_install"
media_directory="$def_media"

View file

@ -467,30 +467,60 @@ get_user_info() {
export username puid pgid
}
_probe_selected_storage() {
local p root
local -A seen=()
for p in "$install_directory" "$media_directory"; do
storage_needs_probe "$p" || continue
root=$(storage_mount_root "$p")
[ -n "$root" ] || continue
[ -n "${seen[$root]:-}" ] && continue
seen[$root]=1
log_info "Checking ${root} is responding..."
if ! probe_writable_path "$root"; then
echo
log_info "Pick another location (Home is safest), or fix the drive and choose it again."
return 1
fi
done
return 0
}
get_installation_paths() {
ui_intro \
"Where should assemblrr store config and media? External drives (WSL E:, USB, …) show up in the list." \
"Change install and media directories. This does not move existing files."
pick_storage_paths
install_directory=$(expand_path "$install_directory")
media_directory=$(expand_path "$media_directory")
while true; do
pick_storage_paths
install_directory=$(expand_path "$install_directory")
media_directory=$(expand_path "$media_directory")
# VPN gate bootstraps ~/assemblrr first. If the user then picked another
# install path, drop that leftover tree so CLI discovery cannot shadow it.
if [ -n "${_ASSEMBLRR_PROVISIONAL_INSTALL:-}" ] && \
[ "$install_directory" != "$_ASSEMBLRR_PROVISIONAL_INSTALL" ] && \
[ -d "$_ASSEMBLRR_PROVISIONAL_INSTALL" ]; then
log_info "Install path changed — removing temporary files at $_ASSEMBLRR_PROVISIONAL_INSTALL"
safe_rm_rf "$_ASSEMBLRR_PROVISIONAL_INSTALL"
fi
if ! _probe_selected_storage; then
continue
fi
create_and_verify_directory "$install_directory" "installation"
setup_directory_structure "$media_directory"
verify_user_permissions "$username" "$media_directory"
# Record both paths before any copy so uninstall can find an external
# drive even if setup dies mid-copy.
write_install_pointer "$install_directory" "$media_directory"
log_success "Install directory: $install_directory"
log_success "Media directory: $media_directory"
# VPN gate bootstraps ~/assemblrr first. If the user then picked another
# install path, drop that leftover tree so CLI discovery cannot shadow it.
if [ -n "${_ASSEMBLRR_PROVISIONAL_INSTALL:-}" ] && \
[ "$install_directory" != "$_ASSEMBLRR_PROVISIONAL_INSTALL" ] && \
[ -d "$_ASSEMBLRR_PROVISIONAL_INSTALL" ]; then
log_info "Install path changed — removing temporary files at $_ASSEMBLRR_PROVISIONAL_INSTALL"
safe_rm_rf "$_ASSEMBLRR_PROVISIONAL_INSTALL"
fi
create_and_verify_directory "$install_directory" "installation"
setup_directory_structure "$media_directory"
verify_user_permissions "$username" "$media_directory"
log_success "Install directory: $install_directory"
log_success "Media directory: $media_directory"
break
done
export install_directory media_directory
}

View file

@ -83,6 +83,29 @@ printf 'INSTALL_DIRECTORY="%s"\n' "$fid_home/on-disk" > "$fid_home/.assemblrr-co
assert_eq "$fid_home/on-disk" "$(HOME="$fid_home" find_install_directory)" \
"pointer wins over leftover ~/assemblrr"
test_suite "write_install_pointer + leftovers"
ptr_home="$tmp/ptr-home"
mkdir -p "$ptr_home"
HOME="$ptr_home" write_install_pointer /mnt/e/assemblrr /mnt/e/assemblrr-media
assert_contains "$(cat "$ptr_home/.assemblrr-config")" 'INSTALL_DIRECTORY="/mnt/e/assemblrr"' "pointer has install"
assert_contains "$(cat "$ptr_home/.assemblrr-config")" 'MEDIA_DIRECTORY="/mnt/e/assemblrr-media"' "pointer has media"
HOME="$ptr_home" load_install_pointer
assert_eq "/mnt/e/assemblrr" "${INSTALL_DIRECTORY:-}" "load install"
assert_eq "/mnt/e/assemblrr-media" "${MEDIA_DIRECTORY:-}" "load media"
empty_inst="$ptr_home/assemblrr"
mkdir -p "$empty_inst"
mkdir -p "$ptr_home/assemblrr-media/torrents/movies"
mkdir -p "$ptr_home/not-ours/assemblrr-media"
assert_success "empty named tree is leftover install" is_assemblrr_install_tree "$empty_inst"
assert_success "torrents/movies is leftover media" is_assemblrr_media_tree "$ptr_home/assemblrr-media"
assert_failure "unrelated folder is not leftover media" is_assemblrr_media_tree "$ptr_home/not-ours/assemblrr-media"
left=$(HOME="$ptr_home" list_assemblrr_leftovers)
assert_contains "$left" $'install\t'"$empty_inst" "lists home leftover install"
assert_contains "$left" $'media\t'"$ptr_home/assemblrr-media" "lists home leftover media"
skipped=$(HOME="$ptr_home" list_assemblrr_leftovers "$empty_inst" "$ptr_home/assemblrr-media")
assert_eq "" "$skipped" "skips known paths"
test_suite "storage_root_label"
assert_eq "Windows E:" "$(storage_root_label /mnt/e)" "WSL E:"
assert_eq "Windows C:" "$(storage_root_label /mnt/c/)" "strips trailing slash"
@ -90,6 +113,24 @@ assert_eq "Seagate" "$(storage_root_label /media/user/Seagate)" "linux /media"
assert_eq "USB" "$(storage_root_label /run/media/user/USB)" "linux /run/media"
assert_eq "/data" "$(storage_root_label /data)" "other path unchanged"
test_suite "storage_mount_root / storage_needs_probe"
assert_eq "/mnt/e" "$(storage_mount_root /mnt/e)" "letter is the root"
assert_eq "/mnt/e" "$(storage_mount_root /mnt/e/assemblrr)" "strips under WSL letter"
assert_eq "/mnt/e" "$(storage_mount_root /mnt/e/assemblrr-media/)" "media path"
assert_eq "/media/u/disk" "$(storage_mount_root /media/u/disk/foo)" "linux /media label"
assert_eq "/run/media/u/USB" "$(storage_mount_root /run/media/u/USB/data)" "run/media label"
assert_eq "/home/u/assemblrr" "$(storage_mount_root /home/u/assemblrr)" "home unchanged"
assert_success "WSL letter needs probe" storage_needs_probe /mnt/e/assemblrr
assert_success "linux media needs probe" storage_needs_probe /media/u/disk
assert_failure "home does not need probe" storage_needs_probe /home/u/assemblrr
test_suite "probe_writable_path"
probe_ok="$tmp/probe-ok"
mkdir -p "$probe_ok"
assert_success "writable tmp is ok" probe_writable_path "$probe_ok"
assert_true "probe file cleaned up" "[ -z \"\$(ls -A \"$probe_ok\"/.assemblrr-write-test.* 2>/dev/null)\" ]"
assert_failure "missing path fails" probe_writable_path "$tmp/no-such-dir/nested"
test_suite "ensure_local_bin_on_path"
path_home="$tmp/path-home"
mkdir -p "$path_home"