From 7379f0b62f689aec7094bdd3d97a625bb2cb9d8b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 04:59:11 +0000 Subject: [PATCH] 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 --- tools/fbq-add-source.sh | 372 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 372 insertions(+) create mode 100644 tools/fbq-add-source.sh diff --git a/tools/fbq-add-source.sh b/tools/fbq-add-source.sh new file mode 100644 index 0000000..789981e --- /dev/null +++ b/tools/fbq-add-source.sh @@ -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 "$@"