Files
obs-config/scripts/deploy-rig.sh
2026-04-28 12:13:42 +02:00

640 lines
25 KiB
Bash
Executable File

#!/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 <repo> ~/.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=9
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 <<BANNER
╔═══════════════════════════════════════════════════════════════╗
║ ophi118 rig deployment ║
║ rig name : $RIG_NAME_TC
║ config : $OBS_DIR
║ mode : $( ((CHECK_ONLY)) && echo '--check (no writes)' || echo 'install' )
║ confirms : $( ((ASSUME_YES)) && echo 'auto-yes' || echo 'interactive' )
╚═══════════════════════════════════════════════════════════════╝
BANNER
# ────────────────────────────────────────────────────────────────────────────
# Phase 1 — system packages
# ────────────────────────────────────────────────────────────────────────────
step "System packages (pacman)"
PACMAN_PKGS=(
flatpak
python python-pip
pipewire pipewire-pulse pipewire-alsa wireplumber
ffmpeg
mpd mpc
avahi nss-mdns
fontconfig
)
if command -v pacman >/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/HDD/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 <path> <body>
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" <<MPDCONF
# ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh.
# Re-run the deploy script to regenerate, or hand-edit for local tweaks.
music_directory "~/HDD/Music"
playlist_directory "~/.config/mpd/playlists"
db_file "~/.config/mpd/database"
state_file "~/.config/mpd/state"
sticker_file "~/.config/mpd/sticker.sql"
log_file "syslog"
auto_update "yes"
restore_paused "yes"
filesystem_charset "UTF-8"
zeroconf_enabled "yes"
zeroconf_name "MPD on $RIG_NAME_TC"
audio_output {
type "pipewire"
target "mpd_stream"
name "MPD"
}
MPDCONF
}
if [[ -f "$MPD_CONF" ]]; then
if grep -q "MPD on $RIG_NAME_TC" "$MPD_CONF"; then
ok "$MPD_CONF already configured for $RIG_NAME_TC"
else
warn "$MPD_CONF exists but zeroconf_name doesn't match $RIG_NAME_TC"
if confirm "overwrite $MPD_CONF?" && would "rewrite $MPD_CONF"; then
cp "$MPD_CONF" "$MPD_CONF.before-deploy.$$"
write_mpd_conf
ok "rewrote $MPD_CONF (backup: $MPD_CONF.before-deploy.$$)"
fi
fi
else
if would "write $MPD_CONF"; then
write_mpd_conf
ok "wrote $MPD_CONF"
fi
fi
if systemctl --user is-enabled mpd.socket >/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/HDD/Music" ]] && [[ -n "$(find "$HOME/HDD/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 "~/HDD/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/vendor/obs-config.js" ]]; then
ok "vendor/obs-config.js present"
else
if would "bash scripts/setup.sh"; then
bash "$DIR/setup.sh" || warn "setup.sh failed — re-run after OBS WS port is reachable"
fi
fi
fi
# ────────────────────────────────────────────────────────────────────────────
# Phase 9 — 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 "scripts/telemetry.sh --collect --no-review (rig snapshot)"; then
bash "$DIR/telemetry.sh" --collect --no-review || \
warn "telemetry.sh --collect failed — re-run interactively to review"
ok "telemetry refreshed (rig=$RIG_NAME_TC)"
fi
fi
cat <<DONE
╔═══════════════════════════════════════════════════════════════╗
║ Deployment complete — manual steps remaining: ║
╚═══════════════════════════════════════════════════════════════╝
1. Twitch bot token (chat:read chat:edit)
https://twitchtokengenerator.com
→ fill twitch-bot/.env
2. Broadcaster token (channel:manage:broadcast)
https://twitchtokengenerator.com
→ fill twitch-bot/.env.ophi118
3. Twitch stream key
Twitch Dashboard → Settings → Stream → Copy stream key
→ paste into OBS Settings → Stream
(saved to basic/profiles/ophi118/service.json)
4. Once .env is populated:
systemctl --user restart obs-twitch-bot.service
5. Review telemetry interactively:
bash scripts/telemetry.sh --collect
6. Launch OBS:
flatpak run com.obsproject.Studio
7. Route Discord audio to the Discord-Stream sink (one-time):
Open pavucontrol → Playback tab → while Discord plays sound,
click its dropdown and pick "Discord-Stream". PipeWire remembers
this across launches; the OBS "Discord" source picks up audio.
Sanity checks:
pactl list short sinks | grep -E 'mpd_stream|discord_stream'
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
mpc status
ss -tlnp | grep 4455
DONE