#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' # Ensure we have system utilities in PATH export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:$PATH" trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR ############################################################################## # Build Configuration ############################################################################## # Detect number of CPU cores for parallel builds NCPU=$(sysctl -n hw.ncpu) # Use all cores for maximum speed MAKE_JOBS=$NCPU ############################################################################## # Progress Tracking ############################################################################## TOTAL_STEPS=19 CURRENT_STEP=0 CHECKPOINT_FILE="" # Mark a step as complete mark_step_complete() { local step_name="$1" if [ -n "$CHECKPOINT_FILE" ]; then # Ensure the directory exists before writing mkdir -p "$(dirname "$CHECKPOINT_FILE")" echo "$step_name" >> "$CHECKPOINT_FILE" fi } # Check if a step has been completed is_step_complete() { local step_name="$1" if [ -n "$CHECKPOINT_FILE" ] && [ -f "$CHECKPOINT_FILE" ]; then grep -q "^${step_name}$" "$CHECKPOINT_FILE" 2>/dev/null return $? fi return 1 } print_progress() { local step_name="$1" local log_file="${2:-}" CURRENT_STEP=$((CURRENT_STEP + 1)) local percent=$((CURRENT_STEP * 100 / TOTAL_STEPS)) local filled=$((CURRENT_STEP * 30 / TOTAL_STEPS)) local empty=$((30 - filled)) # Clear screen and print progress clear echo "╔════════════════════════════════════════════════════════════════════════════╗" echo "║ DISPATCHARR PORTABLE INSTALLATION ║" echo "╚════════════════════════════════════════════════════════════════════════════╝" echo "" printf "Progress: [%${filled}s%${empty}s] %3d%% Step %2d/%2d\n" \ "$(printf '#%.0s' $(seq 1 $filled))" \ "$(printf ' %.0s' $(seq 1 $empty))" \ "$percent" "$CURRENT_STEP" "$TOTAL_STEPS" echo "" echo "────────────────────────────────────────────────────────────────────────────" printf "Current Step: %s\n" "$step_name" if [ -n "$log_file" ]; then printf "Log File: %s\n" "$log_file" fi echo "────────────────────────────────────────────────────────────────────────────" echo "" } print_step_complete() { local message="${1:-Complete}" echo "✓ $message" echo "" sleep 1 } ############################################################################## # Portable Dispatcharr for macOS (Apple Silicon) # # This script creates a completely self-contained installation that: # - Does NOT install system packages # - Does NOT create system users # - Does NOT modify system files # - Bundles ALL dependencies in one directory # - Can be moved/deleted without leaving traces # - Works like a Docker container but native ############################################################################## show_disclaimer() { echo "**************************************************************" echo "Dispatcharr Portable Installation for macOS (Apple Silicon)" echo "" echo "This creates a COMPLETELY SELF-CONTAINED installation:" echo " ✓ All dependencies bundled in one directory" echo " ✓ Can be deleted to remove everything" echo " ✓ VideoToolbox hardware acceleration enabled" echo "" echo "Requirements:" echo " - macOS 12.0+ (Monterey or later)" echo " - Apple Silicon (M1/M2/M3/M4)" echo " - 8GB+ RAM, 15GB+ free disk space" echo " - Xcode Command Line Tools (for compilation)" echo "" echo "⚠️ This installation is UNOFFICIAL and UNSUPPORTED." echo " Docker on Linux is the recommended installation." echo "" echo "NOTE: This process may take a while as dependencies are built" echo " from source to enable VideoToolbox hardware acceleration" echo " and ensure complete portability." echo "**************************************************************" echo "" echo "Press Enter to continue..." read } ############################################################################## # Select Branch ############################################################################## select_branch() { echo "" echo "Select Dispatcharr branch to install:" echo " 1) main - Stable releases (recommended)" echo " 2) dev - Development branch (latest features, may be unstable)" echo " 3) custom - Enter a specific branch name" echo "" echo -n "Choice [1]: " read branch_choice case "${branch_choice:-1}" in 1) DISPATCH_BRANCH="main" echo "Selected: main (stable)" ;; 2) DISPATCH_BRANCH="dev" echo "Selected: dev (development)" ;; 3) echo -n "Enter branch name: " read custom_branch if [ -n "$custom_branch" ]; then DISPATCH_BRANCH="$custom_branch" echo "Selected: $custom_branch" else echo "No branch specified, using main" DISPATCH_BRANCH="main" fi ;; *) echo "Invalid choice, using main (stable)" DISPATCH_BRANCH="main" ;; esac } ############################################################################## # Detect Versions from Project Files ############################################################################## detect_versions() { local repo_dir="${1:-}" # Default versions (fallback) PYTHON_VERSION="3.12.7" POSTGRES_VERSION="17.2" REDIS_VERSION="7.4.1" NODE_VERSION="24.0.0" if [ -n "$repo_dir" ] && [ -d "$repo_dir" ]; then echo ">>> Detecting versions from project files..." # Detect Python version from DispatcharrBase Dockerfile if [ -f "$repo_dir/docker/DispatcharrBase" ]; then local py_ver=$(grep -oE "python3\.[0-9]+" "$repo_dir/docker/DispatcharrBase" | head -1 | grep -oE "[0-9]+\.[0-9]+") if [ -n "$py_ver" ]; then # Get latest patch version available echo " Detected Python ${py_ver}.x from Dockerfile" # We'll use the minor version and find latest patch PYTHON_MINOR="$py_ver" fi fi # Detect PostgreSQL version from DispatcharrBase Dockerfile if [ -f "$repo_dir/docker/DispatcharrBase" ]; then local pg_ver=$(grep -oE "postgresql-[0-9]+" "$repo_dir/docker/DispatcharrBase" | head -1 | grep -oE "[0-9]+") if [ -n "$pg_ver" ]; then echo " Detected PostgreSQL ${pg_ver}.x from Dockerfile" POSTGRES_MAJOR="$pg_ver" fi fi # Detect Node version from Dockerfile if [ -f "$repo_dir/docker/Dockerfile" ]; then local node_ver=$(grep -oE "FROM node:[0-9]+" "$repo_dir/docker/Dockerfile" | head -1 | grep -oE "[0-9]+") if [ -n "$node_ver" ]; then echo " Detected Node ${node_ver}.x from Dockerfile" NODE_MAJOR="$node_ver" fi fi # Detect Redis version from DispatcharrBase (7.x mentioned in comment) if [ -f "$repo_dir/docker/DispatcharrBase" ]; then if grep -q "Redis 7" "$repo_dir/docker/DispatcharrBase"; then echo " Detected Redis 7.x from Dockerfile" REDIS_MAJOR="7" fi fi fi # Fetch latest patch versions echo ">>> Fetching latest stable versions..." # Python - get latest patch for detected minor version if [ -n "${PYTHON_MINOR:-}" ]; then local latest_py=$(curl -s https://www.python.org/ftp/python/ | grep -oE "${PYTHON_MINOR}\.[0-9]+" | sort -V | tail -1) if [ -n "$latest_py" ]; then PYTHON_VERSION="$latest_py" echo " Python: ${PYTHON_VERSION}" fi else echo " Python: ${PYTHON_VERSION} (default)" fi # PostgreSQL - get latest patch for detected major version if [ -n "${POSTGRES_MAJOR:-}" ]; then local latest_pg=$(curl -s https://www.postgresql.org/ftp/source/ | grep -oE "v${POSTGRES_MAJOR}\.[0-9]+" | grep -oE "[0-9]+\.[0-9]+" | sort -V | tail -1) if [ -n "$latest_pg" ]; then POSTGRES_VERSION="$latest_pg" echo " PostgreSQL: ${POSTGRES_VERSION}" fi else echo " PostgreSQL: ${POSTGRES_VERSION} (default)" fi # Node - get latest for detected major version if [ -n "${NODE_MAJOR:-}" ]; then local latest_node=$(curl -s https://nodejs.org/dist/latest-v${NODE_MAJOR}.x/ | grep -oE "node-v${NODE_MAJOR}\.[0-9]+\.[0-9]+" | head -1 | grep -oE "[0-9]+\.[0-9]+\.[0-9]+") if [ -n "$latest_node" ]; then NODE_VERSION="$latest_node" echo " Node.js: ${NODE_VERSION}" fi else echo " Node.js: ${NODE_VERSION} (default)" fi # Redis - get latest for major version if [ -n "${REDIS_MAJOR:-}" ]; then local latest_redis=$(curl -s https://download.redis.io/releases/ | grep -oE "redis-${REDIS_MAJOR}\.[0-9]+\.[0-9]+" | grep -oE "${REDIS_MAJOR}\.[0-9]+\.[0-9]+" | sort -V | tail -1) if [ -n "$latest_redis" ]; then REDIS_VERSION="$latest_redis" echo " Redis: ${REDIS_VERSION}" fi else echo " Redis: ${REDIS_VERSION} (default)" fi echo "" } ############################################################################## # Configuration ############################################################################## configure_variables() { # Get installation directory from user or use default if [ -z "${INSTALL_DIR:-}" ]; then echo "" echo "Enter installation directory (default: $HOME/Dispatcharr):" read user_dir INSTALL_DIR="${user_dir:-$HOME/Dispatcharr}" fi # Expand tilde INSTALL_DIR="${INSTALL_DIR/#\~/$HOME}" # Set checkpoint file location CHECKPOINT_FILE="${INSTALL_DIR}/.install_progress" # Check if resuming from previous install if [ -f "$CHECKPOINT_FILE" ]; then echo "" echo "──────────────────────────────────────────────────────────" echo "⚠️ Found incomplete installation at: ${INSTALL_DIR}" echo "" echo "Completed steps:" cat "$CHECKPOINT_FILE" | sed 's/^/ ✓ /' echo "" echo "Options:" echo " 1) Resume installation (skip completed steps)" echo " 2) Start fresh (delete checkpoint and reinstall everything)" echo " 3) Cancel" echo "" echo -n "Choice [1]: " read resume_choice case "${resume_choice:-1}" in 1) echo "Resuming installation..." ;; 2) echo "Starting fresh..." rm -f "$CHECKPOINT_FILE" ;; 3) echo "Cancelled" exit 0 ;; *) echo "Invalid choice, resuming..." ;; esac fi # Core directories APP_DIR="${INSTALL_DIR}/app" DATA_DIR="${INSTALL_DIR}/data" DEPS_DIR="${INSTALL_DIR}/deps" RUNTIME_DIR="${INSTALL_DIR}/runtime" LOGS_DIR="${INSTALL_DIR}/logs" # Dependency paths PYTHON_PREFIX="${DEPS_DIR}/python" POSTGRES_PREFIX="${DEPS_DIR}/postgresql" REDIS_PREFIX="${DEPS_DIR}/redis" FFMPEG_PREFIX="${DEPS_DIR}/ffmpeg" NODE_PREFIX="${DEPS_DIR}/node" # Configuration POSTGRES_DB="dispatcharr" POSTGRES_USER="dispatch" POSTGRES_PASSWORD="$(openssl rand -base64 32)" HTTP_PORT="9191" echo "" echo "Installation directory: ${INSTALL_DIR}" echo "Branch: ${DISPATCH_BRANCH}" echo "This will use approximately 10-15GB of disk space." echo "" echo "Press Enter to continue or Ctrl+C to cancel..." read } ############################################################################## # Check Prerequisites ############################################################################## check_prerequisites() { if is_step_complete "prerequisites"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Prerequisites already verified (skipping)" return fi print_progress "Checking system prerequisites..." # Check for Command Line Tools if ! xcode-select -p &>/dev/null; then echo "Installing Xcode Command Line Tools..." xcode-select --install echo "Please complete the installation and run this script again." exit 1 fi # Check architecture if [[ $(uname -m) != "arm64" ]]; then echo "[ERROR] This script requires Apple Silicon (arm64 architecture)" exit 1 fi # Check disk space (require 20GB free) local free_space=$(df -g . | awk 'NR==2 {print $4}') if [ "$free_space" -lt 20 ]; then echo "[WARNING] Less than 20GB free disk space. Installation may fail." fi mark_step_complete "prerequisites" print_step_complete "Prerequisites verified" } ############################################################################## # Create Directory Structure ############################################################################## create_directories() { if is_step_complete "create_directories"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Directory structure already created (skipping)" return fi print_progress "Creating directory structure..." mkdir -p "$INSTALL_DIR" mkdir -p "$APP_DIR" mkdir -p "$DATA_DIR"/{logos,recordings,uploads/{m3us,epgs},m3us,epgs,plugins,media,logo_cache,db} mkdir -p "$DEPS_DIR" mkdir -p "$RUNTIME_DIR"/{pids,sockets} mkdir -p "$LOGS_DIR" # Set proper ownership if running with sudo if [ -n "$SUDO_USER" ]; then echo "Setting directory ownership to $SUDO_USER..." chown -R "$SUDO_USER:staff" "$INSTALL_DIR" fi mark_step_complete "create_directories" print_step_complete "Directory structure created" } ############################################################################## # Download and Build Python ############################################################################## install_python() { if is_step_complete "install_python"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Python ${PYTHON_VERSION} already installed (skipping)" return fi print_progress "Installing Python ${PYTHON_VERSION}..." "${DEPS_DIR}/python-build.log" if [ -f "${PYTHON_PREFIX}/bin/python3" ]; then mark_step_complete "install_python" print_step_complete "Python already installed" return fi cd "${DEPS_DIR}" # Clean up any partial builds from previous failed attempts echo "Cleaning up any previous build attempts..." rm -rf "Python-${PYTHON_VERSION}" "python-${PYTHON_VERSION}-macos11.pkg" 2>/dev/null || true # Check for official prebuilt macOS installer local py_major_minor=$(echo $PYTHON_VERSION | cut -d. -f1-2) local py_installer="python-${PYTHON_VERSION}-macos11.pkg" # Try prebuilt if running with sudo (can install .pkg files) if [ "$EUID" -eq 0 ] || [ -n "$SUDO_USER" ]; then echo "Checking for prebuilt Python binary (running with elevated privileges)..." if curl -sfL "https://www.python.org/ftp/python/${PYTHON_VERSION}/${py_installer}" -o "${py_installer}" 2>/dev/null; then echo " Installing prebuilt Python package..." # Install the package to a temporary location and copy to our prefix if installer -pkg "${py_installer}" -target CurrentUserHomeDirectory > "${DEPS_DIR}/python-install.log" 2>&1; then # The .pkg installs to /Library/Frameworks/Python.framework local framework_path="/Library/Frameworks/Python.framework/Versions/${py_major_minor}" if [ -d "$framework_path" ]; then echo " Copying to installation directory..." cp -R "$framework_path"/* "${PYTHON_PREFIX}/" rm -f "${py_installer}" # Verify SSL support if "${PYTHON_PREFIX}/bin/python3" -c "import ssl; print('SSL OK')" > /dev/null 2>&1; then print_step_complete "Python ${PYTHON_VERSION} installed (prebuilt)" return else echo " ⚠️ Prebuilt Python has SSL issues, will build from source instead..." fi fi fi rm -f "${py_installer}" fi fi # Build from source (default path or fallback) echo "Building Python from source with optimizations..." # Ensure system utilities are in PATH export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" # Download Python source if [ ! -f "Python-${PYTHON_VERSION}.tar.xz" ]; then echo " Downloading Python source..." curl -LO "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tar.xz" fi echo " Extracting..." tar -xf "Python-${PYTHON_VERSION}.tar.xz" cd "Python-${PYTHON_VERSION}" # Detect OpenSSL location (try common paths) local openssl_path="" for path in /opt/homebrew/opt/openssl@3 /opt/homebrew/opt/openssl@1.1 /usr/local/opt/openssl@3 /usr/local/opt/openssl; do if [ -d "$path" ]; then openssl_path="$path" echo " Found OpenSSL at: $openssl_path" break fi done if [ -z "$openssl_path" ]; then echo "❌ OpenSSL not found!" echo "" echo "Please install OpenSSL first:" echo " brew install openssl@3" echo "" echo "Or if you don't have Homebrew, install it first:" echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" exit 1 fi # Configure with optimizations for Apple Silicon echo " Configuring for fast build..." CPPFLAGS="-I${openssl_path}/include" \ LDFLAGS="-L${openssl_path}/lib" \ ./configure \ --prefix="${PYTHON_PREFIX}" \ --enable-shared \ MACOSX_DEPLOYMENT_TARGET=12.0 \ --with-openssl="${openssl_path}" \ > "${DEPS_DIR}/python-configure.log" 2>&1 if [ $? -ne 0 ]; then echo "❌ Configuration failed! Check ${DEPS_DIR}/python-configure.log" exit 1 fi # Build with all cores (faster without PGO/LTO optimizations) echo " Compiling with ${MAKE_JOBS} cores (errors will be shown)..." if ! make -j${MAKE_JOBS} > "${DEPS_DIR}/python-build.log" 2>&1; then echo "❌ Build failed! Last 50 lines of log:" tail -50 "${DEPS_DIR}/python-build.log" exit 1 fi echo " Installing..." if ! make install > "${DEPS_DIR}/python-install.log" 2>&1; then echo "❌ Installation failed! Check ${DEPS_DIR}/python-install.log" exit 1 fi # Verify SSL support echo " Verifying SSL support..." if ! "${PYTHON_PREFIX}/bin/python3" -c "import ssl; print('SSL OK')" > /dev/null 2>&1; then echo "⚠️ WARNING: Python SSL module not available!" echo " This may cause issues with pip and HTTPS connections." echo " Check ${DEPS_DIR}/python-configure.log for details." else echo " ✅ SSL support verified" fi # Cleanup cd "${DEPS_DIR}" rm -rf "Python-${PYTHON_VERSION}" mark_step_complete "install_python" print_step_complete "Python ${PYTHON_VERSION} installed (optimized)" } ############################################################################## # Download and Build PostgreSQL ############################################################################## install_postgresql() { if is_step_complete "install_postgresql"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ PostgreSQL ${POSTGRES_VERSION} already installed (skipping)" return fi print_progress "Installing PostgreSQL ${POSTGRES_VERSION}..." "${DEPS_DIR}/postgresql-build.log" if [ -f "${POSTGRES_PREFIX}/bin/postgres" ]; then mark_step_complete "install_postgresql" print_step_complete "PostgreSQL already installed" return fi cd "${DEPS_DIR}" # Clean up any partial builds from previous failed attempts echo "Cleaning up any previous build attempts..." rm -rf "postgresql-${POSTGRES_VERSION}" "postgresql-${POSTGRES_VERSION}-darwin-arm64.zip" pgsql 2>/dev/null || true # Try to download prebuilt binary first local pg_major=$(echo $POSTGRES_VERSION | cut -d. -f1) local pg_archive="postgresql-${POSTGRES_VERSION}-darwin-arm64" echo "Checking for prebuilt PostgreSQL binary..." if curl -sfL "https://get.enterprisedb.com/postgresql/${pg_archive}.zip" -o "${pg_archive}.zip" 2>/dev/null; then echo " Using prebuilt binary..." unzip -q "${pg_archive}.zip" mv pgsql "${POSTGRES_PREFIX}" rm "${pg_archive}.zip" mark_step_complete "install_postgresql" print_step_complete "PostgreSQL ${POSTGRES_VERSION} installed (prebuilt)" else # Fallback to building from source echo " Prebuilt not available, building from source..." # Ensure system utilities are in PATH export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" # Download PostgreSQL source if [ ! -f "postgresql-${POSTGRES_VERSION}.tar.gz" ]; then echo " Downloading PostgreSQL source..." curl -LO "https://ftp.postgresql.org/pub/source/v${POSTGRES_VERSION}/postgresql-${POSTGRES_VERSION}.tar.gz" fi echo " Extracting and building PostgreSQL..." tar -xf "postgresql-${POSTGRES_VERSION}.tar.gz" cd "postgresql-${POSTGRES_VERSION}" echo " Configuring..." ./configure \ --prefix="${POSTGRES_PREFIX}" \ --with-pgport=5432 \ > "${DEPS_DIR}/postgresql-configure.log" 2>&1 if [ $? -ne 0 ]; then echo "❌ Configuration failed! Check ${DEPS_DIR}/postgresql-configure.log" exit 1 fi echo " Compiling (errors will be shown)..." if ! make -j$(sysctl -n hw.ncpu) > "${DEPS_DIR}/postgresql-build.log" 2>&1; then echo "❌ Build failed! Last 50 lines of log:" tail -50 "${DEPS_DIR}/postgresql-build.log" exit 1 fi echo " Installing..." if ! make install > "${DEPS_DIR}/postgresql-install.log" 2>&1; then echo "❌ Installation failed! Check ${DEPS_DIR}/postgresql-install.log" exit 1 fi # Cleanup cd "${DEPS_DIR}" rm -rf "postgresql-${POSTGRES_VERSION}" mark_step_complete "install_postgresql" print_step_complete "PostgreSQL ${POSTGRES_VERSION} installed (from source)" fi } ############################################################################## # Download and Build Redis ############################################################################## install_redis() { if is_step_complete "install_redis"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Redis ${REDIS_VERSION} already installed (skipping)" return fi print_progress "Installing Redis ${REDIS_VERSION}..." "${DEPS_DIR}/redis-build.log" if [ -f "${REDIS_PREFIX}/bin/redis-server" ]; then mark_step_complete "install_redis" print_step_complete "Redis already installed" return fi cd "${DEPS_DIR}" # Clean up any partial builds from previous failed attempts echo "Cleaning up any previous build attempts..." rm -rf "redis-${REDIS_VERSION}" 2>/dev/null || true # Redis doesn't provide official prebuilt binaries, but it compiles quickly # Build from source # Ensure system utilities are in PATH export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" # Download Redis source if [ ! -f "redis-${REDIS_VERSION}.tar.gz" ]; then echo "Downloading Redis source..." curl -LO "https://download.redis.io/releases/redis-${REDIS_VERSION}.tar.gz" fi echo "Building Redis..." tar -xf "redis-${REDIS_VERSION}.tar.gz" cd "redis-${REDIS_VERSION}" echo " Compiling (errors will be shown)..." if ! make -j$(sysctl -n hw.ncpu) > "${DEPS_DIR}/redis-build.log" 2>&1; then echo "❌ Build failed! Last 50 lines of log:" tail -50 "${DEPS_DIR}/redis-build.log" exit 1 fi echo " Installing..." if ! make PREFIX="${REDIS_PREFIX}" install > "${DEPS_DIR}/redis-install.log" 2>&1; then echo "❌ Installation failed! Check ${DEPS_DIR}/redis-install.log" exit 1 fi # Create default config mkdir -p "${REDIS_PREFIX}/etc" cat > "${REDIS_PREFIX}/etc/redis.conf" </dev/null || true # Download nginx source if [ ! -f "nginx-${NGINX_VERSION}.tar.gz" ]; then echo "Downloading nginx source..." curl -LO "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" fi echo "Extracting nginx..." tar -xf "nginx-${NGINX_VERSION}.tar.gz" cd "nginx-${NGINX_VERSION}" echo " Configuring nginx..." ./configure \ --prefix="${NGINX_PREFIX}" \ --with-http_ssl_module \ --with-http_v2_module \ --with-http_realip_module \ --with-http_stub_status_module \ --without-http_gzip_module \ --without-http_rewrite_module \ > "${DEPS_DIR}/nginx-configure.log" 2>&1 echo " Compiling nginx..." if ! make -j$(sysctl -n hw.ncpu) > "${DEPS_DIR}/nginx-build.log" 2>&1; then echo "❌ Build failed! Last 50 lines of log:" tail -50 "${DEPS_DIR}/nginx-build.log" exit 1 fi echo " Installing nginx..." if ! make install > "${DEPS_DIR}/nginx-install.log" 2>&1; then echo "❌ Installation failed! Check ${DEPS_DIR}/nginx-install.log" exit 1 fi # Cleanup cd "${DEPS_DIR}" rm -rf "nginx-${NGINX_VERSION}" mark_step_complete "install_nginx" print_step_complete "nginx installed" } ############################################################################## # Download and Install Node.js ############################################################################## install_node() { if is_step_complete "install_node"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Node.js ${NODE_VERSION} already installed (skipping)" return fi print_progress "Installing Node.js ${NODE_VERSION}..." if [ -f "${NODE_PREFIX}/bin/node" ]; then mark_step_complete "install_node" print_step_complete "Node.js already installed" return fi cd "${DEPS_DIR}" # Download Node.js binary (pre-built for arm64) local node_archive="node-v${NODE_VERSION}-darwin-arm64" if [ ! -f "${node_archive}.tar.gz" ]; then echo "Downloading Node.js..." curl -LO "https://nodejs.org/dist/v${NODE_VERSION}/${node_archive}.tar.gz" fi echo "Extracting Node.js..." tar -xf "${node_archive}.tar.gz" mv "${node_archive}" "${NODE_PREFIX}" mark_step_complete "install_node" print_step_complete "Node.js ${NODE_VERSION} installed" } ############################################################################## # Download and Build FFmpeg with VideoToolbox ############################################################################## install_ffmpeg() { if is_step_complete "install_ffmpeg"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ FFmpeg with VideoToolbox already installed (skipping)" return fi print_progress "Building FFmpeg with VideoToolbox..." "${DEPS_DIR}/ffmpeg-build.log" if [ -f "${FFMPEG_PREFIX}/bin/ffmpeg" ]; then mark_step_complete "install_ffmpeg" print_step_complete "FFmpeg already installed" return fi # Ensure system utilities are in PATH export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH" cd "${DEPS_DIR}" # Clean up any partial builds (but keep the source repo if it exists) if [ -d "ffmpeg-source" ]; then echo "Cleaning previous build artifacts..." cd ffmpeg-source make distclean > /dev/null 2>&1 || true cd "${DEPS_DIR}" fi # Clone FFmpeg if [ ! -d "ffmpeg-source" ]; then echo "Downloading FFmpeg source..." git clone --depth 1 https://git.ffmpeg.org/ffmpeg.git ffmpeg-source fi cd ffmpeg-source echo "Building FFmpeg with VideoToolbox..." echo " Configuring..." ./configure \ --prefix="${FFMPEG_PREFIX}" \ --enable-videotoolbox \ --enable-audiotoolbox \ --enable-gpl \ --enable-version3 \ --enable-nonfree \ --enable-shared \ --disable-static \ --disable-debug \ --arch=arm64 \ --cpu=native \ > "${DEPS_DIR}/ffmpeg-configure.log" 2>&1 if [ $? -ne 0 ]; then echo "❌ Configuration failed! Check ${DEPS_DIR}/ffmpeg-configure.log" exit 1 fi echo " Compiling (this takes the longest - errors will be shown)..." if ! make -j$(sysctl -n hw.ncpu) > "${DEPS_DIR}/ffmpeg-build.log" 2>&1; then echo "❌ Build failed! Last 50 lines of log:" tail -50 "${DEPS_DIR}/ffmpeg-build.log" exit 1 fi echo " Installing..." if ! make install > "${DEPS_DIR}/ffmpeg-install.log" 2>&1; then echo "❌ Installation failed! Check ${DEPS_DIR}/ffmpeg-install.log" exit 1 fi mark_step_complete "install_ffmpeg" print_step_complete "FFmpeg with VideoToolbox installed" } ############################################################################## # Install Streamlink (Python package, but we'll make it self-contained) ############################################################################## install_streamlink() { if is_step_complete "install_streamlink"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Streamlink already installed (skipping)" return fi print_progress "Installing Streamlink..." export PATH="${PYTHON_PREFIX}/bin:$PATH" export DYLD_LIBRARY_PATH="${PYTHON_PREFIX}/lib:${DYLD_LIBRARY_PATH:-}" # Set up pip cache in our installation directory to avoid permission issues local pip_cache="${INSTALL_DIR}/.pip_cache" mkdir -p "$pip_cache" # If running with sudo, ensure cache is owned by the actual user if [ -n "$SUDO_USER" ]; then chown -R "$SUDO_USER:staff" "$pip_cache" # Run pip as the actual user to avoid permission issues sudo -u "$SUDO_USER" "${PYTHON_PREFIX}/bin/pip3" install --cache-dir="$pip_cache" streamlink --quiet else "${PYTHON_PREFIX}/bin/pip3" install --cache-dir="$pip_cache" streamlink --quiet fi mark_step_complete "install_streamlink" print_step_complete "Streamlink installed" } ############################################################################## # Clone Dispatcharr Repository ############################################################################## clone_dispatcharr() { if is_step_complete "clone_dispatcharr"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Dispatcharr repository already cloned (skipping)" return fi print_progress "Cloning Dispatcharr repository (branch: ${DISPATCH_BRANCH})..." if [ -d "${APP_DIR}/.git" ]; then cd "${APP_DIR}" git fetch origin --quiet git checkout $DISPATCH_BRANCH --quiet git pull origin $DISPATCH_BRANCH --quiet echo "Repository updated" else git clone -b $DISPATCH_BRANCH https://github.com/Dispatcharr/Dispatcharr.git "${APP_DIR}" --quiet echo "Repository cloned" fi # Fix ownership if running with sudo (repository was cloned as root) if [ -n "$SUDO_USER" ]; then echo "Fixing repository ownership..." chown -R "$SUDO_USER:staff" "${APP_DIR}" fi # Now re-detect versions from the cloned repository echo "" detect_versions "${APP_DIR}" mark_step_complete "clone_dispatcharr" print_step_complete "Dispatcharr repository ready" } ############################################################################## # Setup Python Virtual Environment ############################################################################## setup_python_env() { if is_step_complete "setup_python_env"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Python environment already configured (skipping)" return fi print_progress "Setting up Python virtual environment..." export PATH="${PYTHON_PREFIX}/bin:$PATH" export DYLD_LIBRARY_PATH="${PYTHON_PREFIX}/lib:${DYLD_LIBRARY_PATH:-}" # Clean up any partial venv from previous failed attempts if [ -d "${APP_DIR}/env" ]; then echo "Cleaning up previous virtual environment..." rm -rf "${APP_DIR}/env" fi # Set up pip cache in our installation directory local pip_cache="${INSTALL_DIR}/.pip_cache" mkdir -p "$pip_cache" # If running with sudo, ensure cache is owned by the actual user if [ -n "$SUDO_USER" ]; then chown -R "$SUDO_USER:staff" "$pip_cache" fi # Set pip cache directory explicitly export PIP_CACHE_DIR="$pip_cache" export XDG_CACHE_HOME="${INSTALL_DIR}" cd "${APP_DIR}" # Create venv (as actual user if using sudo) echo "Creating virtual environment..." if [ -n "$SUDO_USER" ]; then sudo -u "$SUDO_USER" "${PYTHON_PREFIX}/bin/python3" -m venv env else "${PYTHON_PREFIX}/bin/python3" -m venv env fi source env/bin/activate # Install packages as actual user if using sudo if [ -n "$SUDO_USER" ]; then # Upgrade pip (with explicit cache) echo "Upgrading pip..." sudo -u "$SUDO_USER" env/bin/pip install --cache-dir="$pip_cache" --upgrade pip wheel setuptools --quiet # Install requirements (excluding torch to handle separately) echo "Installing Python packages..." grep -v "^torch" requirements.txt > requirements_no_torch.txt sudo -u "$SUDO_USER" env/bin/pip install --cache-dir="$pip_cache" -r requirements_no_torch.txt --quiet sudo -u "$SUDO_USER" env/bin/pip install --cache-dir="$pip_cache" whitenoise --quiet rm requirements_no_torch.txt # Install PyTorch for Apple Silicon (MPS support) echo "Installing PyTorch with Apple Silicon support..." sudo -u "$SUDO_USER" env/bin/pip install --cache-dir="$pip_cache" torch torchvision --quiet else # Upgrade pip (with explicit cache) echo "Upgrading pip..." pip install --cache-dir="$pip_cache" --upgrade pip wheel setuptools --quiet # Install requirements (excluding torch to handle separately) echo "Installing Python packages..." grep -v "^torch" requirements.txt > requirements_no_torch.txt pip install --cache-dir="$pip_cache" -r requirements_no_torch.txt --quiet pip install --cache-dir="$pip_cache" whitenoise --quiet rm requirements_no_torch.txt # Install PyTorch for Apple Silicon (MPS support) echo "Installing PyTorch with Apple Silicon support..." # For macOS, use the default index which has proper Apple Silicon support # The +cpu suffix in requirements.txt is for Linux Docker containers pip install --cache-dir="$pip_cache" torch torchvision --quiet fi # Link FFmpeg ln -sf "${FFMPEG_PREFIX}/bin/ffmpeg" env/bin/ffmpeg ln -sf "${FFMPEG_PREFIX}/bin/ffprobe" env/bin/ffprobe deactivate mark_step_complete "setup_python_env" print_step_complete "Python environment configured" } ############################################################################## # Build Frontend ############################################################################## build_frontend() { if is_step_complete "build_frontend"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Frontend already built (skipping)" return fi print_progress "Building frontend..." export PATH="${NODE_PREFIX}/bin:$PATH" # Set up npm cache in our installation directory to avoid permission issues local npm_cache="${INSTALL_DIR}/.npm_cache" mkdir -p "$npm_cache" # If running with sudo, ensure cache is owned by the actual user if [ -n "$SUDO_USER" ]; then chown -R "$SUDO_USER:staff" "$npm_cache" fi cd "${APP_DIR}/frontend" # Clean up any partial builds from previous failed attempts echo "Cleaning up previous build artifacts..." rm -rf node_modules dist .vite 2>/dev/null || true # Configure Vite to use /static/ base path to match Django's STATIC_URL echo "Configuring Vite for Django static file serving..." if grep -q "base:" vite.config.js; then # Update existing base configuration sed -i.bak "s|base:.*|base: '/static/',|" vite.config.js else # Add base configuration to defineConfig sed -i.bak "/export default defineConfig({/a\\ base: '/static/', " vite.config.js fi # Run npm as actual user if using sudo if [ -n "$SUDO_USER" ]; then echo "Installing npm packages..." sudo -u "$SUDO_USER" npm install --legacy-peer-deps --quiet --cache="$npm_cache" echo "Building frontend assets..." sudo -u "$SUDO_USER" npm run build --quiet else echo "Installing npm packages..." npm install --legacy-peer-deps --quiet --cache="$npm_cache" echo "Building frontend assets..." npm run build --quiet fi # Restore original vite.config.js to avoid git changes if [ -f vite.config.js.bak ]; then mv vite.config.js.bak vite.config.js fi mark_step_complete "build_frontend" print_step_complete "Frontend built successfully" } ############################################################################## # Initialize PostgreSQL Database ############################################################################## init_postgresql() { if is_step_complete "init_postgresql"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ PostgreSQL already initialized (skipping)" return fi print_progress "Initializing PostgreSQL database..." export PATH="${POSTGRES_PREFIX}/bin:$PATH" export PGDATA="${DATA_DIR}/db" # Check if database cluster is properly initialized local need_init=false if [ ! -d "${PGDATA}/base" ] || [ ! -f "${PGDATA}/PG_VERSION" ]; then need_init=true else # Validate the database cluster is not corrupted echo "Validating existing database cluster..." if [ -f "${PGDATA}/postmaster.pid" ]; then # Clean up stale PID file local pg_pid=$(head -1 "${PGDATA}/postmaster.pid" 2>/dev/null) if [ -n "$pg_pid" ] && ! kill -0 "$pg_pid" 2>/dev/null; then echo " Cleaning up stale PID file..." rm -f "${PGDATA}/postmaster.pid" fi fi # Try a quick validation - if pg_controldata fails, the cluster is corrupted if ! "${POSTGRES_PREFIX}/bin/pg_controldata" "${PGDATA}" > /dev/null 2>&1; then echo " ⚠️ Database cluster appears corrupted, will reinitialize..." need_init=true # Clean up the corrupted database rm -rf "${PGDATA}" fi fi if [ "$need_init" = true ]; then echo "Creating database cluster..." echo " Data directory: ${PGDATA}" # Ensure the postgres data directory exists and has proper permissions mkdir -p "${PGDATA}" if [ -n "$SUDO_USER" ]; then chown -R "$SUDO_USER:staff" "${PGDATA}" fi # PostgreSQL initdb cannot run as root, so run as actual user if using sudo if [ -n "$SUDO_USER" ]; then echo " Running initdb as user $SUDO_USER..." if ! sudo -u "$SUDO_USER" "${POSTGRES_PREFIX}/bin/initdb" -D "${PGDATA}" --auth=trust > "${LOGS_DIR}/initdb.log" 2>&1; then echo "❌ Failed to initialize PostgreSQL!" echo "" echo "Last 30 lines of ${LOGS_DIR}/initdb.log:" tail -30 "${LOGS_DIR}/initdb.log" || echo "Could not read log file" exit 1 fi else echo " Running initdb..." if ! "${POSTGRES_PREFIX}/bin/initdb" -D "${PGDATA}" --auth=trust > "${LOGS_DIR}/initdb.log" 2>&1; then echo "❌ Failed to initialize PostgreSQL!" echo "" echo "Last 30 lines of ${LOGS_DIR}/initdb.log:" tail -30 "${LOGS_DIR}/initdb.log" || echo "Could not read log file" exit 1 fi fi echo " ✓ Database cluster initialized" # Configure PostgreSQL (use simpler logging to avoid permission issues) cat >> "${PGDATA}/postgresql.conf" </dev/null; then echo " PostgreSQL is already running" else # Clean up stale PID file if process is not running if [ -f "${PGDATA}/postmaster.pid" ]; then local pg_pid=$(head -1 "${PGDATA}/postmaster.pid" 2>/dev/null) if [ -n "$pg_pid" ] && ! kill -0 "$pg_pid" 2>/dev/null; then echo " Cleaning up stale PID file..." rm -f "${PGDATA}/postmaster.pid" fi fi # Start PostgreSQL echo " Starting PostgreSQL server..." if [ -n "$SUDO_USER" ]; then if ! sudo -u "$SUDO_USER" "${POSTGRES_PREFIX}/bin/pg_ctl" \ -D "${PGDATA}" \ -l "${LOGS_DIR}/postgresql.log" \ -w \ start; then echo "❌ Failed to start PostgreSQL!" echo "" echo "Checking for database corruption..." if "${POSTGRES_PREFIX}/bin/pg_controldata" "${PGDATA}" 2>&1 | grep -q "could not open\|invalid\|corrupted"; then echo " Database appears corrupted. Logs:" fi echo "" echo "Last 30 lines of ${LOGS_DIR}/postgresql.log:" tail -30 "${LOGS_DIR}/postgresql.log" 2>/dev/null || echo "Could not read log file" echo "" echo "To fix this, you can:" echo " 1. Delete the corrupted database: rm -rf ${PGDATA}" echo " 2. Remove the checkpoint: rm ${INSTALL_DIR}/.install_progress" echo " 3. Re-run the installation script" exit 1 fi else if ! "${POSTGRES_PREFIX}/bin/pg_ctl" \ -D "${PGDATA}" \ -l "${LOGS_DIR}/postgresql.log" \ -w \ start; then echo "❌ Failed to start PostgreSQL!" echo "" echo "Checking for database corruption..." if "${POSTGRES_PREFIX}/bin/pg_controldata" "${PGDATA}" 2>&1 | grep -q "could not open\|invalid\|corrupted"; then echo " Database appears corrupted. Logs:" fi echo "" echo "Last 30 lines of ${LOGS_DIR}/postgresql.log:" tail -30 "${LOGS_DIR}/postgresql.log" 2>/dev/null || echo "Could not read log file" echo "" echo "To fix this, you can:" echo " 1. Delete the corrupted database: rm -rf ${PGDATA}" echo " 2. Remove the checkpoint: rm ${INSTALL_DIR}/.install_progress" echo " 3. Re-run the installation script" exit 1 fi fi echo " ✓ PostgreSQL started successfully" fi # Verify PostgreSQL is accepting connections echo " Verifying PostgreSQL is accepting connections..." local ready_count=0 for i in {1..15}; do if "${POSTGRES_PREFIX}/bin/pg_isready" -h 127.0.0.1 -q 2>/dev/null; then ready_count=$((ready_count + 1)) if [ $ready_count -ge 2 ]; then echo " ✓ PostgreSQL is ready and accepting connections" break fi fi sleep 1 done if [ $ready_count -lt 2 ]; then echo "⚠️ Warning: PostgreSQL may not be fully ready" fi # Start Redis (as actual user if using sudo) echo "Starting Redis..." if "${REDIS_PREFIX}/bin/redis-cli" ping > /dev/null 2>&1; then echo " Redis is already running" else if [ -n "$SUDO_USER" ]; then sudo -u "$SUDO_USER" "${REDIS_PREFIX}/bin/redis-server" "${REDIS_PREFIX}/etc/redis.conf" & echo $! > "${RUNTIME_DIR}/pids/redis.pid" else "${REDIS_PREFIX}/bin/redis-server" "${REDIS_PREFIX}/etc/redis.conf" & echo $! > "${RUNTIME_DIR}/pids/redis.pid" fi sleep 2 echo " ✓ Redis started" fi print_step_complete "Database services started" } ############################################################################## # Setup Database and User ############################################################################## setup_database() { if is_step_complete "setup_database"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Database already configured (skipping)" return fi print_progress "Configuring database..." export PATH="${POSTGRES_PREFIX}/bin:$PATH" export PGDATA="${DATA_DIR}/db" # Determine the actual username (not "root" when using sudo) local ACTUAL_USER="${SUDO_USER:-$USER}" # Create user and database echo "Creating database and user..." echo " Connecting as PostgreSQL user: $ACTUAL_USER" # Check if database exists, create if not local db_exists=$("${POSTGRES_PREFIX}/bin/psql" -h 127.0.0.1 -U "$ACTUAL_USER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'" 2>/dev/null) if [ "$db_exists" != "1" ]; then echo " Creating database '$POSTGRES_DB'..." "${POSTGRES_PREFIX}/bin/psql" -h 127.0.0.1 -U "$ACTUAL_USER" -d postgres -c "CREATE DATABASE $POSTGRES_DB;" else echo " Database '$POSTGRES_DB' already exists" fi # Check if user exists, create if not local user_exists=$("${POSTGRES_PREFIX}/bin/psql" -h 127.0.0.1 -U "$ACTUAL_USER" -d postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'" 2>/dev/null) if [ "$user_exists" != "1" ]; then echo " Creating user '$POSTGRES_USER'..." "${POSTGRES_PREFIX}/bin/psql" -h 127.0.0.1 -U "$ACTUAL_USER" -d postgres -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';" else echo " User '$POSTGRES_USER' already exists" fi # Grant privileges echo " Granting privileges..." "${POSTGRES_PREFIX}/bin/psql" -h 127.0.0.1 -U "$ACTUAL_USER" -d postgres <> dispatcharr/settings.py echo '# WhiteNoise configuration for serving static files with ASGI' >> dispatcharr/settings.py echo 'STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"' >> dispatcharr/settings.py fi print_step_complete "WhiteNoise configured" } ############################################################################## # Run Django Migrations ############################################################################## run_migrations() { if is_step_complete "run_migrations"; then CURRENT_STEP=$((CURRENT_STEP + 1)) echo "✓ Django migrations already run (skipping)" return fi print_progress "Running Django migrations..." cd "${APP_DIR}" export PATH="${PYTHON_PREFIX}/bin:$PATH" export DYLD_LIBRARY_PATH="${PYTHON_PREFIX}/lib:${POSTGRES_PREFIX}/lib:${FFMPEG_PREFIX}/lib:${DYLD_LIBRARY_PATH:-}" # Run migrations as actual user if using sudo if [ -n "$SUDO_USER" ]; then sudo -u "$SUDO_USER" bash < "${APP_DIR}/uwsgi.ini" < "${NGINX_PREFIX}/conf/nginx.conf" < "${INSTALL_DIR}/dispatcharr.sh" <<'LAUNCHER_EOF' #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_DIR="${SCRIPT_DIR}/app" DEPS_DIR="${SCRIPT_DIR}/deps" RUNTIME_DIR="${SCRIPT_DIR}/runtime" LOGS_DIR="${SCRIPT_DIR}/logs" # Load environment export PATH="${DEPS_DIR}/python/bin:${DEPS_DIR}/postgresql/bin:${DEPS_DIR}/redis/bin:${DEPS_DIR}/nginx/sbin:${DEPS_DIR}/ffmpeg/bin:$PATH" export DYLD_LIBRARY_PATH="${DEPS_DIR}/python/lib:${DEPS_DIR}/postgresql/lib:${DEPS_DIR}/ffmpeg/lib:${DYLD_LIBRARY_PATH:-}" export PGDATA="${SCRIPT_DIR}/data/db" # Source credentials if they exist if [ -f "${SCRIPT_DIR}/.env" ]; then source "${SCRIPT_DIR}/.env" fi # Export required environment variables for Dispatcharr export POSTGRES_DB="${POSTGRES_DB:-dispatcharr}" export POSTGRES_USER="${POSTGRES_USER:-dispatch}" export POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" export POSTGRES_HOST="${POSTGRES_HOST:-127.0.0.1}" export POSTGRES_PORT="${POSTGRES_PORT:-5432}" export REDIS_HOST="${REDIS_HOST:-127.0.0.1}" export REDIS_DB="${REDIS_DB:-0}" export CELERY_BROKER_URL="${CELERY_BROKER_URL:-redis://127.0.0.1:6379/0}" export DISPATCHARR_PORT="${HTTP_PORT:-9191}" export DISPATCHARR_DEBUG="${DISPATCHARR_DEBUG:-true}" export DISPATCHARR_PLUGINS_DIR="${SCRIPT_DIR}/data/plugins" case "${1:-}" in start) echo "Starting Dispatcharr services..." # Start PostgreSQL if ! pg_isready -h 127.0.0.1 >/dev/null 2>&1; then pg_ctl -D "${PGDATA}" -l "${LOGS_DIR}/postgresql.log" start echo "✓ PostgreSQL started" else echo "✓ PostgreSQL already running" fi # Start Redis if ! redis-cli ping >/dev/null 2>&1; then redis-server "${DEPS_DIR}/redis/etc/redis.conf" & echo $! > "${RUNTIME_DIR}/pids/redis.pid" echo "✓ Redis started" else echo "✓ Redis already running" fi sleep 2 # Start uWSGI (communicates with nginx via Unix socket) if [ ! -f "${RUNTIME_DIR}/pids/uwsgi.pid" ] || ! kill -0 $(cat "${RUNTIME_DIR}/pids/uwsgi.pid") 2>/dev/null; then cd "${APP_DIR}" source env/bin/activate uwsgi \ --ini uwsgi.ini \ --pidfile "${RUNTIME_DIR}/pids/uwsgi.pid" \ --daemonize "${LOGS_DIR}/uwsgi.log" echo "✓ uWSGI started" else echo "✓ uWSGI already running" fi # Start Daphne (handles WebSockets on port 8001) if [ ! -f "${RUNTIME_DIR}/pids/daphne.pid" ] || ! kill -0 $(cat "${RUNTIME_DIR}/pids/daphne.pid") 2>/dev/null; then cd "${APP_DIR}" source env/bin/activate daphne \ -b 127.0.0.1 \ -p 8001 \ --access-log "${LOGS_DIR}/daphne-access.log" \ --proxy-headers \ --websocket_timeout 86400 \ --application-close-timeout 600 \ dispatcharr.asgi:application \ > "${LOGS_DIR}/daphne.log" 2>&1 & echo $! > "${RUNTIME_DIR}/pids/daphne.pid" echo "✓ Daphne started" else echo "✓ Daphne already running" fi # Start nginx (proxies to uWSGI and Daphne) if [ ! -f "${RUNTIME_DIR}/pids/nginx.pid" ] || ! kill -0 $(cat "${RUNTIME_DIR}/pids/nginx.pid") 2>/dev/null; then HTTP_PORT="${HTTP_PORT:-9191}" nginx -c "${DEPS_DIR}/nginx/conf/nginx.conf" > "${LOGS_DIR}/nginx-startup.log" 2>&1 & echo $! > "${RUNTIME_DIR}/pids/nginx.pid" echo "✓ nginx started (HTTP on port ${HTTP_PORT:-9191})" else echo "✓ nginx already running" fi # Start Celery worker if [ ! -f "${RUNTIME_DIR}/pids/celery.pid" ] || ! kill -0 $(cat "${RUNTIME_DIR}/pids/celery.pid") 2>/dev/null; then cd "${APP_DIR}" source env/bin/activate celery -A dispatcharr worker -l info \ --pidfile="${RUNTIME_DIR}/pids/celery.pid" \ --logfile="${LOGS_DIR}/celery.log" \ --detach echo "✓ Celery worker started" else echo "✓ Celery worker already running" fi # Start Celery beat if [ ! -f "${RUNTIME_DIR}/pids/celerybeat.pid" ] || ! kill -0 $(cat "${RUNTIME_DIR}/pids/celerybeat.pid") 2>/dev/null; then cd "${APP_DIR}" source env/bin/activate celery -A dispatcharr beat -l info \ --pidfile="${RUNTIME_DIR}/pids/celerybeat.pid" \ --logfile="${LOGS_DIR}/celerybeat.log" \ --detach echo "✓ Celery beat started" else echo "✓ Celery beat already running" fi echo "" echo "Dispatcharr is running!" echo "Access at: http://localhost:${HTTP_PORT:-9191}" ;; stop) echo "Stopping Dispatcharr services..." # Stop nginx if [ -f "${RUNTIME_DIR}/pids/nginx.pid" ]; then kill $(cat "${RUNTIME_DIR}/pids/nginx.pid") 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/nginx.pid" echo "✓ nginx stopped" fi # Stop uWSGI if [ -f "${RUNTIME_DIR}/pids/uwsgi.pid" ]; then kill $(cat "${RUNTIME_DIR}/pids/uwsgi.pid") 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/uwsgi.pid" echo "✓ uWSGI stopped" fi # Stop Daphne if [ -f "${RUNTIME_DIR}/pids/daphne.pid" ]; then kill $(cat "${RUNTIME_DIR}/pids/daphne.pid") 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/daphne.pid" echo "✓ Daphne stopped" fi # Stop Celery beat if [ -f "${RUNTIME_DIR}/pids/celerybeat.pid" ]; then kill $(cat "${RUNTIME_DIR}/pids/celerybeat.pid") 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/celerybeat.pid" echo "✓ Celery beat stopped" fi # Stop Celery worker if [ -f "${RUNTIME_DIR}/pids/celery.pid" ]; then kill $(cat "${RUNTIME_DIR}/pids/celery.pid") 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/celery.pid" echo "✓ Celery worker stopped" fi # Stop Redis if [ -f "${RUNTIME_DIR}/pids/redis.pid" ]; then redis-cli shutdown 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/redis.pid" echo "✓ Redis stopped" fi # Stop PostgreSQL if pg_isready -h 127.0.0.1 >/dev/null 2>&1; then pg_ctl -D "${PGDATA}" stop echo "✓ PostgreSQL stopped" fi echo "All services stopped" ;; restart) $0 stop sleep 2 $0 start ;; status) echo "Dispatcharr Service Status:" echo "" if pg_isready -h 127.0.0.1 >/dev/null 2>&1; then echo "✓ PostgreSQL: running" else echo "✗ PostgreSQL: stopped" fi if redis-cli ping >/dev/null 2>&1; then echo "✓ Redis: running" else echo "✗ Redis: stopped" fi if [ -f "${RUNTIME_DIR}/pids/nginx.pid" ] && kill -0 $(cat "${RUNTIME_DIR}/pids/nginx.pid") 2>/dev/null; then echo "✓ nginx: running on port ${HTTP_PORT:-9191} (PID $(cat "${RUNTIME_DIR}/pids/nginx.pid"))" else echo "✗ nginx: stopped" fi if [ -f "${RUNTIME_DIR}/pids/uwsgi.pid" ] && kill -0 $(cat "${RUNTIME_DIR}/pids/uwsgi.pid") 2>/dev/null; then echo "✓ uWSGI: running (PID $(cat "${RUNTIME_DIR}/pids/uwsgi.pid"))" else echo "✗ uWSGI: stopped" fi if [ -f "${RUNTIME_DIR}/pids/daphne.pid" ] && kill -0 $(cat "${RUNTIME_DIR}/pids/daphne.pid") 2>/dev/null; then echo "✓ Daphne (WebSockets): running (PID $(cat "${RUNTIME_DIR}/pids/daphne.pid"))" else echo "✗ Daphne: stopped" fi if [ -f "${RUNTIME_DIR}/pids/celery.pid" ] && kill -0 $(cat "${RUNTIME_DIR}/pids/celery.pid") 2>/dev/null; then echo "✓ Celery worker: running (PID $(cat "${RUNTIME_DIR}/pids/celery.pid"))" else echo "✗ Celery worker: stopped" fi if [ -f "${RUNTIME_DIR}/pids/celerybeat.pid" ] && kill -0 $(cat "${RUNTIME_DIR}/pids/celerybeat.pid") 2>/dev/null; then echo "✓ Celery beat: running (PID $(cat "${RUNTIME_DIR}/pids/celerybeat.pid"))" else echo "✗ Celery beat: stopped" fi ;; logs) tail -f "${LOGS_DIR}"/*.log ;; update) echo "Updating Dispatcharr..." # Save current branch CURRENT_BRANCH=$(cd "${APP_DIR}" && git rev-parse --abbrev-ref HEAD) echo "Current branch: ${CURRENT_BRANCH}" # Check if there are local changes if [ -n "$(cd "${APP_DIR}" && git status --porcelain)" ]; then echo "" echo "⚠️ WARNING: You have local changes in the app directory!" echo "These will be overwritten by the update." echo "" echo -n "Continue with update? [y/N]: " read confirm if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then echo "Update cancelled" exit 0 fi fi $0 stop echo "Pulling latest code from ${CURRENT_BRANCH}..." cd "${APP_DIR}" git fetch origin git reset --hard "origin/${CURRENT_BRANCH}" # Clear Python bytecode cache to ensure version updates echo "Clearing Python cache..." find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type f -name "*.pyc" -delete 2>/dev/null || true echo "Updating Python dependencies..." source env/bin/activate # Install requirements (excluding torch to handle separately for Apple Silicon) grep -v "^torch" requirements.txt > requirements_no_torch.txt pip install -r requirements_no_torch.txt --quiet --upgrade rm requirements_no_torch.txt # Install PyTorch for Apple Silicon (MPS support) # The +cpu suffix in requirements.txt is for Linux Docker containers pip install torch torchvision --quiet --upgrade echo "Rebuilding frontend..." cd frontend # Configure Vite to use /static/ base path if grep -q "base:" vite.config.js; then sed -i.bak "s|base:.*|base: '/static/',|" vite.config.js else sed -i.bak "/export default defineConfig({/a\\ base: '/static/', " vite.config.js fi npm install --legacy-peer-deps --quiet npm run build --quiet # Restore original vite.config.js if [ -f vite.config.js.bak ]; then mv vite.config.js.bak vite.config.js fi cd "${APP_DIR}" # Ensure WhiteNoise is configured for static file serving echo "Configuring WhiteNoise..." if ! grep -q "whitenoise.middleware.WhiteNoiseMiddleware" dispatcharr/settings.py; then sed -i.bak '/django.middleware.security.SecurityMiddleware/a\ "whitenoise.middleware.WhiteNoiseMiddleware", ' dispatcharr/settings.py rm -f dispatcharr/settings.py.bak fi if ! grep -q "STATICFILES_STORAGE.*whitenoise" dispatcharr/settings.py; then echo '' >> dispatcharr/settings.py echo '# WhiteNoise configuration for serving static files with ASGI' >> dispatcharr/settings.py echo 'STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"' >> dispatcharr/settings.py fi deactivate # Start PostgreSQL for migrations echo "Starting PostgreSQL..." if ! pg_isready -h 127.0.0.1 >/dev/null 2>&1; then pg_ctl -D "${PGDATA}" -l "${LOGS_DIR}/postgresql.log" start sleep 2 fi # Run migrations with database running echo "Running database migrations..." source env/bin/activate python manage.py migrate --noinput python manage.py collectstatic --noinput --clear deactivate "${SCRIPT_DIR}/dispatcharr.sh" start echo "✓ Update complete" ;; switch-branch) if [ -z "${2:-}" ]; then echo "Usage: ${BASH_SOURCE[0]} switch-branch " echo "" echo "Available branches:" cd "${APP_DIR}" && git branch -r | grep -v HEAD | sed 's/origin\// /' || true exit 1 fi NEW_BRANCH="$2" CURRENT_BRANCH=$(cd "${APP_DIR}" && git rev-parse --abbrev-ref HEAD) echo "Switching from ${CURRENT_BRANCH} to ${NEW_BRANCH}..." # Check if branch exists if ! cd "${APP_DIR}" && git ls-remote --heads origin "${NEW_BRANCH}" | grep -q "${NEW_BRANCH}"; then echo "Error: Branch '${NEW_BRANCH}' does not exist on remote" exit 1 fi "${SCRIPT_DIR}/dispatcharr.sh" stop cd "${APP_DIR}" git fetch origin git checkout "${NEW_BRANCH}" git pull origin "${NEW_BRANCH}" # Clear Python bytecode cache to ensure version updates echo "Clearing Python cache..." find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true find . -type f -name "*.pyc" -delete 2>/dev/null || true echo "Updating dependencies for new branch..." source env/bin/activate # Install requirements (excluding torch to handle separately for Apple Silicon) grep -v "^torch" requirements.txt > requirements_no_torch.txt pip install -r requirements_no_torch.txt --quiet --upgrade rm requirements_no_torch.txt # Install PyTorch for Apple Silicon (MPS support) # The +cpu suffix in requirements.txt is for Linux Docker containers pip install torch torchvision --quiet --upgrade cd frontend # Configure Vite to use /static/ base path if grep -q "base:" vite.config.js; then sed -i.bak "s|base:.*|base: '/static/',|" vite.config.js else sed -i.bak "/export default defineConfig({/a\\ base: '/static/', " vite.config.js fi npm install --legacy-peer-deps --quiet npm run build --quiet # Restore original vite.config.js if [ -f vite.config.js.bak ]; then mv vite.config.js.bak vite.config.js fi cd "${APP_DIR}" # Ensure WhiteNoise is configured for static file serving echo "Configuring WhiteNoise..." if ! grep -q "whitenoise.middleware.WhiteNoiseMiddleware" dispatcharr/settings.py; then sed -i.bak '/django.middleware.security.SecurityMiddleware/a\ "whitenoise.middleware.WhiteNoiseMiddleware", ' dispatcharr/settings.py rm -f dispatcharr/settings.py.bak fi if ! grep -q "STATICFILES_STORAGE.*whitenoise" dispatcharr/settings.py; then echo '' >> dispatcharr/settings.py echo '# WhiteNoise configuration for serving static files with ASGI' >> dispatcharr/settings.py echo 'STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"' >> dispatcharr/settings.py fi deactivate # Update .env with new branch info if grep -q "DISPATCH_BRANCH=" "${SCRIPT_DIR}/.env" 2>/dev/null; then sed -i.bak "s/DISPATCH_BRANCH=.*/DISPATCH_BRANCH=${NEW_BRANCH}/" "${SCRIPT_DIR}/.env" rm -f "${SCRIPT_DIR}/.env.bak" else echo "DISPATCH_BRANCH=${NEW_BRANCH}" >> "${SCRIPT_DIR}/.env" fi # Start PostgreSQL for migrations echo "Starting PostgreSQL..." if ! pg_isready -h 127.0.0.1 >/dev/null 2>&1; then pg_ctl -D "${PGDATA}" -l "${LOGS_DIR}/postgresql.log" start sleep 2 fi # Run migrations with database running echo "Running migrations..." source env/bin/activate python manage.py migrate --noinput python manage.py collectstatic --noinput --clear deactivate "${SCRIPT_DIR}/dispatcharr.sh" start echo "✓ Switched to branch: ${NEW_BRANCH}" ;; version) echo "Dispatcharr Installation Info:" echo "" if [ -f "${SCRIPT_DIR}/VERSIONS.txt" ]; then cat "${SCRIPT_DIR}/VERSIONS.txt" else echo "Version info not found" fi echo "" echo "Current branch: $(cd "${APP_DIR}" && git rev-parse --abbrev-ref HEAD)" echo "Current commit: $(cd "${APP_DIR}" && git rev-parse --short HEAD)" echo "Last update: $(cd "${APP_DIR}" && git log -1 --format=%cd --date=short)" ;; *) echo "Dispatcharr Portable Launcher" echo "" echo "Usage: ${BASH_SOURCE[0]} {start|stop|restart|status|logs|update|switch-branch|version}" echo "" echo " start - Start all services" echo " stop - Stop all services" echo " restart - Restart all services" echo " status - Show service status" echo " logs - Tail all logs" echo " update - Update to latest on current branch" echo " switch-branch - Switch to a different branch" echo " version - Show version and branch info" exit 1 ;; esac LAUNCHER_EOF chmod +x "${INSTALL_DIR}/dispatcharr.sh" # Create environment file cat > "${INSTALL_DIR}/.env" < "${INSTALL_DIR}/UNINSTALL.sh" <<'UNINSTALL_EOF' #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" echo "========================================" echo "Dispatcharr Portable Uninstaller" echo "========================================" echo "" echo "This will:" echo " 1. Stop all running services" echo " 2. Delete everything in: ${SCRIPT_DIR}" echo "" echo "⚠️ WARNING: This will delete all your data!" echo "" echo "Type 'DELETE EVERYTHING' to confirm:" read confirmation if [ "$confirmation" != "DELETE EVERYTHING" ]; then echo "Uninstall cancelled" exit 1 fi # Stop services echo "Stopping services..." "${SCRIPT_DIR}/dispatcharr.sh" stop 2>/dev/null || true # Delete everything echo "Deleting ${SCRIPT_DIR}..." cd / rm -rf "${SCRIPT_DIR}" echo "✓ Dispatcharr completely removed (no trace left)" UNINSTALL_EOF chmod +x "${INSTALL_DIR}/UNINSTALL.sh" } ############################################################################## # Create README ############################################################################## create_readme() { cat > "${INSTALL_DIR}/README.txt" <<'README_EOF' DISPATCHARR PORTABLE INSTALLATION ================================== This is a completely self-contained installation of Dispatcharr. Everything is in this directory - no system files modified. USAGE ----- Start: ./dispatcharr.sh start Stop: ./dispatcharr.sh stop Restart: ./dispatcharr.sh restart Status: ./dispatcharr.sh status Logs: ./dispatcharr.sh logs Update: ./dispatcharr.sh update Switch Branch: ./dispatcharr.sh switch-branch Version Info: ./dispatcharr.sh version ACCESSING DISPATCHARR --------------------- Once started, access at: http://localhost:9191 The server binds to all network interfaces (0.0.0.0), so you can also access it from other devices on your network using your Mac's IP address: http://:9191 To find your Mac's IP address, run: ipconfig getifaddr en0 UPDATES & BRANCHES ------------------ To update to latest on your current branch: ./dispatcharr.sh update To switch branches (e.g., stable <-> dev): ./dispatcharr.sh switch-branch dev ./dispatcharr.sh switch-branch main To check your current branch and versions: ./dispatcharr.sh version Available branches: main - Stable releases (recommended) dev - Development branch (latest features) DIRECTORY STRUCTURE ------------------- app/ - Dispatcharr application code (git repository) data/ - Your recordings, logos, uploads, etc. deps/ - All dependencies (Python, PostgreSQL, Redis, FFmpeg, Node) runtime/ - Runtime files (PIDs, sockets, PostgreSQL data) logs/ - All log files .env - Configuration (database credentials, current branch) VIDEOTOOLBOX (Hardware Acceleration) ------------------------------------ FFmpeg is built with VideoToolbox support for Apple Silicon. Hardware acceleration is automatically enabled. MOVING THIS INSTALLATION ------------------------- You can move this entire directory anywhere: 1. Stop services: ./dispatcharr.sh stop 2. Move the directory 3. Start services: ./dispatcharr.sh start BACKUP ------ To backup your data, simply copy the entire directory. Or just copy the 'data/' subdirectory for recordings/config. UNINSTALL --------- Run: ./UNINSTALL.sh This will stop all services and delete this entire directory. No traces will be left on your system. CREDENTIALS ----------- Database credentials are in: .env Keep this file secure! SUPPORT ------- This is an experimental installation method. For support, use Docker (official method). README_EOF # Create version info file cat > "${INSTALL_DIR}/VERSIONS.txt" < VERSION_EOF echo "✓ README created" } ############################################################################## # Summary ############################################################################## show_summary() { clear cat </dev/null | cut -f1 || echo "~10-15GB") Branch: ${DISPATCH_BRANCH} Commit: $(cd "${APP_DIR}" && git rev-parse --short HEAD) ──────────────────────────────────────────────────────────────────────────── INSTALLED VERSIONS ──────────────────────────────────────────────────────────────────────────── Python: ${PYTHON_VERSION} PostgreSQL: ${POSTGRES_VERSION} Redis: ${REDIS_VERSION} Node.js: ${NODE_VERSION} FFmpeg: latest (with VideoToolbox) ──────────────────────────────────────────────────────────────────────────── QUICK START ──────────────────────────────────────────────────────────────────────────── 1. Start services: cd "${INSTALL_DIR}" ./dispatcharr.sh start 2. Open your browser: http://localhost:${HTTP_PORT} Architecture: nginx → uWSGI (HTTP/streaming) + Daphne (WebSockets) (Matches Docker setup exactly) ──────────────────────────────────────────────────────────────────────────── COMMANDS ──────────────────────────────────────────────────────────────────────────── Start: ./dispatcharr.sh start Stop: ./dispatcharr.sh stop Status: ./dispatcharr.sh status Logs: ./dispatcharr.sh logs Update: ./dispatcharr.sh update Switch Branch: ./dispatcharr.sh switch-branch Version Info: ./dispatcharr.sh version ──────────────────────────────────────────────────────────────────────────── FILES ──────────────────────────────────────────────────────────────────────────── README.txt - Quick reference guide VERSIONS.txt - Installed versions .env - Configuration & credentials UNINSTALL.sh - Complete removal script ──────────────────────────────────────────────────────────────────────────── FEATURES ──────────────────────────────────────────────────────────────────────────── ✓ Completely self-contained (no system modifications) ✓ VideoToolbox hardware acceleration enabled ✓ Can be moved anywhere on your system ✓ Delete directory to remove completely ✓ Zero traces left on system when uninstalled ──────────────────────────────────────────────────────────────────────────── Ready to start? Run: cd "${INSTALL_DIR}" && ./dispatcharr.sh start ╚════════════════════════════════════════════════════════════════════════════╝ EOF } ############################################################################## # Update Scripts Only ############################################################################## update_scripts() { local target_dir="${1:-}" if [ -z "$target_dir" ]; then echo "Usage: $0 --update-scripts " echo "" echo "Example: $0 --update-scripts ~/Dispatcharr" exit 1 fi if [ ! -d "$target_dir" ]; then echo "Error: Directory '$target_dir' does not exist" exit 1 fi if [ ! -f "$target_dir/.env" ]; then echo "Error: '$target_dir' does not appear to be a Dispatcharr installation" echo " (missing .env file)" exit 1 fi echo "╔════════════════════════════════════════════════════════════════════════════╗" echo "║ UPDATING DISPATCHARR LAUNCHER SCRIPTS ║" echo "╚════════════════════════════════════════════════════════════════════════════╝" echo "" echo "Target: $target_dir" echo "" # Set up variables for script generation INSTALL_DIR="$target_dir" APP_DIR="${INSTALL_DIR}/app" DATA_DIR="${INSTALL_DIR}/data" DEPS_DIR="${INSTALL_DIR}/deps" RUNTIME_DIR="${INSTALL_DIR}/runtime" LOGS_DIR="${INSTALL_DIR}/logs" # Verify critical directories exist if [ ! -d "$APP_DIR" ] || [ ! -d "$DEPS_DIR" ]; then echo "Error: Missing required directories in $INSTALL_DIR" exit 1 fi echo "→ Regenerating dispatcharr.sh..." if ! create_launcher_scripts; then echo "✗ Failed to create launcher scripts" exit 1 fi echo "✓ dispatcharr.sh updated" echo "" echo "→ Regenerating UNINSTALL.sh..." if ! create_uninstaller; then echo "✗ Failed to create uninstaller" exit 1 fi echo "✓ UNINSTALL.sh updated" echo "" echo "→ Regenerating README.txt..." if ! create_readme; then echo "✗ Failed to create README" exit 1 fi echo "✓ README.txt updated" echo "" echo "╔════════════════════════════════════════════════════════════════════════════╗" echo "║ SCRIPTS UPDATED SUCCESSFULLY ║" echo "╚════════════════════════════════════════════════════════════════════════════╝" echo "" echo "The following scripts have been updated:" echo " - dispatcharr.sh" echo " - UNINSTALL.sh" echo " - README.txt" echo "" } ############################################################################## # Main Installation Flow ############################################################################## main() { show_disclaimer select_branch configure_variables check_prerequisites create_directories # Clone the repository first to detect versions clone_dispatcharr # Always detect versions (even on resume, in case skipped clone) if [ -d "${APP_DIR}/.git" ]; then detect_versions "${APP_DIR}" fi install_python install_postgresql install_redis install_nginx install_node install_ffmpeg install_streamlink setup_python_env build_frontend init_postgresql start_services_background setup_database configure_whitenoise run_migrations create_uwsgi_config create_nginx_config # Stop services before creating launchers echo "" echo "═══════════════════════════════════════════════════════════" echo " Finalizing installation..." echo "═══════════════════════════════════════════════════════════" export PATH="${POSTGRES_PREFIX}/bin:${REDIS_PREFIX}/bin:$PATH" echo " Stopping PostgreSQL..." if "${POSTGRES_PREFIX}/bin/pg_ctl" -D "${RUNTIME_DIR}/postgres" status > /dev/null 2>&1; then "${POSTGRES_PREFIX}/bin/pg_ctl" -D "${RUNTIME_DIR}/postgres" stop -m fast > /dev/null 2>&1 || true fi echo " Stopping Redis..." if [ -f "${RUNTIME_DIR}/pids/redis.pid" ]; then kill $(cat "${RUNTIME_DIR}/pids/redis.pid") 2>/dev/null || true rm -f "${RUNTIME_DIR}/pids/redis.pid" fi echo " Creating launcher scripts..." if ! create_launcher_scripts; then echo "❌ Failed to create launcher scripts!" exit 1 fi echo " Creating uninstaller..." if ! create_uninstaller; then echo "❌ Failed to create uninstaller!" exit 1 fi echo " Creating documentation..." if ! create_readme; then echo "❌ Failed to create documentation!" exit 1 fi # Fix ownership if running with sudo if [ -n "$SUDO_USER" ]; then echo "Fixing file ownership (running with sudo)..." chown -R "$SUDO_USER:staff" "${INSTALL_DIR}" fi # Remove checkpoint file on successful completion rm -f "$CHECKPOINT_FILE" # Final step CURRENT_STEP=$TOTAL_STEPS show_summary } ############################################################################## # Script Entry Point ############################################################################## # Parse command line arguments if [ $# -gt 0 ]; then case "$1" in --update-scripts) update_scripts "${2:-}" exit 0 ;; --help|-h) echo "Dispatcharr Portable Installation for macOS (Apple Silicon)" echo "" echo "Usage:" echo " $0 Run full installation" echo " $0 --update-scripts Update launcher scripts in existing installation" echo " $0 --help Show this help message" echo "" echo "Examples:" echo " $0 # Fresh install" echo " $0 --update-scripts ~/Dispatcharr # Update scripts only" exit 0 ;; *) echo "Unknown option: $1" echo "Run '$0 --help' for usage information" exit 1 ;; esac fi main "$@"