#!/usr/bin/env bash # scripts/deploy-rig.sh — bring up the ophi118 OBS rig from scratch. # Idempotent: re-running is safe, every phase checks before acting. # # Usage: # bash scripts/deploy-rig.sh # interactive, full install # bash scripts/deploy-rig.sh --yes # non-interactive, accept all prompts # bash scripts/deploy-rig.sh --check # validate state, write nothing # bash scripts/deploy-rig.sh --rig-name X # override hostname-derived rig name # # Run on a fresh Midgolem (or re-run on Ignia to verify). After: # git clone ~/.config/obs-studio # cd ~/.config/obs-studio # bash scripts/deploy-rig.sh # # What it covers: # 1. Arch system packages (pacman + AUR for sansation-font) # 2. Python user packages (python-mpd2, websockets) # 3. Flatpak OBS Studio + filesystems=host override # 4. Git submodules + asset directories under $HOME # 5. PipeWire null sinks (mpd_stream + discord_stream) + loopbacks # 6. MPD config (zeroconf_name = this rig) + system units # 7. Custom systemd --user units (bridge, bot) # 8. OBS first-launch to seed obs-websocket plugin config # 9. Secrets stub + manual next-steps report set -euo pipefail # ── globals ──────────────────────────────────────────────────────────────── DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OBS_DIR="$(cd "$DIR/.." && pwd)" RIG_NAME="${RIG_NAME:-$(hostname)}" CHECK_ONLY=0 ASSUME_YES=0 # ── arg parsing ──────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --check) CHECK_ONLY=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; --rig-name) RIG_NAME="${2:?--rig-name needs an argument}"; shift 2 ;; -h|--help) sed -n 's/^# \?//p' "$0" | head -28; exit 0 ;; *) echo "unknown flag: $1" >&2; exit 2 ;; esac done RIG_NAME_TC="${RIG_NAME^}" # title-case: ignia → Ignia # ── output helpers ───────────────────────────────────────────────────────── PHASE=0 TOTAL=10 step() { PHASE=$((PHASE+1)); printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$PHASE" "$TOTAL" "$*"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; } skip() { printf ' \033[90m·\033[0m %s\n' "$*"; } warn() { printf ' \033[33m!\033[0m %s\n' "$*" >&2; } err() { printf ' \033[31m✗\033[0m %s\n' "$*" >&2; } die() { err "$*"; exit 1; } confirm() { (( ASSUME_YES )) && return 0 local q="$1" read -rp " ? $q [y/N]: " a [[ "${a,,}" =~ ^(y|yes)$ ]] } would() { # In --check mode, prefix the message and skip the action. (( CHECK_ONLY )) && { skip "would: $*"; return 1; } return 0 } # ── pre-flight ───────────────────────────────────────────────────────────── [[ "$OBS_DIR" == "$HOME/.config/obs-studio" ]] \ || die "Run this from \$HOME/.config/obs-studio (current OBS_DIR=$OBS_DIR)" cat </dev/null; then MISSING=() for p in "${PACMAN_PKGS[@]}"; do pacman -Qq "$p" >/dev/null 2>&1 || MISSING+=("$p") done if (( ${#MISSING[@]} == 0 )); then ok "all base packages installed" elif would "sudo pacman -S --needed ${MISSING[*]}"; then sudo pacman -S --needed $( ((ASSUME_YES)) && echo --noconfirm ) "${MISSING[@]}" ok "installed ${#MISSING[@]} package(s)" fi # NVIDIA branch if lspci 2>/dev/null | grep -qi 'vga.*nvidia'; then NV_MISSING=() for p in nvidia nvidia-utils; do pacman -Qq "$p" >/dev/null 2>&1 || NV_MISSING+=("$p") done if (( ${#NV_MISSING[@]} == 0 )); then ok "NVIDIA driver present" elif would "sudo pacman -S --needed ${NV_MISSING[*]}"; then sudo pacman -S --needed $( ((ASSUME_YES)) && echo --noconfirm ) "${NV_MISSING[@]}" ok "installed NVIDIA driver" fi else skip "no NVIDIA GPU detected (skipping nvidia/nvidia-utils)" fi # AUR: sansation-font (used by the `info` text source on Game scene). if pacman -Qq sansation-font >/dev/null 2>&1 \ || fc-list 2>/dev/null | grep -qi 'sansation'; then ok "Sansation font present" else AUR_HELPER="" for h in paru yay; do command -v "$h" >/dev/null && { AUR_HELPER="$h"; break; }; done if [[ -z "$AUR_HELPER" ]]; then warn "Sansation font missing and no AUR helper (paru/yay) found" warn " → install manually from AUR or accept the system-default fallback" elif would "$AUR_HELPER -S sansation-font"; then "$AUR_HELPER" -S $( ((ASSUME_YES)) && echo --noconfirm ) sansation-font || \ warn "AUR install failed — install sansation-font manually if you want the Game scene 'info' text to render exactly" fi fi else warn "pacman not found — this script targets Arch. Skipping system-package phase." warn " Make sure these are installed by other means: ${PACMAN_PKGS[*]}" fi # ──────────────────────────────────────────────────────────────────────────── # Phase 2 — Python user packages # ──────────────────────────────────────────────────────────────────────────── step "Python user packages" PIP_PKGS=(python-mpd2 websockets) PY_MISSING=() for p in "${PIP_PKGS[@]}"; do python -c "import importlib, sys; importlib.import_module('${p//-/_}'.replace('python_mpd2','mpd'))" 2>/dev/null \ || PY_MISSING+=("$p") done if (( ${#PY_MISSING[@]} == 0 )); then ok "python-mpd2 and websockets importable" elif would "pip install --user ${PY_MISSING[*]}"; then python -m pip install --user --upgrade "${PY_MISSING[@]}" ok "installed ${PY_MISSING[*]}" fi # ──────────────────────────────────────────────────────────────────────────── # Phase 3 — OBS Flatpak # ──────────────────────────────────────────────────────────────────────────── step "OBS Studio (Flatpak)" if ! command -v flatpak >/dev/null; then die "flatpak not installed — phase 1 should have done this" fi if ! flatpak remotes --user 2>/dev/null | grep -q '^flathub' \ && ! flatpak remotes 2>/dev/null | grep -q '^flathub'; then if would "flatpak remote-add flathub"; then flatpak remote-add --if-not-exists --user flathub \ https://dl.flathub.org/repo/flathub.flatpakrepo ok "added Flathub remote" fi else ok "Flathub remote present" fi if flatpak info com.obsproject.Studio >/dev/null 2>&1; then ok "OBS Studio flatpak installed" else if would "flatpak install flathub com.obsproject.Studio"; then flatpak install $( ((ASSUME_YES)) && echo -y ) flathub com.obsproject.Studio ok "installed OBS Studio flatpak" fi fi if flatpak info --show-permissions com.obsproject.Studio 2>/dev/null \ | grep -q 'filesystems=.*host'; then ok "filesystems=host override present (config dir reads from ~/.config/)" else if would "flatpak override --user com.obsproject.Studio --filesystems=host"; then flatpak override --user com.obsproject.Studio --filesystems=host ok "added filesystems=host override" else warn "without filesystems=host, OBS will read from ~/.var/app/... not from this dir!" fi fi # ──────────────────────────────────────────────────────────────────────────── # Phase 4 — submodules + asset dirs # ──────────────────────────────────────────────────────────────────────────── step "Submodules + asset directories" if [[ -f "$OBS_DIR/.gitmodules" ]]; then if would "git submodule update --init --recursive"; then ( cd "$OBS_DIR" && git submodule update --init --recursive ) ok "submodules initialized" fi fi ASSET_DIRS=( "$HOME/Videos" "$HOME/Music" "$HOME/HDD/Images/Gifs" "$HOME/cloud.jakubzych.com/_img/logos" ) for d in "${ASSET_DIRS[@]}"; do if [[ -d "$d" ]]; then ok "exists: $d" elif would "mkdir -p $d"; then mkdir -p "$d" ok "created: $d" fi done ASSET_FILES=( "$HOME/HDD/Images/Gifs/stamd.png:Game-scene 'Starting Soon' image" "$HOME/cloud.jakubzych.com/_img/logos/dgw.png:Doomguard watermark" ) MISSING_ASSETS=0 for entry in "${ASSET_FILES[@]}"; do path="${entry%%:*}"; what="${entry##*:}" if [[ -f "$path" ]]; then ok "asset present: $path" else warn "missing asset: $path ($what)" MISSING_ASSETS=1 fi done (( MISSING_ASSETS )) && warn " → bring these over manually from Ignia; OBS will surface 'missing source' errors otherwise" # ──────────────────────────────────────────────────────────────────────────── # Phase 5 — PipeWire null sinks (mpd_stream + discord_stream) # ──────────────────────────────────────────────────────────────────────────── # Two isolated streams into OBS so Discord voice and MPD music don't mix # with each other — or with the rest of the desktop. Each gets its own # null sink + loopback (so you still hear them locally) + monitor source # that OBS captures via pulse_output_capture. step "PipeWire null sinks (mpd_stream + discord_stream)" PW_CONF_DIR="$HOME/.config/pipewire/pipewire-pulse.conf.d" mkdir -p "$PW_CONF_DIR" PW_CONF_CHANGED=0 write_pw_conf() { # write_pw_conf local path="$1"; local body="$2" if [[ -f "$path" ]]; then ok "$(basename "$path") already present" elif would "write $path"; then printf '%s' "$body" > "$path" ok "wrote $path" PW_CONF_CHANGED=1 fi } write_pw_conf "$PW_CONF_DIR/mpd-stream.conf" '# Persistent virtual sink for streaming MPD into OBS without mixing in # desktop/browser/Discord audio. # # MPD ──► null sink "mpd_stream" # │ # ├──► loopback ──► default sink (so you still hear it on speakers) # │ # └──► monitor ──► OBS "Audio Output Capture (PulseAudio) → # Monitor of MPD-Stream" # # Wired on the MPD side via target "mpd_stream" in ~/.config/mpd/mpd.conf. # Loaded automatically when pipewire-pulse starts. pulse.cmd = [ { cmd = "load-module" args = "module-null-sink sink_name=mpd_stream sink_properties=device.description=MPD-Stream" } { cmd = "load-module" args = "module-loopback source=mpd_stream.monitor latency_msec=50" } ] ' write_pw_conf "$PW_CONF_DIR/discord-stream.conf" '# Persistent virtual sink for capturing Discord audio in OBS without mixing # in the rest of the desktop (browser, MPD, system bleeps). # # Mirrors mpd-stream.conf — separate isolated channel for voice chat: # Discord ──► null sink "discord_stream" # │ # ├──► loopback ──► default sink (you still hear voice # │ chat on speakers/headset) # │ # └──► monitor ──► OBS "Audio Output Capture (PulseAudio) # → Monitor of Discord-Stream" # # Discord side: set the app'"'"'s audio output to "Discord-Stream" once via # pavucontrol (Playback tab). PipeWire remembers the routing across launches. # Discord has no equivalent of MPD'"'"'s `target` setting, so this is a # one-time per-rig manual step. # # Loaded automatically when pipewire-pulse starts. pulse.cmd = [ { cmd = "load-module" args = "module-null-sink sink_name=discord_stream sink_properties=device.description=Discord-Stream" } { cmd = "load-module" args = "module-loopback source=discord_stream.monitor latency_msec=50" } ] ' # Restart pipewire-pulse only if we wrote a new conf AND OBS is not running # (pipewire-pulse restart silently detaches OBS pulse_output_capture sources; # fix needs OBS process restart, not just a settings refresh). if (( PW_CONF_CHANGED )) && (( ! CHECK_ONLY )); then if flatpak ps --columns=application 2>/dev/null | grep -qx com.obsproject.Studio \ || pgrep -x obs >/dev/null 2>&1; then warn "OBS is running — skipping pipewire-pulse restart (would detach OBS audio sources)" warn " → close OBS, then: systemctl --user restart pipewire-pulse.service" warn " → or just relog; conf loads on next pipewire-pulse start either way" elif would "systemctl --user restart pipewire-pulse"; then systemctl --user restart pipewire-pulse.service 2>/dev/null || \ warn "could not restart pipewire-pulse (will load on next login anyway)" fi fi # Verify the sinks are actually loaded (only meaningful in install mode). if (( ! CHECK_ONLY )); then sleep 1 for sink in mpd_stream discord_stream; do if pactl list short sinks 2>/dev/null | grep -q "^[0-9]*[[:space:]]\+${sink}\b"; then ok "$sink sink loaded" else warn "$sink sink NOT visible to pactl — restart pipewire-pulse or relog and re-check" fi done fi # ──────────────────────────────────────────────────────────────────────────── # Phase 6 — MPD # ──────────────────────────────────────────────────────────────────────────── step "MPD config + service" MPD_DIR="$HOME/.config/mpd" MPD_CONF="$MPD_DIR/mpd.conf" write_mpd_conf() { mkdir -p "$MPD_DIR/playlists" cat > "$MPD_CONF" </dev/null 2>&1; then ok "mpd.socket enabled" else if would "systemctl --user enable --now mpd.socket mpd.service"; then systemctl --user enable --now mpd.socket mpd.service ok "enabled mpd.socket + mpd.service" fi fi if [[ -d "$HOME/Music" ]] && [[ -n "$(find "$HOME/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then if would "mpc update"; then mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)" ok "MPD library scan kicked off" fi else warn "~/Music is empty — MPD will have no tracks until you populate it" fi # Avahi for zeroconf advertisement (system service). if systemctl is-enabled avahi-daemon.service >/dev/null 2>&1; then ok "avahi-daemon enabled (system)" else warn "avahi-daemon not enabled — MPD zeroconf won't advertise. To fix:" warn " sudo systemctl enable --now avahi-daemon.service" fi # ──────────────────────────────────────────────────────────────────────────── # Phase 7 — custom systemd --user units # ──────────────────────────────────────────────────────────────────────────── step "Systemd --user units (bridge + bot)" UNIT_DIR="$HOME/.config/systemd/user" mkdir -p "$UNIT_DIR" write_unit() { local name body path name="$1" body="$2" path="$UNIT_DIR/$name" if [[ -f "$path" ]] && diff -q <(printf '%s' "$body") "$path" >/dev/null 2>&1; then ok "$name already up to date" elif would "write $path"; then printf '%s' "$body" > "$path" ok "wrote $path" fi } write_unit obs-mpd-bridge.service "[Unit] Description=MPD → OBS WebSocket state bridge Documentation=file:%h/.config/obs-studio/bridges/mpd-state.py After=mpd.service mpd.socket pipewire.service Wants=mpd.service [Service] Type=simple ExecStart=/usr/bin/python3 %h/.config/obs-studio/bridges/mpd-state.py Restart=on-failure RestartSec=2 [Install] WantedBy=default.target " write_unit obs-twitch-bot.service "[Unit] Description=Twitch chat → MPD control bot Documentation=file:%h/.config/obs-studio/twitch-bot/bot.py After=mpd.service mpd.socket network-online.target Wants=mpd.service network-online.target [Service] Type=simple ExecStart=/usr/bin/python3 %h/.config/obs-studio/twitch-bot/bot.py Restart=on-failure RestartSec=5 [Install] WantedBy=default.target " if would "systemctl --user daemon-reload"; then systemctl --user daemon-reload fi # bridge can start now (no secrets needed) if systemctl --user is-enabled obs-mpd-bridge.service >/dev/null 2>&1; then ok "obs-mpd-bridge.service enabled" else if would "systemctl --user enable --now obs-mpd-bridge.service"; then systemctl --user enable --now obs-mpd-bridge.service || \ warn "obs-mpd-bridge failed to start — needs vendor/obs-config.js (see phase 8)" fi fi # bot must NOT auto-start until .env is populated; just enable it if systemctl --user is-enabled obs-twitch-bot.service >/dev/null 2>&1; then ok "obs-twitch-bot.service enabled (start manually after .env is filled)" else if would "systemctl --user enable obs-twitch-bot.service"; then systemctl --user enable obs-twitch-bot.service ok "enabled obs-twitch-bot.service (NOT started — needs .env)" fi fi # ──────────────────────────────────────────────────────────────────────────── # Phase 8 — OBS first-launch + WebSocket bootstrap # ──────────────────────────────────────────────────────────────────────────── step "OBS first-launch + WebSocket bootstrap" WS_CFG="$OBS_DIR/plugin_config/obs-websocket/config.json" if [[ -f "$WS_CFG" ]]; then ok "obs-websocket config exists ($WS_CFG)" else if would "launch OBS once to seed plugin_config"; then echo " → launching OBS once to seed plugin configs." echo " Close the OBS window when it appears (or wait — script auto-stops it)." flatpak run com.obsproject.Studio >/dev/null 2>&1 & OBS_PID=$! for _ in $(seq 1 60); do [[ -f "$WS_CFG" ]] && break sleep 1 done kill "$OBS_PID" 2>/dev/null || true wait "$OBS_PID" 2>/dev/null || true if [[ -f "$WS_CFG" ]]; then ok "obs-websocket config seeded" else warn "config not seeded after 60s — start OBS manually once and re-run" fi fi fi if [[ -f "$WS_CFG" ]]; then if python3 -c "import json, sys; sys.exit(0 if json.load(open('$WS_CFG')).get('server_enabled') else 1)" 2>/dev/null; then ok "obs-websocket server_enabled = true" else if would "set server_enabled=true in $WS_CFG"; then python3 - "$WS_CFG" <<'PY' import json, sys p = sys.argv[1] d = json.load(open(p)) d['server_enabled'] = True json.dump(d, open(p, 'w'), indent=4) PY ok "set server_enabled=true (takes effect on next OBS launch)" fi fi if [[ -f "$OBS_DIR/webapp/public/js/obs-config.js" ]]; then ok "webapp/public/js/obs-config.js present" else if would "php artisan rig:setup"; then ( cd "$OBS_DIR/webapp" && php artisan rig:setup ) \ || warn "rig:setup failed — re-run after OBS WS port is reachable" fi fi fi # ──────────────────────────────────────────────────────────────────────────── # Phase 9 — webapp (Laravel) + supervisord # ──────────────────────────────────────────────────────────────────────────── step "Webapp (Laravel) + supervisord program" if [[ -d "$OBS_DIR/webapp" ]]; then if [[ -d "$OBS_DIR/webapp/vendor" ]] && [[ -f "$OBS_DIR/webapp/vendor/autoload.php" ]]; then ok "webapp/vendor present (composer install already ran)" else if would "composer install in webapp/"; then ( cd "$OBS_DIR/webapp" && composer install --no-dev --optimize-autoloader ) \ || warn "composer install failed — re-run manually if php/composer were just installed" ok "composer install complete" fi fi if [[ -f "$OBS_DIR/webapp/.env" ]]; then ok "webapp/.env present" elif [[ -f "$OBS_DIR/webapp/.env.example" ]]; then if would "cp .env.example .env + php artisan key:generate"; then cp "$OBS_DIR/webapp/.env.example" "$OBS_DIR/webapp/.env" ( cd "$OBS_DIR/webapp" && php artisan key:generate ) >/dev/null ok "seeded webapp/.env from .env.example" fi fi if [[ -f /etc/supervisor.d/obs-webapp.conf ]]; then ok "/etc/supervisor.d/obs-webapp.conf already installed" else if would "sudo install scripts/obs-webapp.supervisord.conf → /etc/supervisor.d/"; then sudo install -m 644 "$DIR/obs-webapp.supervisord.conf" /etc/supervisor.d/obs-webapp.conf ok "installed /etc/supervisor.d/obs-webapp.conf" fi fi if systemctl is-enabled supervisord >/dev/null 2>&1; then ok "supervisord enabled" else if would "sudo systemctl enable --now supervisord"; then sudo systemctl enable --now supervisord ok "enabled supervisord" fi fi if (( ! CHECK_ONLY )); then sudo supervisorctl reread >/dev/null 2>&1 || true sudo supervisorctl update >/dev/null 2>&1 || true if sudo supervisorctl status obs-webapp 2>/dev/null | grep -q RUNNING; then ok "obs-webapp program RUNNING" else if would "sudo supervisorctl start obs-webapp"; then sudo supervisorctl start obs-webapp 2>&1 | sed 's/^/ /' || \ warn "obs-webapp didn't start — check storage/logs/supervisord.err.log" fi fi fi if (( ! CHECK_ONLY )); then sleep 1 if curl -fsS -o /dev/null http://127.0.0.1:1118/landing; then ok "http://127.0.0.1:1118/landing responding" else warn "127.0.0.1:1118/landing not reachable yet — check obs-webapp status" fi fi else warn "webapp/ missing — clone the repo properly first" fi # ──────────────────────────────────────────────────────────────────────────── # Phase 10 — secrets stub + final report # ──────────────────────────────────────────────────────────────────────────── step "Secrets stub + final report" stub_env() { local target="$1" if [[ -f "$target" ]]; then ok "$target exists (leaving alone)" return fi if [[ -f "$OBS_DIR/twitch-bot/.env.example" ]] && would "stub $target from .env.example"; then cp "$OBS_DIR/twitch-bot/.env.example" "$target" chmod 600 "$target" ok "stubbed $target (chmod 600)" fi } stub_env "$OBS_DIR/twitch-bot/.env" stub_env "$OBS_DIR/twitch-bot/.env.ophi118" # Refresh telemetry so the rig name is correct on this machine. if (( ! CHECK_ONLY )); then if would "php artisan rig:telemetry --collect (rig snapshot)"; then ( cd "$OBS_DIR/webapp" && php artisan rig:telemetry --collect ) || \ warn "rig:telemetry failed — re-run interactively to review" ok "telemetry refreshed (rig=$RIG_NAME_TC)" fi fi cat <