#!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' # Root check if [[ $EUID -ne 0 ]]; then echo "[ERROR] This script must be run as root." >&2 exit 1 fi trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR ############################################################################## # 0) Locales & Warning / Disclaimer ############################################################################## setup_locales() { echo ">>> Setting up locales..." apt-get update apt-get install -y locales sed -i '/en_US.UTF-8 UTF-8/s/^# //g' /etc/locale.gen locale-gen update-locale LANG=en_US.UTF-8 export LANG=en_US.UTF-8 export LC_ALL=en_US.UTF-8 } show_disclaimer() { echo "**************************************************************" echo "WARNING: While we do not anticipate any problems, we disclaim all" echo "responsibility for anything that happens to your machine." echo "" echo "This script is intended for **Debian-based operating systems only**." echo "Running it on other distributions WILL cause unexpected issues." echo "" echo "This script is **NOT RECOMMENDED** for use on your primary machine." echo "For safety and best results, we strongly advise running this inside a" echo "clean virtual machine (VM) or LXC container environment." echo "" echo "Additionally, there is NO SUPPORT for this method; Docker is the only" echo "officially supported way to run Dispatcharr." echo "**************************************************************" echo "" echo "If you wish to proceed, type \"I understand\" and press Enter." read user_input if [ "$user_input" != "I understand" ]; then echo "Exiting script..." exit 1 fi } ############################################################################## # 1) Configuration ############################################################################## configure_variables() { DISPATCH_USER="dispatcharr" DISPATCH_GROUP="dispatcharr" APP_DIR="/opt/dispatcharr" DISPATCH_BRANCH="main" POSTGRES_DB="dispatcharr" POSTGRES_USER="dispatch" POSTGRES_PASSWORD="secret" NGINX_HTTP_PORT="9191" WEBSOCKET_PORT="8001" GUNICORN_RUNTIME_DIR="dispatcharr" GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock" PYTHON_BIN=$(command -v python3) SYSTEMD_DIR="/etc/systemd/system" NGINX_SITE="/etc/nginx/sites-available/dispatcharr" } ############################################################################## # 2) Install System Packages ############################################################################## install_packages() { echo ">>> Installing system packages..." apt-get update declare -a packages=( git curl wget build-essential gcc libpq-dev python3-dev python3-venv python3-pip nginx redis-server postgresql postgresql-contrib ffmpeg procps streamlink sudo ) apt-get install -y --no-install-recommends "${packages[@]}" if ! command -v node >/dev/null 2>&1; then echo ">>> Installing Node.js..." curl -sL https://deb.nodesource.com/setup_24.x | bash - apt-get install -y nodejs fi systemctl enable --now postgresql redis-server } ############################################################################## # 3) Create User/Group ############################################################################## create_dispatcharr_user() { if ! getent group "$DISPATCH_GROUP" >/dev/null; then groupadd "$DISPATCH_GROUP" fi if ! id -u "$DISPATCH_USER" >/dev/null; then useradd -m -g "$DISPATCH_GROUP" -s /bin/bash "$DISPATCH_USER" fi } ############################################################################## # 4) PostgreSQL Setup ############################################################################## setup_postgresql() { echo ">>> Checking PostgreSQL database and user..." db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'") if [[ "$db_exists" != "1" ]]; then echo ">>> Creating database '${POSTGRES_DB}' with UTF8 encoding..." sudo -u postgres createdb -E UTF8 "$POSTGRES_DB" else echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation." fi user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'") if [[ "$user_exists" != "1" ]]; then echo ">>> Creating user '${POSTGRES_USER}'..." sudo -u postgres psql -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';" else echo ">>> User '${POSTGRES_USER}' already exists, skipping creation." fi echo ">>> Granting privileges..." sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;" sudo -u postgres psql -c "ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;" sudo -u postgres psql -d "$POSTGRES_DB" -c "ALTER SCHEMA public OWNER TO $POSTGRES_USER;" } ############################################################################## # 5) Clone Dispatcharr Repository ############################################################################## clone_dispatcharr_repo() { echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..." if [ ! -d "$APP_DIR" ]; then mkdir -p "$APP_DIR" chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR" fi if [ -d "$APP_DIR/.git" ]; then echo ">>> Updating existing Dispatcharr repo..." su - "$DISPATCH_USER" <>> Cloning Dispatcharr repo into ${APP_DIR}..." rm -rf "$APP_DIR"/* chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR" su - "$DISPATCH_USER" -c "git clone -b $DISPATCH_BRANCH https://github.com/Dispatcharr/Dispatcharr.git $APP_DIR" fi } ############################################################################## # 6) Setup Python Environment ############################################################################## install_uv() { echo ">>> Installing UV package manager..." curl -LsSf https://astral.sh/uv/install.sh | sh export PATH="$HOME/.local/bin:$PATH" } setup_python_env() { echo ">>> Setting up Python virtual environment with UV..." su - "$DISPATCH_USER" </dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh rm -rf env $PYTHON_BIN -m venv env env/bin/python -m ensurepip --upgrade export UV_PROJECT_ENVIRONMENT="$APP_DIR/env" uv sync --no-dev env/bin/python -m pip install -q gunicorn EOSU ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg" } ############################################################################## # 6.1) Ensure Environment File ############################################################################## ensure_env_file() { echo ">>> Ensuring DJANGO_SECRET_KEY exists in ${APP_DIR}/.env..." su - "$DISPATCH_USER" <> .env fi EOSU } ############################################################################## # 7) Build Frontend ############################################################################## build_frontend() { echo ">>> Building frontend..." su - "$DISPATCH_USER" <>> Running Django migrations & collectstatic..." su - "$DISPATCH_USER" <>> Creating systemd service files..." # Gunicorn cat <${SYSTEMD_DIR}/dispatcharr.service [Unit] Description=Gunicorn for Dispatcharr After=network.target postgresql.service redis-server.service [Service] User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} RuntimeDirectory=${GUNICORN_RUNTIME_DIR} RuntimeDirectoryMode=0775 EnvironmentFile=/opt/dispatcharr/.env Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" Environment="POSTGRES_HOST=localhost" ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done' ExecStart=${APP_DIR}/env/bin/gunicorn \\ --workers=4 \\ --worker-class=gevent \\ --timeout=300 \\ --bind unix:${GUNICORN_SOCKET} \\ dispatcharr.wsgi:application Restart=always KillMode=mixed SyslogIdentifier=dispatcharr StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target EOF # Celery cat <${SYSTEMD_DIR}/dispatcharr-celery.service [Unit] Description=Celery Worker for Dispatcharr After=network.target redis-server.service Requires=dispatcharr.service [Service] User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} EnvironmentFile=/opt/dispatcharr/.env Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" Environment="POSTGRES_HOST=localhost" Environment="CELERY_BROKER_URL=redis://localhost:6379/0" ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr worker -l info Restart=always KillMode=mixed SyslogIdentifier=dispatcharr-celery StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target EOF # Celery Beat cat <${SYSTEMD_DIR}/dispatcharr-celerybeat.service [Unit] Description=Celery Beat Scheduler for Dispatcharr After=network.target redis-server.service Requires=dispatcharr.service [Service] User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} EnvironmentFile=/opt/dispatcharr/.env Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" Environment="POSTGRES_HOST=localhost" Environment="CELERY_BROKER_URL=redis://localhost:6379/0" ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr beat -l info Restart=always KillMode=mixed SyslogIdentifier=dispatcharr-celerybeat StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target EOF # Daphne cat <${SYSTEMD_DIR}/dispatcharr-daphne.service [Unit] Description=Daphne for Dispatcharr (ASGI/WebSockets) After=network.target Requires=dispatcharr.service [Service] User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} EnvironmentFile=/opt/dispatcharr/.env Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" Environment="POSTGRES_DB=${POSTGRES_DB}" Environment="POSTGRES_USER=${POSTGRES_USER}" Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" Environment="POSTGRES_HOST=localhost" ExecStart=${APP_DIR}/env/bin/daphne -b 0.0.0.0 -p ${WEBSOCKET_PORT} dispatcharr.asgi:application Restart=always KillMode=mixed SyslogIdentifier=dispatcharr-daphne StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target EOF echo ">>> Creating Nginx config..." cat </etc/nginx/sites-available/dispatcharr.conf server { listen ${NGINX_HTTP_PORT}; location / { include proxy_params; proxy_pass http://unix:${GUNICORN_SOCKET}; } location /static/ { alias ${APP_DIR}/static/; } location /assets/ { alias ${APP_DIR}/frontend/dist/assets/; } location /media/ { alias ${APP_DIR}/media/; } location /ws/ { proxy_pass http://127.0.0.1:${WEBSOCKET_PORT}; proxy_http_version 1.1; proxy_set_header Upgrade \$http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header X-Real-IP \$remote_addr; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header Host \$host; } } EOF ln -sf /etc/nginx/sites-available/dispatcharr.conf /etc/nginx/sites-enabled/dispatcharr.conf [ -f /etc/nginx/sites-enabled/default ] && rm /etc/nginx/sites-enabled/default nginx -t systemctl restart nginx systemctl enable nginx } ############################################################################## # 11) Start Services ############################################################################## start_services() { echo ">>> Enabling and starting services..." systemctl daemon-reexec systemctl daemon-reload systemctl enable --now dispatcharr dispatcharr-celery dispatcharr-celerybeat dispatcharr-daphne } ############################################################################## # 12) Summary ############################################################################## show_summary() { server_ip=$(ip route get 1 | awk '{print $7; exit}') cat <