tools: simplify fbq-add-source.sh — config.yaml only, no compose edits

Assumes the parent data directory is already broadly mounted in
docker-compose.yml so subdirectories are already inside the container.
Script now only edits config.yaml and restarts the container — no
docker-compose surgery needed per new source.

https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt
This commit is contained in:
Claude
2026-06-08 05:20:41 +00:00
parent 7379f0b62f
commit 99e8671110
+159 -206
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# fbq-add-source.sh — Add file sources to a FileBrowser Quantum installation. # fbq-add-source.sh — Manage file sources for FileBrowser Quantum.
# #
# Edits docker-compose.yml and config.yaml together so they stay in sync: # Assumes the parent data directory is already mounted in docker-compose.yml.
# - Adds a volume mount to the filebrowser service # Subdirectories of that mount are already accessible inside the container,
# - Adds a matching source entry under server.sources # so only config.yaml needs editing — no compose changes required.
# - Optionally restarts the container to apply changes
# #
# Usage: # Usage:
# bash fbq-add-source.sh [--dir /path/to/compose] # bash fbq-add-source.sh [--dir /path/to/compose]
@@ -19,17 +18,17 @@
set -euo pipefail set -euo pipefail
# ── Colour helpers ──────────────────────────────────────────────────────────── # ── Colour helpers ────────────────────────────────────────────────────────────
_blue() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; } info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; }
_green() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; } ok() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; }
_yellow() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
_red() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; } err() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; }
# ── Dependency checks ───────────────────────────────────────────────────────── # ── Dependency checks ─────────────────────────────────────────────────────────
check_deps() { check_deps() {
local missing=0 local missing=0
if ! command -v yq &>/dev/null; then if ! command -v yq &>/dev/null; then
_red "yq is required but not installed." err "yq is required but not installed."
echo "" echo ""
echo " Install with:" echo " Install with:"
echo " sudo wget -qO /usr/local/bin/yq \\" echo " sudo wget -qO /usr/local/bin/yq \\"
@@ -38,17 +37,16 @@ check_deps() {
echo "" echo ""
missing=1 missing=1
else else
local yq_ver local major
yq_ver=$(yq --version 2>&1 | grep -oP '\d+\.\d+' | head -1) major=$(yq --version 2>&1 | grep -oP '(?<=v)\d+' | head -1 || echo 0)
local major="${yq_ver%%.*}"
if [[ "$major" -lt 4 ]]; then if [[ "$major" -lt 4 ]]; then
_red "yq v4+ is required (found v${yq_ver}). The mikefarah/yq version is needed." err "yq v4+ required (found v${major}). Get it from github.com/mikefarah/yq"
missing=1 missing=1
fi fi
fi fi
if ! docker compose version &>/dev/null; then if ! docker compose version &>/dev/null; then
_red "Docker Compose plugin (v2) is required." err "Docker Compose plugin (v2) is required."
echo " Install: sudo apt-get install -y docker-compose-plugin" echo " Install: sudo apt-get install -y docker-compose-plugin"
missing=1 missing=1
fi fi
@@ -61,128 +59,125 @@ find_install_dir() {
local dir="${1:-}" local dir="${1:-}"
if [[ -n "$dir" ]]; then if [[ -n "$dir" ]]; then
echo "$dir" [[ -f "$dir/docker-compose.yml" ]] || { err "docker-compose.yml not found in: $dir"; exit 1; }
return [[ -f "$dir/data/config.yaml" ]] || { err "data/config.yaml not found in: $dir"; exit 1; }
echo "$dir"; return
fi fi
# Common locations
local candidates=( local candidates=(
"$HOME/docker/filebrowser-quantum" "$HOME/docker/filebrowser-quantum"
"$HOME/docker/filebrowser" "$HOME/docker/filebrowser"
"/opt/filebrowser-quantum" "/opt/filebrowser-quantum"
"/opt/filebrowser" "/opt/filebrowser"
) )
for c in "${candidates[@]}"; do for c in "${candidates[@]}"; do
if [[ -f "$c/docker-compose.yml" && -f "$c/data/config.yaml" ]]; then if [[ -f "$c/docker-compose.yml" && -f "$c/data/config.yaml" ]]; then
echo "$c" echo "$c"; return
return
fi fi
done done
# Not found — ask
echo "" echo ""
read -r -p " Path to FileBrowser Quantum directory (contains docker-compose.yml): " dir read -r -p " Path to FileBrowser Quantum directory: " dir
dir="${dir%/}" dir="${dir%/}"
if [[ ! -f "$dir/docker-compose.yml" ]]; then [[ -f "$dir/docker-compose.yml" ]] || { err "docker-compose.yml not found in: $dir"; exit 1; }
_red "docker-compose.yml not found in: $dir" [[ -f "$dir/data/config.yaml" ]] || { err "data/config.yaml not found in: $dir"; exit 1; }
exit 1
fi
if [[ ! -f "$dir/data/config.yaml" ]]; then
_red "data/config.yaml not found in: $dir"
exit 1
fi
echo "$dir" echo "$dir"
} }
# ── Get the container-side path for the main data mount ──────────────────────
# Reads docker-compose.yml and returns the container path of the first
# non-data-dir volume mount (i.e. the user files mount).
get_data_mount() {
local compose="$1"
yq e '.services.filebrowser.volumes[]' "$compose" 2>/dev/null \
| grep -v '/home/filebrowser/data' \
| head -1 \
| cut -d: -f2 \
| sed 's|/$||'
}
# ── Backup a file ───────────────────────────────────────────────────────────── # ── Backup a file ─────────────────────────────────────────────────────────────
backup() { backup() {
local file="$1" local file="$1"
local bk="${file}.backup.$(date +%Y%m%d-%H%M%S)" local bk="${file}.backup.$(date +%Y%m%d-%H%M%S)"
cp "$file" "$bk" cp "$file" "$bk"
_blue "Backed up $(basename "$file")$(basename "$bk")" info "Backed up $(basename "$file")$(basename "$bk")"
} }
# ── Check if a volume mount already exists in compose ──────────────────────── # ── Check if a source path already exists in config ──────────────────────────
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() { source_exists() {
local config="$1" container_path="$2" local config="$1" path="$2"
yq e '.server.sources[].path' "$config" 2>/dev/null \ yq e '.server.sources[].path' "$config" 2>/dev/null | grep -qxF "$path"
| grep -qxF "$container_path" || return 1
} }
# ── Add a single source ─────────────────────────────────────────────────────── # ── List existing sources ─────────────────────────────────────────────────────
add_source() { list_sources() {
local compose="$1" config="$2" local config="$1"
yq e '.server.sources[] | .path' "$config" 2>/dev/null
}
# ── Restart the container ─────────────────────────────────────────────────────
restart_container() {
local dir="$1" prompt="${2:-true}"
if [[ "$prompt" == "true" ]]; then
echo ""
local yn=""
read -r -p " Restart FileBrowser Quantum now to apply changes? [Y/n]: " yn
[[ "${yn,,}" == "n" ]] && {
warn "Remember to restart: cd $dir && docker compose restart"
return 0
}
fi
info "Restarting FileBrowser Quantum..."
docker compose -f "$dir/docker-compose.yml" restart \
&& ok "FileBrowser Quantum restarted." \
|| warn "Restart failed — try: cd $dir && docker compose restart"
}
# ── ADD ───────────────────────────────────────────────────────────────────────
cmd_add() {
local dir="$1" config="$2" data_mount="$3"
echo "" echo ""
echo "────────────────────────────────────────────────" echo " The data directory is mounted at: ${data_mount:-unknown}"
echo " Enter subdirectory names (relative to that mount) to add as sources."
echo " Example: if your music lives at ${data_mount}/music, enter 'music'"
echo ""
local added=0
# Host path
local host_path=""
while true; do while true; do
read -r -p " Host directory path (e.g. /mnt/data/music): " host_path local subdir=""
host_path="${host_path%/}" read -r -p " Subdirectory to add (or Enter to finish): " subdir
if [[ -z "$host_path" ]]; then [[ -z "$subdir" ]] && break
_yellow "No path entered — skipping." subdir="${subdir#/}" # strip any leading slash
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 container_path="${data_mount}/${subdir}"
# Default name = subdir basename, lowercase, hyphenated
local default_name local default_name
default_name=$(basename "$host_path" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') default_name=$(basename "$subdir" | tr '[:upper:]' '[:lower:]' | tr ' _' '-')
local source_name="" local source_name=""
read -r -p " Source name [${default_name}]: " source_name read -r -p " Display name for this source [${default_name}]: " source_name
source_name="${source_name:-$default_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/-*$//') 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 if source_exists "$config" "$container_path"; then
_yellow "Source path '${container_path}' already exists in config.yaml — skipping." warn "Source '$container_path' already in config.yaml — skipping."
return 0 continue
fi fi
# Access defaults
local default_enabled=""
read -r -p " Enable for all users by default? [y/N]: " default_enabled
local default_enabled_bool="false" local default_enabled_bool="false"
[[ "${default_enabled,,}" == "y" ]] && default_enabled_bool="true" local yn=""
read -r -p " Enable for all users by default? [y/N]: " yn
[[ "${yn,,}" == "y" ]] && default_enabled_bool="true"
local read_only=""
read -r -p " Read-only? [y/N]: " read_only
local read_only_bool="false" local read_only_bool="false"
[[ "${read_only,,}" == "y" ]] && read_only_bool="true" read -r -p " Read-only? [y/N]: " read_only_bool_raw
[[ "${read_only_bool_raw,,}" == "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" backup "$config"
yq e -i ".server.sources += [{ yq e -i ".server.sources += [{
\"path\": \"${container_path}\", \"path\": \"${container_path}\",
@@ -192,181 +187,139 @@ add_source() {
\"readOnly\": ${read_only_bool} \"readOnly\": ${read_only_bool}
} }
}]" "$config" }]" "$config"
_green "Added source: ${source_name}${container_path}"
ok "Added source '${source_name}' → ${container_path}"
(( added++ ))
done
if [[ "$added" -gt 0 ]]; then
restart_container "$dir"
echo ""
ok "$added source(s) added."
info "Assign them to users: Settings → Users → edit user → Add source"
else
info "Nothing added."
fi
} }
# ── Remove a source ─────────────────────────────────────────────────────────── # ── REMOVE ────────────────────────────────────────────────────────────────────
remove_source() { cmd_remove() {
local compose="$1" config="$2" local dir="$1" config="$2"
local paths=()
while IFS= read -r p; do paths+=("$p"); done < <(list_sources "$config")
if [[ ${#paths[@]} -eq 0 ]]; then
warn "No sources found in config.yaml."
return 0
fi
echo "" echo ""
echo " Current sources:" echo " Current sources:"
echo "" 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 local i=1
for p in "${paths[@]}"; do for p in "${paths[@]}"; do
printf " %2d) %s\n" "$i" "$p" local name
name=$(yq e ".server.sources[] | select(.path == \"$p\") | .name" "$config" 2>/dev/null || echo "")
printf " %2d) %-20s %s\n" "$i" "${name:-?}" "$p"
(( i++ )) (( i++ ))
done done
echo "" echo ""
local choice="" local choice=""
read -r -p " Remove source number [cancel]: " choice read -r -p " Remove source number [cancel]: " choice
if [[ -z "$choice" || ! "$choice" =~ ^[0-9]+$ ]]; then [[ -z "$choice" || ! "$choice" =~ ^[0-9]+$ ]] && { info "Cancelled."; return 0; }
_blue "Cancelled."
return 0
fi
local idx=$(( choice - 1 )) local idx=$(( choice - 1 ))
if [[ "$idx" -lt 0 || "$idx" -ge ${#paths[@]} ]]; then if [[ "$idx" -lt 0 || "$idx" -ge ${#paths[@]} ]]; then
_yellow "Invalid selection." warn "Invalid selection."; return 0
return 0
fi fi
local target_path="${paths[$idx]}" local target="${paths[$idx]}"
warn "Will remove source '$target' from config.yaml."
# Confirm
echo ""
_yellow "Will remove source '$target_path' from config.yaml and its volume mount from docker-compose.yml."
local confirm="" local confirm=""
read -r -p " Confirm? [y/N]: " confirm read -r -p " Confirm? [y/N]: " confirm
[[ "${confirm,,}" == "y" ]] || { _blue "Cancelled."; return 0; } [[ "${confirm,,}" == "y" ]] || { info "Cancelled."; return 0; }
# Remove from config.yaml
backup "$config" backup "$config"
yq e -i "del(.server.sources[] | select(.path == \"${target_path}\"))" "$config" yq e -i "del(.server.sources[] | select(.path == \"${target}\"))" "$config"
_green "Removed source from config.yaml" ok "Removed source '$target' from config.yaml."
restart_container "$dir"
# 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 ──────────────────────────────────────────────────────────────────────
show_sources() { cmd_show() {
local compose="$1" config="$2" local config="$1"
echo ""
echo " Sources configured in config.yaml:"
echo ""
echo "" local count=0
echo " Sources in config.yaml:" while IFS= read -r p; do
echo "" local name enabled readonly
yq e '.server.sources[] | " " + .name + " → " + .path + name=$(yq e ".server.sources[] | select(.path == \"$p\") | .name" "$config" 2>/dev/null || echo "?")
" (defaultEnabled: " + (.config.defaultEnabled | tostring) + ")"' \ enabled=$(yq e ".server.sources[] | select(.path == \"$p\") | .config.defaultEnabled" "$config" 2>/dev/null || echo "false")
"$config" 2>/dev/null || _yellow " None found." readonly=$(yq e ".server.sources[] | select(.path == \"$p\") | .config.readOnly" "$config" 2>/dev/null || echo "false")
printf " %-20s %-35s defaultEnabled=%-5s readOnly=%s\n" \
"${name}" "${p}" "${enabled}" "${readonly}"
(( count++ ))
done < <(list_sources "$config")
echo "" [[ "$count" -eq 0 ]] && warn "No sources found."
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 "" echo ""
} }
# ── Restart container ───────────────────────────────────────────────────────── # ── MAIN ──────────────────────────────────────────────────────────────────────
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() { main() {
local explicit_dir="" local explicit_dir=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--dir|-d) explicit_dir="$2"; shift 2 ;; --dir|-d) explicit_dir="$2"; shift 2 ;;
*) _red "Unknown argument: $1"; exit 1 ;; *) err "Unknown argument: $1"; exit 1 ;;
esac esac
done done
check_deps check_deps
echo "" echo ""
echo "┌──────────────────────────────────────────────────────┐" echo "┌──────────────────────────────────────────────────────────┐"
echo "│ FileBrowser Quantum — Source Manager │" echo "│ FileBrowser Quantum — Source Manager │"
echo "│ Edits docker-compose.yml + config.yaml in sync │" echo "│ Manages config.yaml and restarts the container │"
echo "└──────────────────────────────────────────────────────┘" echo "└──────────────────────────────────────────────────────────┘"
local install_dir local install_dir
install_dir=$(find_install_dir "$explicit_dir") install_dir=$(find_install_dir "$explicit_dir")
local compose="$install_dir/docker-compose.yml"
local config="$install_dir/data/config.yaml" local config="$install_dir/data/config.yaml"
local compose="$install_dir/docker-compose.yml"
local data_mount
data_mount=$(get_data_mount "$compose")
_blue "Install dir: $install_dir" info "Install dir : $install_dir"
info "Data mount : ${data_mount:-not detected — enter full container paths}"
local changed=0 echo ""
while true; do while true; do
echo ""
echo " What would you like to do?" echo " What would you like to do?"
echo " 1) Add a source" echo " 1) Add source(s)"
echo " 2) Remove a source" echo " 2) Remove a source"
echo " 3) Show current sources" echo " 3) Show current sources"
echo " 4) Quit" echo " 4) Quit"
echo "" echo ""
read -r -p " Choice [1]: " action read -r -p " Choice [1]: " action
action="${action:-1}" action="${action:-1}"
echo ""
case "$action" in case "$action" in
1) 1) cmd_add "$install_dir" "$config" "$data_mount" ;;
add_source "$compose" "$config" 2) cmd_remove "$install_dir" "$config" ;;
changed=1 3) cmd_show "$config" ;;
local another="" 4|q|Q) break ;;
read -r -p " Add another source? [y/N]: " another *) warn "Invalid choice." ;;
[[ "${another,,}" == "y" ]] || break
;;
2)
remove_source "$compose" "$config"
changed=1
;;
3)
show_sources "$compose" "$config"
;;
4|q|Q)
break
;;
*)
_yellow "Invalid choice."
;;
esac esac
echo ""
done done
if [[ "$changed" -eq 1 ]]; then ok "Done."
restart_container "$install_dir"
fi
echo ""
_green "Done."
} }
main "$@" main "$@"