assemblrr/lib/core.sh
soulis-1256 bd126b704e
Fix quality profile setup and split Recyclarr into include packs
Always sync HD/UHD (and Sonarr WEB) as include packs, name them with
an assemblrr prefix, soften blocking CF scores, zero quality minSize,
relax minimumSeeders, and update Seerr on reconfigure. Document TRaSH
guide drift and tighten comments and uninstall messaging.
2026-08-05 16:12:47 +03:00

473 lines
15 KiB
Bash

#!/bin/bash
# assemblrr core library — sourced by all other lib modules and entry points
# Provides: color codes, logging, safe_source, find_install_directory,
# path utilities, directory helpers, dot_inline
# Guard against double-sourcing (readonly arrays would fail on re-source)
if [ -n "${_ASSEMBLRR_CORE_SOURCED:-}" ]; then
return 0
fi
readonly _ASSEMBLRR_CORE_SOURCED=1
# --- Color codes ---
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly NC='\033[0m'
# --- Logging ---
# Optional: set LOG_FILE before sourcing lib/core.sh to enable file logging
_log_to_file() {
if [ -n "${LOG_FILE:-}" ]; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
fi
return 0
}
log_success() { echo -e "${GREEN}$1${NC}"; _log_to_file "INFO: $1"; return 0; }
log_error() { echo -e "${RED}$1${NC}" >&2; _log_to_file "ERROR: $1"; exit 1; }
log_warning() { echo -e "${YELLOW}$1${NC}"; _log_to_file "WARN: $1"; return 0; }
log_info() { echo "$1"; _log_to_file "INFO: $1"; return 0; }
log_debug() { _log_to_file "DEBUG: $1"; return 0; }
# --- Progress indicators ---
# Inline dot for polling loops (simple, foreground, no process management)
dot_inline() { printf "." >&2; }
# --- Safe source ---
# Safely source a config file — validates that it contains only KEY=VALUE
# assignments (no command substitution, pipes, etc.) before sourcing
safe_source() {
local file="$1"
if [ ! -f "$file" ]; then
return 1
fi
local line
while IFS= read -r line || [ -n "$line" ]; do
# Skip blank lines and comments
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
[[ "$line" =~ ^[[:space:]]*# ]] && continue
# Must match KEY=VALUE pattern (KEY: alphanumeric + underscore)
if ! [[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
echo -e "${RED}Rejecting unsafe line in $file: $line${NC}" >&2
return 1
fi
# Reject dangerous shell constructs in the value
case "$line" in
*'$('* | *'`'* | *';'* | *'||'* | *'&&'* | *'>'* | *'<'*)
echo -e "${RED}Rejecting unsafe line in $file: $line${NC}" >&2
return 1
;;
esac
done < "$file"
# shellcheck disable=SC1090
source "$file"
}
# --- Install directory discovery ---
# Discover installation directory from runtime config
find_install_directory() {
local search_order=(
"/opt/assemblrr/.assemblrr-config"
"$HOME/assemblrr/.assemblrr-config"
"$HOME/.assemblrr-config"
)
# Check ASSEMBLRR_DIR env var first
if [ -n "${ASSEMBLRR_DIR:-}" ]; then
echo "$ASSEMBLRR_DIR"
return 0
fi
for config_file in "${search_order[@]}"; do
if [ -f "$config_file" ]; then
safe_source "$config_file"
if [ -n "${INSTALL_DIRECTORY:-}" ]; then
echo "$INSTALL_DIRECTORY"
return 0
fi
fi
done
echo ""
return 1
}
# --- Path utilities ---
# Expand tilde in user input paths (no eval — safe from code injection)
expand_path() {
local path="$1"
# Only expand leading ~ to $HOME, nothing else
echo "${path/#\~/$HOME}"
}
# Validate that a path is safe to remove (used by safe_rm_rf)
_is_safe_rm_path() {
local dir="$1"
# Refuse empty paths
if [ -z "$dir" ]; then
log_error "Refusing to remove empty path"
return 1
fi
# Safety guard: never remove root, system paths, or shallow paths
case "$dir" in
""|"/"|"/home"|"/usr"|"/etc"|"/var"|"/opt"|"/root"|"/tmp"|"${HOME:-}")
log_error "Refusing to remove system path: $dir"
return 1
;;
esac
# Refuse to remove a user's home directory directly
if [[ "$dir" =~ ^/home/[^/]+$ ]]; then
log_error "Refusing to remove home directory: $dir"
return 1
fi
# Refuse to remove paths shallower than /home/<user>/<app> (3+ slashes required)
# Examples: /home/soulis (2 levels) = blocked, /home/soulis/assemblrr (3 levels) = allowed
local depth
depth=$(echo "$dir" | tr -cd '/' | wc -c)
if [ "$depth" -lt 2 ]; then
log_error "Refusing to remove shallow path (depth < 2): $dir"
return 1
fi
return 0
}
# Remove a directory with sudo fallback (Docker containers create root-owned files)
safe_rm_rf() {
local dir="$1"
# Normalize trailing slashes
while [ "${dir%/}" != "$dir" ]; do
dir="${dir%/}"
done
if ! _is_safe_rm_path "$dir"; then
return 1
fi
if ! rm -rf "$dir" 2>/dev/null; then
local parent_dir; parent_dir="$(dirname "$dir")"
local base_name; base_name="$(basename "$dir")"
# Run rm inside docker by mounting the parent directory
if ! docker run --rm -v "$parent_dir:/target" alpine rm -rf "/target/$base_name" 2>/dev/null; then
log_warning "Failed to remove $dir. You may need to remove it manually."
fi
fi
}
# --- Directory helpers ---
create_and_verify_directory() {
local dir="$1"
local dir_type="$2"
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
log_error "Failed to create $dir_type directory at \"$dir\". Check permissions"
fi
fi
if [ ! -w "$dir" ] || [ ! -r "$dir" ]; then
log_error "Directory \"$dir\" is not writable or readable. Check permissions"
fi
}
# Set ownership on a path (recursive). Tries without sudo first.
# Usage: ensure_owned "$path" "$uid" "$gid"
ensure_owned() {
local path="$1"
local uid="$2"
local gid="$3"
[ -e "$path" ] || return 0
local actual_uid actual_gid
actual_uid=$(stat -c '%u' "$path" 2>/dev/null || echo "")
actual_gid=$(stat -c '%g' "$path" 2>/dev/null || echo "")
if [ "$actual_uid" = "$uid" ] && [ "$actual_gid" = "$gid" ]; then
# Directory may still be mode-inaccessible to us (e.g. root sticky leftovers)
if [ -d "$path" ] && [ "$(id -u)" = "$uid" ] && [ ! -w "$path" ]; then
:
else
return 0
fi
fi
if chown -R "$uid:$gid" "$path" 2>/dev/null; then
return 0
fi
if command -v sudo >/dev/null 2>&1 && sudo chown -R "$uid:$gid" "$path" 2>/dev/null; then
return 0
fi
log_error "Cannot set ownership of $path to ${uid}:${gid} (Docker may have created it as root). Fix with: sudo chown -R ${uid}:${gid} \"$path\""
}
# Create every host bind-mount target under the install tree *before* any
# container starts. Docker creates missing mount parents as root; that makes
# later setup mkdir fail. Call this immediately before compose up / VPN test.
#
# Usage: prepare_install_dirs "$install_dir" "$puid" "$pgid"
prepare_install_dirs() {
local install_dir="$1"
local uid="${2:-$(id -u)}"
local gid="${3:-$(id -g)}"
if [ -z "$install_dir" ]; then
log_error "prepare_install_dirs: install_dir is required"
fi
# Paths mounted from compose/base.yaml, vpn.yaml, and optional custom services
local -a rel_paths=(
"config"
"config/gluetun"
"config/jellyfin"
"config/emby"
"config/plex"
"config/qbittorrent"
"config/sonarr"
"config/radarr"
"config/prowlarr"
"config/portainer"
"config/seerr"
"config/recyclarr"
"config/lidarr"
"config/sabnzbd"
"config/bazarr"
"secrets"
"scripts"
)
# Ensure install root is owned by the target user
if [ -d "$install_dir" ] && [ ! -w "$install_dir" ]; then
ensure_owned "$install_dir" "$(id -u)" "$(id -g)"
fi
if [ ! -d "$install_dir" ]; then
mkdir -p "$install_dir" || log_error "Failed to create install directory: $install_dir"
fi
# If config/ already exists as root (failed prior VPN test), reclaim before mkdir
if [ -d "$install_dir/config" ] && [ ! -w "$install_dir/config" ]; then
ensure_owned "$install_dir/config" "$(id -u)" "$(id -g)"
fi
local rel
for rel in "${rel_paths[@]}"; do
local path="$install_dir/$rel"
if [ ! -d "$path" ]; then
if ! mkdir -p "$path" 2>/dev/null; then
ensure_owned "$(dirname "$path")" "$(id -u)" "$(id -g)"
mkdir -p "$path" || log_error "Failed to create $path"
fi
fi
done
# Align tree to the service host user (PUID/PGID)
ensure_owned "$install_dir/config" "$uid" "$gid"
ensure_owned "$install_dir/secrets" "$uid" "$gid"
ensure_owned "$install_dir/scripts" "$uid" "$gid"
# Seerr runs as node (UID 1000), not necessarily PUID
if [ "$uid" != "1000" ] || [ "$gid" != "1000" ]; then
ensure_owned "$install_dir/config/seerr" "1000" "1000"
fi
# Setup process must keep writing under config/ (qBittorrent conf, etc.)
if [ "$(id -u)" = "$uid" ] && [ ! -w "$install_dir/config" ]; then
log_error "Install config directory is not writable: $install_dir/config"
fi
}
setup_directory_structure() {
local media_dir="$1"
create_and_verify_directory "$media_dir" "media"
for subdir in "${MEDIA_SUBDIRS[@]}"; do
create_and_verify_directory "$media_dir/$subdir" "media subdirectory"
done
}
verify_user_permissions() {
local username="$1"
local directory="$2"
if ! id -u "$username" &>/dev/null; then
log_error "User \"$username\" doesn't exist!"
fi
# Check write access directly for the current user (no sudo needed)
if [ "$username" = "$(id -un)" ]; then
if [ ! -w "$directory" ]; then
log_error "User \"$username\" doesn't have write permissions to \"$directory\""
fi
elif ! sudo -u "$username" test -w "$directory"; then
log_error "User \"$username\" doesn't have write permissions to \"$directory\""
fi
}
verify_docker() {
local docker_exe="/mnt/c/Program Files/Docker/Docker/Docker Desktop.exe"
local is_wsl=false
if grep -qi microsoft /proc/version 2>/dev/null; then
is_wsl=true
fi
# Check if docker command is missing
if ! command -v docker &>/dev/null; then
# If in WSL2 and Docker Desktop is installed on host, we can start it to mount it
if [ "$is_wsl" = true ] && [ -f "$docker_exe" ] && command -v cmd.exe &>/dev/null; then
log_info "Docker Desktop integration is offline. Attempting to start Docker Desktop on Windows..."
cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe" < /dev/null > /dev/null 2>&1 &
log_info "Waiting for Docker Desktop integration to become ready (up to 45s)..."
for i in {1..45}; do
dot_inline
sleep 1
if command -v docker &>/dev/null && docker info &>/dev/null; then
echo ""
log_success "Docker Desktop started successfully and integration is ready."
return 0
fi
done
echo ""
fi
log_warning "Docker is not installed or not in PATH."
return 1
fi
# If docker command exists, check if daemon is running
if ! docker info &>/dev/null; then
if [ "$is_wsl" = true ] && [ -f "$docker_exe" ] && command -v cmd.exe &>/dev/null; then
log_info "Docker daemon is not running. Attempting to start Docker Desktop on Windows..."
cmd.exe /c start "" "C:\\Program Files\\Docker\\Docker\\Docker Desktop.exe" < /dev/null > /dev/null 2>&1 &
log_info "Waiting for Docker daemon to become ready (up to 45s)..."
for i in {1..45}; do
dot_inline
sleep 1
if docker info &>/dev/null; then
echo ""
log_success "Docker Desktop started successfully and is ready."
return 0
fi
done
echo ""
fi
return 1
fi
return 0
}
# Helper to generate PBKDF2 password hash for qBittorrent WebUI
# Format: @ByteArray(salt:hash) where both salt and hash are Base64 encoded
qbit_generate_pbkdf2() {
local password="$1"
if [ -z "$password" ]; then
echo ""
return
fi
if command -v python3 &>/dev/null; then
QBIT_PWD="$password" python3 -c "import hashlib, os, base64; salt = os.urandom(16); dk = hashlib.pbkdf2_hmac('sha512', os.environ.get('QBIT_PWD', '').encode('utf-8'), salt, 100000); print(f'@ByteArray({base64.b64encode(salt).decode()}:{base64.b64encode(dk).decode()})')" 2>/dev/null || echo ""
elif command -v python &>/dev/null; then
QBIT_PWD="$password" python -c "import hashlib, os, base64; salt = os.urandom(16); dk = hashlib.pbkdf2_hmac('sha512', os.environ.get('QBIT_PWD', '').encode('utf-8'), salt, 100000); print(f'@ByteArray({base64.b64encode(salt).decode()}:{base64.b64encode(dk).decode()})')" 2>/dev/null || echo ""
else
echo ""
fi
}
run_docker() {
# Run the docker command directly
# Disable exit on error temporarily so we can catch the status
set +e
docker "$@"
local exit_code=$?
set -e
if [ $exit_code -ne 0 ]; then
# If it failed, check if the Docker daemon is actually running or missing
if ! command -v docker &>/dev/null || ! docker info &>/dev/null; then
log_warning "Docker daemon is not running or integration is offline. Attempting auto-start..."
if verify_docker; then
# Retry the command once
docker "$@"
return $?
else
log_error "Docker daemon is not running. Please start Docker and try again."
fi
fi
fi
return $exit_code
}
# --- Masked input ---
# Read masked input (shows asterisks for each character, works in WSL2)
read_masked() {
local prompt="$1"
local var_name="$2"
local value=""
local charcount=0
local old_settings
clear_masked_input() {
while [ "$charcount" -gt 0 ]; do
printf '\b \b' >&2
charcount=$((charcount - 1))
done
value=""
}
printf "%s" "$prompt"
# Save and disable terminal echo
old_settings=$(stty -g)
stty -echo
while IFS= read -r -n 1 char; do
# Enter key
if [[ $char == $'\n' ]] || [[ -z "$char" ]]; then
break
fi
# Backspace / Delete
if [[ $char == $'\177' ]] || [[ $char == $'\b' ]]; then
if [ $charcount -gt 0 ]; then
charcount=$((charcount - 1))
printf '\b \b' >&2
value="${value%?}"
fi
# Ctrl+U and Ctrl+W clear current masked input
elif [[ $char == $'\025' ]] || [[ $char == $'\027' ]]; then
clear_masked_input
# Escape sequences (Ctrl+Backspace, Ctrl+Delete, etc.)
elif [[ $char == $'\e' ]]; then
local seq=""
local next=""
while IFS= read -r -s -n 1 -t 0.01 next; do
seq+="$next"
[[ $next == "~" ]] && break
[ ${#seq} -ge 8 ] && break
done
case "$seq" in
"[3;5~"|"[127;5u"|"[8;5~")
clear_masked_input
;;
esac
else
charcount=$((charcount + 1))
printf '*' >&2
value+="$char"
fi
done
# Restore terminal settings
stty "$old_settings"
echo
printf -v "$var_name" '%s' "$value"
}