#!/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" UWSGI_RUNTIME_DIR="dispatcharr" UWSGI_SOCKET="/run/${UWSGI_RUNTIME_DIR}/dispatcharr.sock" SYSTEMD_DIR="/etc/systemd/system" } # Helper: pick the first candidate package that exists in apt repos pick_candidate() { local cand info candidate for cand in "$@"; do info=$(apt-cache policy "$cand" 2>/dev/null || true) candidate=$(printf '%s' "$info" | awk '/Candidate:/ {print $2; exit}') if [ -n "$candidate" ] && [ "$candidate" != "(none)" ]; then printf '%s' "$cand" return 0 fi done return 1 } # Resolve a list of package candidate entries into installable package names. # Each entry may contain alternatives separated by '|', e.g. 'libpcre3-dev|libpcre2-dev' resolve_packages() { local entry local alts local alt local resolved=() for entry in "$@"; do IFS='|' read -r -a alts <<<"$entry" alt=$(pick_candidate "${alts[@]}" 2>/dev/null || true) if [ -n "$alt" ]; then resolved+=("$alt") else echo "[WARN] No available candidate for package group: $entry" >&2 fi done printf '%s\n' "${resolved[@]}" } ############################################################################## # 2) Install System Packages ############################################################################## install_packages() { echo ">>> Installing system packages..." # Refresh package lists before probing availability apt-get update # Candidate package groups (use '|' to separate alternatives) package_candidates=( 'git' 'curl' 'wget' 'build-essential' 'gcc' 'libpq-dev' 'libpcre3-dev|libpcre2-dev|pcre3-dev' 'python3-dev|python3.13-dev' 'libssl-dev' 'pkg-config' 'nginx' 'redis-server' 'postgresql' 'postgresql-contrib' 'ffmpeg' 'procps' 'streamlink' 'sudo' ) # Resolve candidates to actual installable package names mapfile -t packages < <(resolve_packages "${package_candidates[@]}") if [ "${#packages[@]}" -eq 0 ]; then echo "[ERROR] No installable packages found. Aborting." >&2 exit 1 fi 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 ">>> Waiting for PostgreSQL to accept connections..." until pg_isready -h /var/run/postgresql >/dev/null 2>&1; do sleep 1 done 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 ############################################################################## setup_python_env() { echo ">>> Setting up Python virtual environment with UV (Python 3.13)..." su - "$DISPATCH_USER" </dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh rm -rf env # uv creates the venv with a managed Python 3.13 (auto-downloads if missing), # avoiding system Python version mismatches on Debian 12 / Ubuntu 24.04. uv venv --python 3.13 env export UV_PROJECT_ENVIRONMENT="$APP_DIR/env" uv sync --no-dev 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..." # uWSGI config cat <${APP_DIR}/uwsgi-debian.ini [uwsgi] chdir = ${APP_DIR} module = dispatcharr.wsgi:application virtualenv = ${APP_DIR}/env master = true workers = 4 socket = ${UWSGI_SOCKET} chmod-socket = 666 vacuum = true die-on-term = true gevent = 100 gevent-early-monkey-patch = true import = dispatcharr.gevent_patch lazy-apps = true buffer-size = 65536 socket-timeout = 600 thunder-lock = true EOF chown ${DISPATCH_USER}:${DISPATCH_GROUP} ${APP_DIR}/uwsgi-debian.ini # uWSGI cat <${SYSTEMD_DIR}/dispatcharr.service [Unit] Description=uWSGI for Dispatcharr After=network.target postgresql.service redis-server.service [Service] User=${DISPATCH_USER} Group=${DISPATCH_GROUP} WorkingDirectory=${APP_DIR} RuntimeDirectory=${UWSGI_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/uwsgi --ini ${APP_DIR}/uwsgi-debian.ini 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}; client_max_body_size 0; location / { include uwsgi_params; uwsgi_param HTTP_X_REAL_IP \$remote_addr; uwsgi_read_timeout 600; uwsgi_send_timeout 600; uwsgi_pass unix:${UWSGI_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 <