1
0
This repository has been archived on 2026-02-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
dispatcharr-apple-silicon/macos_portable_install.sh
2025-11-03 16:45:15 -05:00

2639 lines
87 KiB
Bash
Executable File

#!/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" <<EOF
bind 127.0.0.1
port 6379
daemonize no
dir ${RUNTIME_DIR}
logfile ${LOGS_DIR}/redis.log
pidfile ${RUNTIME_DIR}/pids/redis.pid
EOF
# Cleanup
cd "${DEPS_DIR}"
rm -rf "redis-${REDIS_VERSION}"
mark_step_complete "install_redis"
print_step_complete "Redis ${REDIS_VERSION} installed"
}
##############################################################################
# Download and Install nginx
##############################################################################
install_nginx() {
if is_step_complete "install_nginx"; then
CURRENT_STEP=$((CURRENT_STEP + 1))
echo "✓ nginx already installed (skipping)"
return
fi
print_progress "Installing nginx..." "${DEPS_DIR}/nginx-build.log"
NGINX_VERSION="1.26.2"
NGINX_PREFIX="${DEPS_DIR}/nginx"
if [ -f "${NGINX_PREFIX}/sbin/nginx" ]; then
mark_step_complete "install_nginx"
print_step_complete "nginx already installed"
return
fi
cd "${DEPS_DIR}"
# Clean up any partial builds
echo "Cleaning up any previous build attempts..."
rm -rf "nginx-${NGINX_VERSION}" 2>/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" <<EOF
# Dispatcharr custom settings
listen_addresses = '127.0.0.1'
port = 5432
unix_socket_directories = '${RUNTIME_DIR}/sockets'
logging_collector = off
EOF
else
echo "Database cluster already exists and is valid, skipping initdb"
fi
mark_step_complete "init_postgresql"
print_step_complete "PostgreSQL initialized"
}
##############################################################################
# Start Services (Background)
##############################################################################
start_services_background() {
print_progress "Starting database services..."
export PATH="${POSTGRES_PREFIX}/bin:${REDIS_PREFIX}/bin:${PYTHON_PREFIX}/bin:$PATH"
export DYLD_LIBRARY_PATH="${PYTHON_PREFIX}/lib:${POSTGRES_PREFIX}/lib:${FFMPEG_PREFIX}/lib:${DYLD_LIBRARY_PATH:-}"
export PGDATA="${DATA_DIR}/db"
# Ensure logs directory has proper permissions
if [ -n "$SUDO_USER" ]; then
echo "Ensuring proper permissions for logs and runtime directories..."
chown -R "$SUDO_USER:staff" "${LOGS_DIR}" "${RUNTIME_DIR}"
fi
# Start PostgreSQL (as actual user if using sudo)
echo "Starting PostgreSQL..."
# Check if PostgreSQL is already running using pg_isready (more reliable)
if "${POSTGRES_PREFIX}/bin/pg_isready" -h 127.0.0.1 -q 2>/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 <<EOF
GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;
ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;
EOF
"${POSTGRES_PREFIX}/bin/psql" -h 127.0.0.1 -U "$ACTUAL_USER" -d "$POSTGRES_DB" <<EOF
GRANT ALL ON SCHEMA public TO $POSTGRES_USER;
EOF
mark_step_complete "setup_database"
print_step_complete "Database configured"
}
##############################################################################
# Configure WhiteNoise for Static Files
##############################################################################
configure_whitenoise() {
print_progress "Configuring WhiteNoise for static file serving..."
cd "${APP_DIR}"
# Add WhiteNoise to middleware after SecurityMiddleware
if ! grep -q "whitenoise.middleware.WhiteNoiseMiddleware" dispatcharr/settings.py; then
echo " Adding WhiteNoise middleware..."
sed -i '' '/django.middleware.security.SecurityMiddleware/a\
"whitenoise.middleware.WhiteNoiseMiddleware",
' dispatcharr/settings.py
fi
# Add WhiteNoise storage configuration if not already present
if ! grep -q "STATICFILES_STORAGE.*whitenoise" dispatcharr/settings.py; then
echo " Adding WhiteNoise storage configuration..."
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
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 <<MIGRATIONS_EOF
source env/bin/activate
export POSTGRES_DB="$POSTGRES_DB"
export POSTGRES_USER="$POSTGRES_USER"
export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"
export POSTGRES_HOST="127.0.0.1"
export REDIS_HOST="127.0.0.1"
export DISPATCHARR_PLUGINS_DIR="${DATA_DIR}/plugins"
echo "Applying database migrations..."
python manage.py migrate --noinput
echo "Collecting static files..."
python manage.py collectstatic --noinput --clear
deactivate
MIGRATIONS_EOF
else
source env/bin/activate
export POSTGRES_DB="$POSTGRES_DB"
export POSTGRES_USER="$POSTGRES_USER"
export POSTGRES_PASSWORD="$POSTGRES_PASSWORD"
export POSTGRES_HOST="127.0.0.1"
export REDIS_HOST="127.0.0.1"
export DISPATCHARR_PLUGINS_DIR="${DATA_DIR}/plugins"
echo "Applying database migrations..."
python manage.py migrate --noinput
echo "Collecting static files..."
python manage.py collectstatic --noinput --clear
deactivate
fi
mark_step_complete "run_migrations"
print_step_complete "Django setup complete"
}
##############################################################################
# Create uWSGI Configuration
##############################################################################
create_uwsgi_config() {
print_progress "Creating uWSGI configuration..."
cat > "${APP_DIR}/uwsgi.ini" <<UWSGI_EOF
[uwsgi]
# Core settings
chdir = ${APP_DIR}
module = dispatcharr.wsgi:application
home = ${APP_DIR}/env
master = true
env = DJANGO_SETTINGS_MODULE=dispatcharr.settings
socket = ${APP_DIR}/uwsgi.sock
chmod-socket = 777
vacuum = true
die-on-term = true
# Worker management
workers = 4
# Optimize for streaming - HTTP server on port 5656 for direct access
http = 127.0.0.1:5656
http-keepalive = 1
buffer-size = 65536
post-buffering = 4096
http-timeout = 600
lazy-apps = true
# Async mode (use gevent for high concurrency)
gevent = 400
# Performance tuning
thunder-lock = true
log-4xx = true
log-5xx = true
disable-logging = false
# Logging configuration
log-master = true
logformat-strftime = true
log-date = %%Y-%%m-%%d %%H:%%M:%%S
log-format = %%(ftime) [uwsgi] Worker ID: %%(wid) %%(method) %%(status) %%(uri) %%(msecs)ms
log-buffering = 1024
UWSGI_EOF
mark_step_complete "create_uwsgi_config"
print_step_complete "uWSGI configuration created"
}
##############################################################################
# Create nginx Configuration
##############################################################################
create_nginx_config() {
print_progress "Creating nginx configuration..."
NGINX_PREFIX="${DEPS_DIR}/nginx"
cat > "${NGINX_PREFIX}/conf/nginx.conf" <<NGINX_EOF
worker_processes auto;
daemon off;
pid ${RUNTIME_DIR}/pids/nginx.pid;
error_log ${LOGS_DIR}/nginx-error.log;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
access_log ${LOGS_DIR}/nginx-access.log;
sendfile on;
keepalive_timeout 65;
client_max_body_size 0;
upstream uwsgi_backend {
server unix:${APP_DIR}/uwsgi.sock;
}
upstream daphne_backend {
server 127.0.0.1:8001;
}
server {
listen ${HTTP_PORT:-9191};
server_name localhost;
proxy_connect_timeout 75;
proxy_send_timeout 300;
proxy_read_timeout 300;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host \$host:\$server_port;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Port \$server_port;
# Serve Django via uWSGI (includes static files via WhiteNoise)
location / {
include ${NGINX_PREFIX}/conf/uwsgi_params;
uwsgi_pass uwsgi_backend;
}
location /logos/ {
alias ${DATA_DIR}/logos/;
}
# Logo cache endpoints - use HTTP for better performance
location /api/logos/ {
proxy_pass http://127.0.0.1:5656;
}
location /api/channels/logos/ {
proxy_pass http://127.0.0.1:5656;
}
# Route HDHR request to Django
location /hdhr {
include ${NGINX_PREFIX}/conf/uwsgi_params;
uwsgi_pass uwsgi_backend;
}
# Serve FFmpeg streams efficiently via HTTP
location /output/stream/ {
proxy_pass http://127.0.0.1:5656;
proxy_buffering off;
proxy_set_header Connection keep-alive;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header Host \$host;
}
# WebSockets for real-time communication
location /ws/ {
proxy_pass http://daphne_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "Upgrade";
}
# Route TS proxy requests to uWSGI socket
location /proxy/ {
include ${NGINX_PREFIX}/conf/uwsgi_params;
uwsgi_pass uwsgi_backend;
}
}
}
NGINX_EOF
mark_step_complete "create_nginx_config"
print_step_complete "nginx configuration created"
}
##############################################################################
# Create Launcher Scripts
##############################################################################
create_launcher_scripts() {
# Main launcher
cat > "${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 <branch-name>"
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" <<ENV_EOF
# Dispatcharr Environment Configuration
# Generated: $(date)
POSTGRES_DB=$POSTGRES_DB
POSTGRES_USER=$POSTGRES_USER
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
REDIS_HOST=127.0.0.1
REDIS_DB=0
CELERY_BROKER_URL=redis://127.0.0.1:6379/0
HTTP_PORT=$HTTP_PORT
DISPATCH_BRANCH=$DISPATCH_BRANCH
ENV_EOF
chmod 600 "${INSTALL_DIR}/.env"
}
##############################################################################
# Create Uninstaller
##############################################################################
create_uninstaller() {
cat > "${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 <branch-name>
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://<your-mac-ip>: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
INSTALLED VERSIONS
==================
Installation Date: $(date)
Branch: ${DISPATCH_BRANCH}
Commit: $(cd "${APP_DIR}" && git rev-parse --short HEAD)
Dependencies:
Python: ${PYTHON_VERSION}
PostgreSQL: ${POSTGRES_VERSION}
Redis: ${REDIS_VERSION}
Node.js: ${NODE_VERSION}
FFmpeg: Built from source with VideoToolbox support
These versions were auto-detected from the Dispatcharr
Dockerfile and requirements to ensure compatibility.
To verify versions:
deps/python/bin/python3 --version
deps/postgresql/bin/postgres --version
deps/redis/bin/redis-server --version
deps/node/bin/node --version
deps/ffmpeg/bin/ffmpeg -version
To check current branch:
cd app && git branch
To update:
./dispatcharr.sh update
To switch branches:
./dispatcharr.sh switch-branch <branch-name>
VERSION_EOF
echo "✓ README created"
}
##############################################################################
# Summary
##############################################################################
show_summary() {
clear
cat <<EOF
╔════════════════════════════════════════════════════════════════════════════╗
║ DISPATCHARR PORTABLE INSTALLATION ║
╚════════════════════════════════════════════════════════════════════════════╝
✓ COMPLETE! ✓
────────────────────────────────────────────────────────────────────────────
INSTALLATION DETAILS
────────────────────────────────────────────────────────────────────────────
Directory: ${INSTALL_DIR}
Disk Usage: $(du -sh "${INSTALL_DIR}" 2>/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 <name>
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 <installation-directory>"
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 <dir> 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 "$@"