tools: add fbq-add-source.sh for FileBrowser Quantum source management
Interactive script that keeps docker-compose.yml and config.yaml in sync when adding or removing file sources in a FileBrowser Quantum installation. - Prompts for host path, source name, defaultEnabled, readOnly - Validates against duplicate volume mounts and source entries - Backs up both files before editing - Uses yq v4 for safe YAML editing (no fragile sed/awk on YAML) - Offers container restart after changes - Works standalone; no post-install framework dependency https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env bash
|
||||
# fbq-add-source.sh — Add file sources to a FileBrowser Quantum installation.
|
||||
#
|
||||
# Edits docker-compose.yml and config.yaml together so they stay in sync:
|
||||
# - Adds a volume mount to the filebrowser service
|
||||
# - Adds a matching source entry under server.sources
|
||||
# - Optionally restarts the container to apply changes
|
||||
#
|
||||
# Usage:
|
||||
# bash fbq-add-source.sh [--dir /path/to/compose]
|
||||
#
|
||||
# Requirements:
|
||||
# - yq v4+ (https://github.com/mikefarah/yq)
|
||||
# Install: sudo wget -qO /usr/local/bin/yq \
|
||||
# https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 \
|
||||
# && sudo chmod +x /usr/local/bin/yq
|
||||
# - docker compose v2
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Colour helpers ────────────────────────────────────────────────────────────
|
||||
_blue() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
|
||||
_green() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; }
|
||||
_yellow() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
|
||||
_red() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; }
|
||||
|
||||
# ── Dependency checks ─────────────────────────────────────────────────────────
|
||||
check_deps() {
|
||||
local missing=0
|
||||
|
||||
if ! command -v yq &>/dev/null; then
|
||||
_red "yq is required but not installed."
|
||||
echo ""
|
||||
echo " Install with:"
|
||||
echo " sudo wget -qO /usr/local/bin/yq \\"
|
||||
echo " https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64"
|
||||
echo " sudo chmod +x /usr/local/bin/yq"
|
||||
echo ""
|
||||
missing=1
|
||||
else
|
||||
local yq_ver
|
||||
yq_ver=$(yq --version 2>&1 | grep -oP '\d+\.\d+' | head -1)
|
||||
local major="${yq_ver%%.*}"
|
||||
if [[ "$major" -lt 4 ]]; then
|
||||
_red "yq v4+ is required (found v${yq_ver}). The mikefarah/yq version is needed."
|
||||
missing=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! docker compose version &>/dev/null; then
|
||||
_red "Docker Compose plugin (v2) is required."
|
||||
echo " Install: sudo apt-get install -y docker-compose-plugin"
|
||||
missing=1
|
||||
fi
|
||||
|
||||
[[ "$missing" -eq 0 ]] || exit 1
|
||||
}
|
||||
|
||||
# ── Locate install directory ──────────────────────────────────────────────────
|
||||
find_install_dir() {
|
||||
local dir="${1:-}"
|
||||
|
||||
if [[ -n "$dir" ]]; then
|
||||
echo "$dir"
|
||||
return
|
||||
fi
|
||||
|
||||
# Common locations
|
||||
local candidates=(
|
||||
"$HOME/docker/filebrowser-quantum"
|
||||
"$HOME/docker/filebrowser"
|
||||
"/opt/filebrowser-quantum"
|
||||
"/opt/filebrowser"
|
||||
)
|
||||
|
||||
for c in "${candidates[@]}"; do
|
||||
if [[ -f "$c/docker-compose.yml" && -f "$c/data/config.yaml" ]]; then
|
||||
echo "$c"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
# Not found — ask
|
||||
echo ""
|
||||
read -r -p " Path to FileBrowser Quantum directory (contains docker-compose.yml): " dir
|
||||
dir="${dir%/}"
|
||||
if [[ ! -f "$dir/docker-compose.yml" ]]; then
|
||||
_red "docker-compose.yml not found in: $dir"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$dir/data/config.yaml" ]]; then
|
||||
_red "data/config.yaml not found in: $dir"
|
||||
exit 1
|
||||
fi
|
||||
echo "$dir"
|
||||
}
|
||||
|
||||
# ── Backup a file ─────────────────────────────────────────────────────────────
|
||||
backup() {
|
||||
local file="$1"
|
||||
local bk="${file}.backup.$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$file" "$bk"
|
||||
_blue "Backed up $(basename "$file") → $(basename "$bk")"
|
||||
}
|
||||
|
||||
# ── Check if a volume mount already exists in compose ────────────────────────
|
||||
volume_exists() {
|
||||
local compose="$1" container_path="$2"
|
||||
yq e '.services.filebrowser.volumes[]' "$compose" 2>/dev/null \
|
||||
| grep -qF ":${container_path}" || return 1
|
||||
}
|
||||
|
||||
# ── Check if a source already exists in config ───────────────────────────────
|
||||
source_exists() {
|
||||
local config="$1" container_path="$2"
|
||||
yq e '.server.sources[].path' "$config" 2>/dev/null \
|
||||
| grep -qxF "$container_path" || return 1
|
||||
}
|
||||
|
||||
# ── Add a single source ───────────────────────────────────────────────────────
|
||||
add_source() {
|
||||
local compose="$1" config="$2"
|
||||
|
||||
echo ""
|
||||
echo "────────────────────────────────────────────────"
|
||||
|
||||
# Host path
|
||||
local host_path=""
|
||||
while true; do
|
||||
read -r -p " Host directory path (e.g. /mnt/data/music): " host_path
|
||||
host_path="${host_path%/}"
|
||||
if [[ -z "$host_path" ]]; then
|
||||
_yellow "No path entered — skipping."
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -d "$host_path" ]]; then
|
||||
_yellow "'$host_path' does not exist on this host."
|
||||
local yn=""
|
||||
read -r -p " Add it anyway? [y/N]: " yn
|
||||
[[ "${yn,,}" == "y" ]] && break
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Source name (used as both the container mount point and the Quantum source name)
|
||||
local default_name
|
||||
default_name=$(basename "$host_path" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
|
||||
local source_name=""
|
||||
read -r -p " Source name [${default_name}]: " source_name
|
||||
source_name="${source_name:-$default_name}"
|
||||
# Sanitise: lowercase, alphanumeric + hyphen only
|
||||
source_name=$(echo "$source_name" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-' | sed 's/-*$//')
|
||||
|
||||
local container_path="/${source_name}"
|
||||
|
||||
# Collision checks
|
||||
if volume_exists "$compose" "$container_path"; then
|
||||
_yellow "Volume mount ':${container_path}' already exists in docker-compose.yml — skipping."
|
||||
return 0
|
||||
fi
|
||||
if source_exists "$config" "$container_path"; then
|
||||
_yellow "Source path '${container_path}' already exists in config.yaml — skipping."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Access defaults
|
||||
local default_enabled=""
|
||||
read -r -p " Enable for all users by default? [y/N]: " default_enabled
|
||||
local default_enabled_bool="false"
|
||||
[[ "${default_enabled,,}" == "y" ]] && default_enabled_bool="true"
|
||||
|
||||
local read_only=""
|
||||
read -r -p " Read-only? [y/N]: " read_only
|
||||
local read_only_bool="false"
|
||||
[[ "${read_only,,}" == "y" ]] && read_only_bool="true"
|
||||
|
||||
# ── Write changes ──────────────────────────────────────────────────────────
|
||||
|
||||
# 1. docker-compose.yml — add volume mount
|
||||
backup "$compose"
|
||||
yq e -i ".services.filebrowser.volumes += [\"${host_path}:${container_path}\"]" "$compose"
|
||||
_green "Added volume: ${host_path}:${container_path}"
|
||||
|
||||
# 2. config.yaml — add source entry
|
||||
backup "$config"
|
||||
yq e -i ".server.sources += [{
|
||||
\"path\": \"${container_path}\",
|
||||
\"name\": \"${source_name}\",
|
||||
\"config\": {
|
||||
\"defaultEnabled\": ${default_enabled_bool},
|
||||
\"readOnly\": ${read_only_bool}
|
||||
}
|
||||
}]" "$config"
|
||||
_green "Added source: ${source_name} → ${container_path}"
|
||||
}
|
||||
|
||||
# ── Remove a source ───────────────────────────────────────────────────────────
|
||||
remove_source() {
|
||||
local compose="$1" config="$2"
|
||||
|
||||
echo ""
|
||||
echo "Current sources:"
|
||||
echo ""
|
||||
|
||||
# List sources from config
|
||||
local paths=()
|
||||
while IFS= read -r p; do
|
||||
paths+=("$p")
|
||||
done < <(yq e '.server.sources[].path' "$config" 2>/dev/null)
|
||||
|
||||
if [[ ${#paths[@]} -eq 0 ]]; then
|
||||
_yellow "No sources found in config.yaml."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local i=1
|
||||
for p in "${paths[@]}"; do
|
||||
printf " %2d) %s\n" "$i" "$p"
|
||||
(( i++ ))
|
||||
done
|
||||
echo ""
|
||||
|
||||
local choice=""
|
||||
read -r -p " Remove source number [cancel]: " choice
|
||||
if [[ -z "$choice" || ! "$choice" =~ ^[0-9]+$ ]]; then
|
||||
_blue "Cancelled."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local idx=$(( choice - 1 ))
|
||||
if [[ "$idx" -lt 0 || "$idx" -ge ${#paths[@]} ]]; then
|
||||
_yellow "Invalid selection."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local target_path="${paths[$idx]}"
|
||||
|
||||
# Confirm
|
||||
echo ""
|
||||
_yellow "Will remove source '$target_path' from config.yaml and its volume mount from docker-compose.yml."
|
||||
local confirm=""
|
||||
read -r -p " Confirm? [y/N]: " confirm
|
||||
[[ "${confirm,,}" == "y" ]] || { _blue "Cancelled."; return 0; }
|
||||
|
||||
# Remove from config.yaml
|
||||
backup "$config"
|
||||
yq e -i "del(.server.sources[] | select(.path == \"${target_path}\"))" "$config"
|
||||
_green "Removed source from config.yaml"
|
||||
|
||||
# Remove from docker-compose.yml (volume entry ending with :target_path)
|
||||
if volume_exists "$compose" "$target_path"; then
|
||||
backup "$compose"
|
||||
yq e -i "del(.services.filebrowser.volumes[] | select(. == \"*:${target_path}\"))" "$compose"
|
||||
# yq glob select doesn't do substring — use a precise match
|
||||
yq e -i "del(.services.filebrowser.volumes[] | select(test(\":${target_path}$\")))" "$compose"
|
||||
_green "Removed volume mount from docker-compose.yml"
|
||||
else
|
||||
_yellow "No matching volume mount found in docker-compose.yml (may have been added manually)."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Show current sources ──────────────────────────────────────────────────────
|
||||
show_sources() {
|
||||
local compose="$1" config="$2"
|
||||
|
||||
echo ""
|
||||
echo " Sources in config.yaml:"
|
||||
echo ""
|
||||
yq e '.server.sources[] | " " + .name + " → " + .path +
|
||||
" (defaultEnabled: " + (.config.defaultEnabled | tostring) + ")"' \
|
||||
"$config" 2>/dev/null || _yellow " None found."
|
||||
|
||||
echo ""
|
||||
echo " Volume mounts in docker-compose.yml:"
|
||||
echo ""
|
||||
yq e '.services.filebrowser.volumes[]' "$compose" 2>/dev/null \
|
||||
| grep -v '/home/filebrowser/data' \
|
||||
| sed 's/^/ /' \
|
||||
|| _yellow " None found."
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── Restart container ─────────────────────────────────────────────────────────
|
||||
restart_container() {
|
||||
local dir="$1"
|
||||
echo ""
|
||||
local yn=""
|
||||
read -r -p " Restart FileBrowser Quantum now to apply changes? [Y/n]: " yn
|
||||
if [[ "${yn,,}" != "n" ]]; then
|
||||
_blue "Restarting..."
|
||||
docker compose -f "$dir/docker-compose.yml" down
|
||||
docker compose -f "$dir/docker-compose.yml" up -d
|
||||
_green "FileBrowser Quantum restarted."
|
||||
echo ""
|
||||
_blue "Assign the new source(s) to users: Settings → Users → edit user → Add source"
|
||||
else
|
||||
_yellow "Remember to restart manually: docker compose down && docker compose up -d"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
local explicit_dir=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dir|-d) explicit_dir="$2"; shift 2 ;;
|
||||
*) _red "Unknown argument: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
check_deps
|
||||
|
||||
echo ""
|
||||
echo "┌──────────────────────────────────────────────────────┐"
|
||||
echo "│ FileBrowser Quantum — Source Manager │"
|
||||
echo "│ Edits docker-compose.yml + config.yaml in sync │"
|
||||
echo "└──────────────────────────────────────────────────────┘"
|
||||
|
||||
local install_dir
|
||||
install_dir=$(find_install_dir "$explicit_dir")
|
||||
local compose="$install_dir/docker-compose.yml"
|
||||
local config="$install_dir/data/config.yaml"
|
||||
|
||||
_blue "Install dir: $install_dir"
|
||||
|
||||
local changed=0
|
||||
|
||||
while true; do
|
||||
echo ""
|
||||
echo " What would you like to do?"
|
||||
echo " 1) Add a source"
|
||||
echo " 2) Remove a source"
|
||||
echo " 3) Show current sources"
|
||||
echo " 4) Quit"
|
||||
echo ""
|
||||
read -r -p " Choice [1]: " action
|
||||
action="${action:-1}"
|
||||
|
||||
case "$action" in
|
||||
1)
|
||||
add_source "$compose" "$config"
|
||||
changed=1
|
||||
local another=""
|
||||
read -r -p " Add another source? [y/N]: " another
|
||||
[[ "${another,,}" == "y" ]] || break
|
||||
;;
|
||||
2)
|
||||
remove_source "$compose" "$config"
|
||||
changed=1
|
||||
;;
|
||||
3)
|
||||
show_sources "$compose" "$config"
|
||||
;;
|
||||
4|q|Q)
|
||||
break
|
||||
;;
|
||||
*)
|
||||
_yellow "Invalid choice."
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$changed" -eq 1 ]]; then
|
||||
restart_container "$install_dir"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
_green "Done."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user