From 831b6978c47564b784da2f25f5ac01cbb1f824bc Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Mon, 3 Nov 2025 14:55:04 -0500 Subject: [PATCH] initial commit --- README.md | 641 +++++++++ macos_portable_install.sh | 2638 +++++++++++++++++++++++++++++++++++++ 2 files changed, 3279 insertions(+) create mode 100644 README.md create mode 100755 macos_portable_install.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..459ff94 --- /dev/null +++ b/README.md @@ -0,0 +1,641 @@ +# Dispatcharr Portable for macOS (Apple Silicon) + +--- + +## ⚠️ ⚠️ ⚠️ EXPERIMENTAL - NO OFFICIAL SUPPORT ⚠️ ⚠️ ⚠️ + +### 🚨 READ THIS BEFORE INSTALLING 🚨 + +**THIS IS AN UNSUPPORTED, EXPERIMENTAL INSTALLATION METHOD** + +- ❌ **NO SUPPORT** will be provided in Discord +- ❌ **NO SUPPORT** will be provided in GitHub Issues +- ❌ **NOT OFFICIALLY MAINTAINED** by the Dispatcharr team +- ❌ **USE AT YOUR OWN RISK** + +**The ONLY officially supported installation method is Docker.** + +If you use this portable installation: +- You are on your own for troubleshooting +- Do NOT ask for help in Discord or GitHub +- Do NOT open issues related to this installation method +- The maintainers reserve the right to close any support requests + +**If you need support, use the official Docker installation.** + +--- + +**A completely self-contained, leave-no-trace installation** + +This installation method creates a fully portable Dispatcharr instance that works like a Docker container but runs natively on macOS with Apple Silicon hardware acceleration. + +## 🎯 What Makes This Special + +### Zero System Impact +- ✅ **No Homebrew packages** installed globally +- ✅ **No system users** created +- ✅ **No system files** modified +- ✅ **No launchd/systemd** services +- ✅ **Everything in one directory** +- ✅ **Delete directory = complete removal** + +### Complete Portability +- Move the installation anywhere +- Copy to external drive +- Backup by copying directory +- Share with others +- Multiple installations possible + +### Hardware Acceleration +- Built-in VideoToolbox support +- Native Apple Silicon (ARM64) +- FFmpeg with hardware encoding/decoding +- PyTorch with MPS (Metal Performance Shaders) + +## 📋 Requirements + +- **macOS 12.0+** (Monterey or later) +- **Apple Silicon** (M1/M2/M3/M4) +- **Xcode Command Line Tools** +- **OpenSSL** (via Homebrew) +- **15GB+ free disk space** +- **8GB+ RAM** recommended + +## 🚀 Installation + +### Install Prerequisites + +```bash +# Install Xcode Command Line Tools +xcode-select --install + +# Install Homebrew (if not already installed) +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Install OpenSSL (required for Python SSL support) +brew install openssl@3 +``` + +**Note:** While we try to minimize system dependencies, OpenSSL is required for Python's SSL module. The installer will detect and use Homebrew's OpenSSL automatically. + +### Run the Installer + +```bash +# Download +curl -O https://git.seth.services/seth-public/dispatcharr-apple-silicon/raw/branch/main/macos_portable_install.sh + +# Make executable +chmod +x macos_portable_install.sh + +# Run (no sudo needed - builds from source with optimizations) +./macos_portable_install.sh + +# OR run with sudo to use prebuilt binaries where possible (faster) +sudo ./macos_portable_install.sh +``` + +**Running with sudo:** +- Uses prebuilt Python installer (saves ~3-5 minutes) +- Still uses optimized builds for FFmpeg (VideoToolbox) +- All files are still owned by your user (chown applied) + +**Running without sudo (recommended):** +- Builds Python from source (~3-5 minutes) +- No system modifications +- Truly portable installation + +The installer will: +1. Ask you to confirm understanding of experimental nature +2. **Ask which branch to install** (main/dev/custom) +3. Ask for installation directory (default: `~/Dispatcharr`) +4. **Show live progress bar** throughout the installation +5. Clone the Dispatcharr repository +6. **Auto-detect required versions** from Dockerfile and requirements +7. Download and build all dependencies (~15-20 minutes with fast builds) +8. Configure everything automatically +9. Create launcher scripts +10. **Display completion summary** with all details + +### Progress Tracking + +The installer features a **visual progress bar** that shows: +- Current step being executed +- Progress percentage +- Step number (e.g., "Step 5/15") + +The display updates in real-time and clears the screen to show only current progress, making it easy to track the installation without scrolling through logs. + +**Build logs** from compilation steps (Python, PostgreSQL, Redis, FFmpeg) are saved to `deps/*.log` files for troubleshooting if needed. + +**Example Progress Display:** +``` +╔════════════════════════════════════════════════════════════════════════════╗ +║ DISPATCHARR PORTABLE INSTALLATION ║ +╚════════════════════════════════════════════════════════════════════════════╝ + +Progress: [##################### ] 45% Step 7/15 + +──────────────────────────────────────────────────────────────────────────── +Current Step: Building FFmpeg with VideoToolbox (15-20 min)... +──────────────────────────────────────────────────────────────────────────── + + Compiling (this takes the longest)... +``` + +### Branch Selection + +During installation, you can choose: + +- **main** - Stable releases (recommended for production) +- **dev** - Development branch (latest features, may be unstable) +- **custom** - Enter any specific branch name + +Your chosen branch is saved and used for updates. + +### Automatic Version Detection + +The script automatically detects the correct versions to install by analyzing: +- `docker/DispatcharrBase` - Python, PostgreSQL, and Redis versions +- `docker/Dockerfile` - Node.js version +- `requirements.txt` - Python package dependencies + +This ensures your portable installation uses the **exact same versions** as the official Docker container, guaranteeing compatibility. + +### What Gets Installed + +Dependencies are downloaded using the fastest method available: + +- **Python** - Built from source (fast build, ~3-5 min) +- **PostgreSQL** - Prebuilt binary if available, otherwise compiled from source (~5-8 min) +- **Redis** - Built from source (quick 2-3 minute compile) +- **nginx** - Built from source (~2-3 minutes) +- **Node.js** - Official prebuilt binary (instant) +- **FFmpeg** - Built from source with VideoToolbox hardware acceleration (~10-15 min) +- **PyTorch** - Official Apple Silicon build with Metal Performance Shaders (MPS) support + +**Why build some from source?** +- **Python**: Required for SSL support with portable OpenSSL +- **FFmpeg**: VideoToolbox support requires custom build flags +- **nginx**: Built with HTTP/2 and SSL support for optimal performance +- **PostgreSQL/Redis**: Prebuilt used when available to save time + +**Build Speed:** +- Uses all CPU cores for parallel compilation +- Python builds in ~3-5 minutes (skips PGO optimizations) +- Total installation: ~20-25 minutes on modern Apple Silicon + +**Architecture:** +The portable installation uses the **exact same architecture as Docker**: +- nginx as reverse proxy (routes traffic to uWSGI and Daphne) +- uWSGI for WSGI/HTTP requests (streaming optimized) +- Daphne for WebSocket connections +- This ensures identical behavior and compatibility + +**Hardware Acceleration:** +- FFmpeg uses VideoToolbox for video encoding/decoding +- PyTorch uses Metal Performance Shaders (MPS) for AI/ML tasks +- Both leverage Apple Silicon's specialized hardware + +## 📁 Directory Structure + +``` +~/Dispatcharr/ # Installation root +├── dispatcharr.sh # Main launcher script +├── UNINSTALL.sh # Complete removal script +├── README.txt # Quick reference +├── VERSIONS.txt # Installed versions info +├── .env # Configuration & credentials +│ +├── app/ # Dispatcharr application (git repo) +│ ├── manage.py +│ ├── env/ # Python virtual environment +│ └── frontend/dist/ # Built assets +│ +├── data/ # YOUR DATA (recordings, configs, etc.) +│ ├── db/ # PostgreSQL data directory +│ ├── recordings/ +│ ├── logos/ +│ ├── uploads/ +│ ├── m3us/ +│ ├── epgs/ +│ ├── plugins/ +│ ├── media/ +│ └── logo_cache/ +│ +├── deps/ # All dependencies (self-contained) +│ ├── python/ +│ ├── postgresql/ +│ ├── redis/ +│ ├── nginx/ +│ ├── ffmpeg/ +│ └── node/ +│ +├── runtime/ # Runtime files (PIDs, sockets) +│ ├── pids/ +│ └── sockets/ +│ +└── logs/ # All log files + ├── postgresql.log + ├── redis.log + ├── nginx-access.log + ├── nginx-error.log + ├── uwsgi.log + ├── daphne.log + ├── celery.log + └── celerybeat.log +``` + +## 🎮 Usage + +All management is done through the `dispatcharr.sh` script: + +```bash +cd ~/Dispatcharr + +# Start all services +./dispatcharr.sh start + +# Stop all services +./dispatcharr.sh stop + +# Restart everything +./dispatcharr.sh restart + +# Check service status +./dispatcharr.sh status + +# View live logs +./dispatcharr.sh logs + +# Update to latest on current branch +./dispatcharr.sh update + +# Switch to a different branch +./dispatcharr.sh switch-branch dev +./dispatcharr.sh switch-branch main + +# Show version and branch info +./dispatcharr.sh version +``` + +### Accessing the Web Interface + +After starting, open: **http://localhost:9191** + +The architecture mirrors Docker exactly: +- **nginx** (port 9191) - Reverse proxy for all traffic +- **uWSGI** (Unix socket + HTTP 5656) - Django WSGI app for HTTP/streaming +- **Daphne** (port 8001) - Django ASGI app for WebSockets (nginx proxies `/ws/` requests) + +All traffic goes through nginx on port 9191, which routes to the appropriate backend. + +## 🎬 VideoToolbox Hardware Acceleration + +FFmpeg is built with full VideoToolbox support for Apple Silicon. + +### Verify Support + +```bash +cd ~/Dispatcharr +./deps/ffmpeg/bin/ffmpeg -hwaccels +# Should show: videotoolbox +``` + +### FFmpeg Parameters for Dispatcharr + +Use these parameters in Dispatcharr's FFmpeg settings for hardware-accelerated encoding: + +**H.264 Hardware Encoding (Recommended):** +``` +-user_agent {userAgent} -i {streamUrl} -c:v h264_videotoolbox -b:v 5M -maxrate 5M -bufsize 10M -c:a copy -f mpegts pipe:1 +``` + +**HEVC/H.265 Hardware Encoding:** +``` +-user_agent {userAgent} -i {streamUrl} -c:v hevc_videotoolbox -b:v 5M -maxrate 5M -bufsize 10M -c:a copy -f mpegts pipe:1 +``` + +**With Hardware Decoding + Encoding:** +``` +-hwaccel videotoolbox -user_agent {userAgent} -i {streamUrl} -c:v h264_videotoolbox -b:v 5M -maxrate 5M -bufsize 10M -c:a copy -f mpegts pipe:1 +``` + +**Variable Quality (instead of constant bitrate):** +``` +-user_agent {userAgent} -i {streamUrl} -c:v h264_videotoolbox -q:v 65 -c:a copy -f mpegts pipe:1 +``` + +**With Specific Profile:** +``` +-user_agent {userAgent} -i {streamUrl} -c:v h264_videotoolbox -profile:v high -b:v 5M -maxrate 5M -bufsize 10M -c:a copy -f mpegts pipe:1 +``` + +**Realtime Mode (for live streams):** +``` +-user_agent {userAgent} -i {streamUrl} -c:v h264_videotoolbox -realtime 1 -b:v 5M -maxrate 5M -bufsize 10M -c:a copy -f mpegts pipe:1 +``` + +### Parameter Notes + +- `-b:v 5M -maxrate 5M -bufsize 10M` are **required** for stable encoding +- `-c:a copy` copies audio without re-encoding (saves CPU) +- `-f mpegts pipe:1` outputs MPEG-TS format to stdout +- `-q:v 65` uses variable quality (1-100, lower = better quality) +- `-profile:v high` sets H.264 profile (baseline, main, or high) +- `-realtime 1` optimizes for live streaming with lower latency + +## 🔧 Configuration + +### Check Installed Versions + +```bash +# View installed versions +cat ~/Dispatcharr/VERSIONS.txt + +# Or verify directly +~/Dispatcharr/deps/python/bin/python3 --version +~/Dispatcharr/deps/postgresql/bin/postgres --version +~/Dispatcharr/deps/redis/bin/redis-server --version +~/Dispatcharr/deps/node/bin/node --version +~/Dispatcharr/deps/ffmpeg/bin/ffmpeg -version +``` + +### Database Credentials + +Stored in `.env` file (automatically generated): + +```bash +cat ~/Dispatcharr/.env +``` + +### Modify Ports + +Edit `.env` and restart: + +```bash +nano ~/Dispatcharr/.env +# Change HTTP_PORT or WEBSOCKET_PORT +./dispatcharr.sh restart +``` + +### Django Settings + +For advanced configuration: + +```bash +nano ~/Dispatcharr/app/dispatcharr/settings.py +``` + +## 📊 Monitoring + +### View Logs + +```bash +# All logs (live) +./dispatcharr.sh logs + +# Specific service +tail -f ~/Dispatcharr/logs/nginx-access.log +tail -f ~/Dispatcharr/logs/uwsgi.log +tail -f ~/Dispatcharr/logs/celery.log +tail -f ~/Dispatcharr/logs/postgresql.log + +# Errors only +grep ERROR ~/Dispatcharr/logs/*.log +``` + +### Check Status + +```bash +./dispatcharr.sh status +``` + +Output shows running/stopped state of each service: +- PostgreSQL +- Redis +- nginx (reverse proxy) +- uWSGI (Django/WSGI) +- Daphne (WebSockets/ASGI) +- Celery worker (background tasks) +- Celery beat (scheduler) + +### Resource Usage + +```bash +# Find processes +ps aux | grep dispatcharr + +# Memory usage +cd ~/Dispatcharr +du -sh data/db # Database size +du -sh data/ # Your data size +``` + +## 🔄 Updates + +### Update on Current Branch + +```bash +cd ~/Dispatcharr +./dispatcharr.sh update +``` + +This will: +1. Stop all services +2. Pull latest code from your current branch +3. Update Python dependencies +4. Rebuild frontend +5. Run database migrations +6. Restart services + +### Switch Branches + +You can switch between branches (e.g., stable ↔ development): + +```bash +# Switch to development branch +./dispatcharr.sh switch-branch dev + +# Switch back to stable +./dispatcharr.sh switch-branch main + +# Switch to any custom branch +./dispatcharr.sh switch-branch feature/new-feature +``` + +Branch switching will: +1. Stop services +2. Checkout the new branch +3. Update all dependencies +4. Rebuild frontend +5. Run migrations +6. Update `.env` with new branch +7. Restart services + +### Check Current Version + +```bash +# Show detailed version info +./dispatcharr.sh version + +# Output shows: +# - Installed dependency versions +# - Current branch +# - Current commit +# - Last update date +``` + +### Update Dependency Versions + +If Dispatcharr updates its Python/Node/PostgreSQL requirements: + +**Option 1: Update in place (if minor version changes)** +```bash +./dispatcharr.sh update +``` + +**Option 2: Reinstall (if major version changes)** +```bash +# Backup your data +cp -r ~/Dispatcharr/data ~/Dispatcharr-data-backup + +# Stop and remove old installation +cd ~/Dispatcharr +./dispatcharr.sh stop +cd ~ +rm -rf ~/Dispatcharr + +# Reinstall with new versions +./macos_portable_install.sh +# Choose same branch + +# Restore data +cp -r ~/Dispatcharr-data-backup/* ~/Dispatcharr/data/ + +# Start services +cd ~/Dispatcharr +./dispatcharr.sh start +``` + +Your PostgreSQL database is preserved in the data directory! + +The entire installation is portable: + +```bash +# 1. Stop services +cd ~/Dispatcharr +./dispatcharr.sh stop + +# 2. Move the directory +mv ~/Dispatcharr /Volumes/External/Dispatcharr + +# 3. Start from new location +cd /Volumes/External/Dispatcharr +./dispatcharr.sh start +``` + +Works across: +- Different folders +- External drives +- Network shares +- Different Macs (same architecture) + +## 💾 Backup & Restore + +### Full Backup + +```bash +# Stop services +cd ~/Dispatcharr +./dispatcharr.sh stop + +# Backup everything +tar -czf dispatcharr-backup-$(date +%Y%m%d).tar.gz ~/Dispatcharr + +# Or just copy the directory +cp -r ~/Dispatcharr ~/Dispatcharr-backup +``` + +### Data-Only Backup + +```bash +# Just your recordings and configs +tar -czf dispatcharr-data-$(date +%Y%m%d).tar.gz ~/Dispatcharr/data +``` + +### Restore + +```bash +# Extract backup +tar -xzf dispatcharr-backup-20250103.tar.gz -C ~ + +# Start services +cd ~/Dispatcharr +./dispatcharr.sh start +``` + +## 🔄 Updates + +### Automatic Update + +```bash +cd ~/Dispatcharr +./dispatcharr.sh update +``` + +This will: +1. Stop services +2. Pull latest code +3. Update Python dependencies +4. Rebuild frontend +5. Run migrations +6. Restart services + +### Manual Update + +```bash +./dispatcharr.sh stop + +cd ~/Dispatcharr/app +git pull origin main + +source env/bin/activate +pip install -r requirements.txt + +cd frontend +npm install --legacy-peer-deps +npm run build + +cd ~/Dispatcharr/app +python manage.py migrate +python manage.py collectstatic --noinput + +cd ~/Dispatcharr +./dispatcharr.sh start +``` + +## 🗑️ Complete Removal + +### Using the Uninstaller + +```bash +cd ~/Dispatcharr +./UNINSTALL.sh +# Type: DELETE EVERYTHING +``` + +This will: +1. Stop all services +2. Delete the entire directory +3. Leave no traces + +### Manual Removal + +```bash +cd ~/Dispatcharr +./dispatcharr.sh stop +cd ~ +rm -rf ~/Dispatcharr +``` + +That's it! No system cleanup needed. diff --git a/macos_portable_install.sh b/macos_portable_install.sh new file mode 100755 index 0000000..8902d7e --- /dev/null +++ b/macos_portable_install.sh @@ -0,0 +1,2638 @@ +#!/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 "$@"