Scripts, scenes, bridges, bots and more

This commit is contained in:
Jakub Zych
2026-04-26 23:08:48 +02:00
parent a34cd5fab7
commit fb5aaed9e7
32 changed files with 2985 additions and 4382 deletions

18
scripts/clean.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# scripts/clean.sh — audit which .webm sources in playlist/ already have a
# converted .m4a sibling (produced by scripts/convert.sh). Read-only; deletes
# nothing. Run from anywhere — operates on $OBS_DIR/playlist/.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
PLAYLIST="$OBS_DIR/playlist"
[[ -d "$PLAYLIST" ]] || { echo "$PLAYLIST does not exist" >&2; exit 1; }
cd "$PLAYLIST"
shopt -s nullglob
for f in *.webm; do
m="${f%.webm}.m4a"
[[ -f "$m" ]] && echo "OK $f$m" || echo "MISS $f (no m4a yet, keep!)"
done

80
scripts/convert.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# scripts/convert.sh — strip video tracks out of audio files in
# ~/.config/obs-studio/playlist/ and re-encode to AAC m4a in place.
#
# WHY: yt-dlp .webm downloads include 1080p video. Chromium's <audio>
# element decodes the video stream too (just doesn't render it) — pegs
# CPU and stalls playback mid-track. Audio-only m4a is ~95% smaller
# and decodes ~20× faster.
#
# ./convert.sh — convert all video-bearing files
# ./convert.sh --dry-run — show what would happen, do nothing
#
# DO NOT run this while OBS has a playlist track open (between streams
# is fine; mid-stream you'll lock the file ffmpeg is reading from).
#
# After conversion, re-index: ./playlist.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
AUDIO_DIR="$OBS_DIR/playlist"
DRY=0
[[ "${1:-}" == "--dry-run" ]] && DRY=1
if [[ ! -d "$AUDIO_DIR" ]]; then
echo "$AUDIO_DIR does not exist" >&2
exit 1
fi
if ! command -v ffprobe >/dev/null 2>&1 || ! command -v ffmpeg >/dev/null 2>&1; then
echo " ✗ ffmpeg / ffprobe required" >&2
exit 1
fi
shopt -s nullglob nocaseglob
# Files that contain a video stream (and aren't already m4a)
declare -a TODO=()
for f in "$AUDIO_DIR"/*.{webm,mp4,mkv,mov,avi}; do
[[ -f "$f" ]] || continue
# Skip yt-dlp intermediate files
case "$(basename "$f")" in
*.f[0-9]*.*|*.temp.*|*.part) continue ;;
esac
# Only consider files that actually contain a video stream
if ffprobe -v error -select_streams v -show_entries stream=codec_type \
-of csv=p=0 "$f" 2>/dev/null | grep -q video; then
TODO+=("$f")
fi
done
if [[ ${#TODO[@]} -eq 0 ]]; then
echo " ✓ nothing to convert (no video-bearing files in $AUDIO_DIR)"
exit 0
fi
echo "── Converting ${#TODO[@]} file(s) → AAC m4a ──"
for f in "${TODO[@]}"; do
base="${f%.*}"
out="$base.m4a"
if [[ -f "$out" ]]; then
echo " ⊘ skip (m4a exists): $(basename "$f")"
continue
fi
echo "$(basename "$f")"
if [[ $DRY -eq 1 ]]; then
echo " (dry-run: would write $(basename "$out") and remove the source)"
continue
fi
if ffmpeg -nostdin -loglevel error -i "$f" -vn -c:a aac -b:a 192k "$out"; then
rm -f "$f"
echo "${out##*/} ($(du -h "$out" | cut -f1))"
else
echo " ✗ ffmpeg failed; leaving $(basename "$f") in place" >&2
rm -f "$out"
fi
done
echo
echo " → next: ./playlist.sh (re-index for the new filenames)"

581
scripts/deploy-rig.sh Executable file
View File

@@ -0,0 +1,581 @@
#!/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 mpd_stream null sink + loopback
# 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 mpd_stream sink
# ────────────────────────────────────────────────────────────────────────────
step "PipeWire null sink (mpd_stream)"
PW_CONF_DIR="$HOME/.config/pipewire/pipewire-pulse.conf.d"
PW_CONF="$PW_CONF_DIR/mpd-stream.conf"
if [[ -f "$PW_CONF" ]]; then
ok "$PW_CONF already present"
else
if would "write $PW_CONF"; then
mkdir -p "$PW_CONF_DIR"
cat > "$PW_CONF" <<'PWCONF'
# 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"
}
]
PWCONF
ok "wrote $PW_CONF"
if 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
fi
# Verify the sink is actually loaded (only meaningful in install mode).
if (( ! CHECK_ONLY )); then
sleep 1
if pactl list short sinks 2>/dev/null | grep -q '^[0-9]*[[:space:]]\+mpd_stream\b'; then
ok "mpd_stream sink loaded"
else
warn "mpd_stream sink NOT visible to pactl — restart pipewire-pulse or relog and re-check"
fi
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
Sanity checks:
pactl list short sinks | grep mpd_stream
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
mpc status
ss -tlnp | grep 4455
DONE

172
scripts/loading.sh Executable file
View File

@@ -0,0 +1,172 @@
#!/usr/bin/env bash
# Interactive manifest builder for the Project Loading scene.
#
# ./loading.sh
#
# Walks you through 5 prompts, writes loading.json + loading.js, then
# refresh the Project Loading browser source in OBS. Re-running uses
# previous answers as defaults — press Enter to keep them.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
JSON="$OBS_DIR/loading/loading.json"
JS="$OBS_DIR/loading/loading.js"
# Reads a top-level field from the previous loading.json. Always exits 0;
# prints empty string if file/field is missing.
prev() {
[[ -f "$JSON" ]] || return 0
python3 - "$JSON" "$1" <<'PY' 2>/dev/null || true
import json, sys
try:
d = json.load(open(sys.argv[1]))
v = d.get(sys.argv[2])
if v is None: print('')
elif isinstance(v, bool): print('y' if v else 'n')
else: print(v)
except Exception:
pass
PY
}
ask() {
# ask "question" "default" → echoes user input or default if blank
local q="$1" def="${2:-}"
local prompt=" $q"
[[ -n "$def" ]] && prompt+=" [$def]"
prompt+=": "
local v
read -rp "$prompt" v
[[ -z "$v" && -n "$def" ]] && v="$def"
printf '%s' "$v"
}
ask_yn() {
local v; v=$(ask "$1" "${2:-y}")
case "${v,,}" in
y|yes|on|true|1) printf 'true' ;;
n|no|off|false|0) printf 'false' ;;
*) printf 'true' ;;
esac
}
ask_int() {
local v
while :; do
v=$(ask "$1" "${2:-}")
[[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
echo " ✗ must be a whole number" >&2
done
}
sanitize() {
printf '%s' "${1:-}" \
| tr -d '"\\' \
| tr '\n\r\t' ' ' \
| sed 's/ */ /g; s/^ //; s/ $//'
}
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
# ── Pull previous answers as defaults ───────────────
prev_game=$(prev game)
prev_game_id=$(prev gameId)
prev_subtitle=$(prev subtitle)
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
prev_camera=$(prev camera); [[ -z "$prev_camera" ]] && prev_camera="y"
prev_mic=$(prev microphone); [[ -z "$prev_mic" ]] && prev_mic="y"
echo "── Project Loading manifest ──"
echo " (press Enter to keep [defaults])"
echo
# Resolve Game via Twitch search → exact directory entry. The picker prints
# "<id>\t<name>" to stdout on success, or non-zero on no-match / cancel.
# Re-running and accepting the previous game unchanged reuses the cached id
# so we don't burn an API call to re-resolve a known answer.
SEARCH_GAME="$OBS_DIR/twitch-bot/search-game.py"
ENV_FILE="$OBS_DIR/twitch-bot/.env.ophi118"
have_twitch=0
[[ -x "$SEARCH_GAME" && -f "$ENV_FILE" ]] && have_twitch=1
game=""; game_id=""
while :; do
query=$(ask "Game (search)" "$prev_game")
if [[ -z "$query" ]]; then
echo " ✗ Game is required" >&2
continue
fi
if (( have_twitch )); then
# Reuse cached id only if it looks like a real Twitch numeric id. A past
# bug (search-game.py prompt leaking into stdout) wrote "Pick: 506462"
# here; the regex makes sure such corruption falls back to a fresh search
# instead of getting passed straight to PATCH /helix/channels.
if [[ -n "$prev_game_id" && "$query" == "$prev_game" && "$prev_game_id" =~ ^[0-9]+$ ]]; then
game="$prev_game"; game_id="$prev_game_id"
echo " ↻ reusing cached: $game (id=$game_id)"
break
fi
if line=$("$SEARCH_GAME" "$query"); then
IFS=$'\t' read -r game_id game <<<"$line"
break
fi
# search-game.py already printed its own error; loop and re-ask
else
# no twitch helper available — accept the raw input, no id resolution
game="$query"; game_id=""
break
fi
done
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
countdown=$(ask_int "Countdown (minutes)" "$prev_count")
camera=$(ask_yn "Camera" "$prev_camera")
mic=$(ask_yn "Microphone" "$prev_mic")
game=$(sanitize "$game")
subtitle=$(sanitize "$subtitle")
now_iso=$(date -u +%FT%TZ)
tmp="$JSON.tmp.$$"
cat > "$tmp" <<JSON
{
"compiledAt": "$now_iso",
"game": $(emit_str "$game"),
"gameId": $(emit_str "$game_id"),
"subtitle": $(emit_str "$subtitle"),
"countdownMin": $countdown,
"camera": $camera,
"microphone": $mic
}
JSON
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
echo " ✗ produced invalid JSON, leaving $tmp for inspection" >&2
exit 1
fi
mv -f "$tmp" "$JSON"
{ printf 'window.__LOADING = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
mv -f "$JS.tmp.$$" "$JS"
echo
echo " ✓ wrote $JSON"
echo " ✓ wrote $JS"
# ── Push to Twitch (game + title) via the broadcaster token ─────────────
# Soft-fail: a Twitch hiccup must not block the local manifest from being
# written — the overlay can still load offline. set-channel.py reads
# ../twitch-bot/.env.ophi118 (channel:manage:broadcast scope required).
# We pass --game-id so set-channel.py skips its own (exact-match) lookup —
# the id is already resolved by the search step above.
SET_CHANNEL="$OBS_DIR/twitch-bot/set-channel.py"
if (( have_twitch )) && [[ -x "$SET_CHANNEL" && -n "$game_id" ]]; then
twitch_title="$game"
[[ -n "$subtitle" ]] && twitch_title="$game$subtitle"
echo
if ! "$SET_CHANNEL" --game-id "$game_id" "$twitch_title"; then
echo " ! Twitch sync failed (manifest still saved) — fix and re-run if needed" >&2
fi
fi
echo
echo " → refresh the Project Loading browser source in OBS."

185
scripts/playlist.sh Executable file
View File

@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# scripts/playlist.sh — playlist sync + control API for the music daemon.
#
# Usage:
# ./playlist.sh → sync (default; index audio in ../playlist/)
# ./playlist.sh sync → explicit sync
# ./playlist.sh skip → next track
# ./playlist.sh prev → previous track
# ./playlist.sh pause → pause playback
# ./playlist.sh resume → resume playback
# ./playlist.sh status → show last queued command
# ./playlist.sh help → this help
#
# Control commands (skip/prev/pause/resume) write a tiny `cmd.js` that the
# Music Daemon polls every 250ms. The daemon picks up the new command,
# acts on it, and broadcasts the resulting state via OBS WebSocket so the
# loading overlay updates live.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
JSON="$OBS_DIR/loading/playlist.json"
JS="$OBS_DIR/loading/playlist.js"
CMD_JS="$OBS_DIR/loading/cmd.js"
# Audio roots — scanned recursively, in order. Add more here as the library grows.
# Missing roots are skipped with a warning, not an error.
ROOTS=(
"$OBS_DIR/playlist"
"$HOME/HDD/Music/Electronic/NCS Directory"
)
usage() {
sed -n 's/^# \?//p' "$0" | head -20
}
# ─────────────── sync ───────────────
cmd_sync() {
if ! command -v ffprobe >/dev/null 2>&1; then
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
exit 1
fi
echo "── Indexing audio roots ──"
for r in "${ROOTS[@]}"; do
if [[ -d "$r" ]]; then echo " + $r"; else echo " ! $r (missing — skipped)"; fi
done
local TMP="$JSON.tmp.$$"
python3 - "${ROOTS[@]}" "$TMP" <<'PY'
import json, subprocess, sys, os, datetime, re, urllib.parse
roots = sys.argv[1:-1]
out = sys.argv[-1]
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
def ffprobe_meta(path):
try:
r = subprocess.run(
['ffprobe', '-v', 'error',
'-show_entries', 'format=duration:format_tags=title,artist',
'-of', 'json', path],
capture_output=True, text=True, timeout=10,
)
if r.returncode != 0: return {}
fmt = (json.loads(r.stdout) or {}).get('format', {}) or {}
tags = {k.lower(): v for k, v in (fmt.get('tags') or {}).items()}
return {
'title': tags.get('title'),
'artist': tags.get('artist'),
'durationSec': float(fmt['duration']) if fmt.get('duration') else None,
}
except Exception:
return {}
def clean_title(t):
if not t: return t
# PRIMARY rule: keep only what's before the first vertical bar
# (fullwidth U+FF5C or ASCII |). Everything after is genre/label noise.
t = re.sub(r'\s*[|].*$', '', t)
# Strip trailing YouTube-ID brackets, e.g. " [-XxZTgMWKV0]"
t = re.sub(r'\s*\[[A-Za-z0-9_-]{11}\]\s*$', '', t)
# Strip "[NCS Release]" / "(NCS10 Release)" suffix variants
t = re.sub(r'\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$', '', t, flags=re.I)
return t.strip()
INTERMEDIATE = re.compile(r'\.(?:f\d+|temp|part)$', re.I)
def is_audio_file(name):
base, ext = os.path.splitext(name)
if ext.lower() not in exts: return False
if INTERMEDIATE.search(base): return False
return True
# Absolute file:// URI — works across roots since the daemon HTML and the audio
# may live on different filesystems. CEF in OBS browser source loads these
# directly thanks to the Flatpak's filesystems=host permission.
def file_uri(path):
return 'file://' + urllib.parse.quote(os.path.abspath(path), safe='/')
tracks = []
for root in roots:
if not os.path.isdir(root):
print(f' ! skipping missing root: {root}', file=sys.stderr)
continue
for dirpath, _, files in os.walk(root):
for fname in sorted(files):
if not is_audio_file(fname): continue
path = os.path.join(dirpath, fname)
meta = ffprobe_meta(path)
display = clean_title(os.path.splitext(fname)[0])
tracks.append({
'title': display,
'file': file_uri(path),
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
})
doc = {
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'sourceDirs': roots,
'trackCount': len(tracks),
'tracks': tracks,
}
with open(out, 'w') as f:
json.dump(doc, f, indent=2, ensure_ascii=False)
PY
if ! python3 -m json.tool "$TMP" >/dev/null 2>&1; then
echo " ✗ manifest invalid, leaving $TMP" >&2
exit 1
fi
mv -f "$TMP" "$JSON"
{ printf 'window.__PLAYLIST = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
mv -f "$JS.tmp.$$" "$JS"
local count
count=$(python3 -c "import json; print(json.load(open('$JSON'))['trackCount'])")
echo
echo " ✓ wrote $JSON ($count tracks)"
echo " ✓ wrote $JS"
echo " → refresh the Music Daemon browser source in OBS to reload the queue."
}
# ─────────────── control commands ───────────────
# Writes a unique-per-call cmd.js that the daemon polls. The id is
# nanosecond-precise so the daemon's "did this id change?" check always passes
# even on rapid-fire commands.
send_cmd() {
local type="$1"
local id ts
id=$(date +%s%N)
ts=$(date +%s)
printf 'window.__CMD = {"id":%s,"type":"%s","ts":%s};\n' "$id" "$type" "$ts" > "$CMD_JS"
echo "${type} (id=$id)"
}
cmd_skip() { send_cmd skip; }
cmd_prev() { send_cmd prev; }
cmd_pause() { send_cmd pause; }
cmd_resume() { send_cmd resume; }
cmd_status() {
if [[ -f "$CMD_JS" ]]; then
echo "── last queued command ──"
cat "$CMD_JS"
else
echo " (no command file yet — daemon hasn't been signalled this session)"
fi
}
# ─────────────── dispatch ───────────────
case "${1:-sync}" in
sync) cmd_sync ;;
skip|next) cmd_skip ;;
prev|back) cmd_prev ;;
pause) cmd_pause ;;
resume|play) cmd_resume ;;
status) cmd_status ;;
help|-h|--help) usage ;;
*)
echo " ✗ unknown command: $1" >&2
usage
exit 1
;;
esac

57
scripts/setup.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# One-time setup: builds vendor/obs-config.js from your existing OBS WebSocket
# plugin config so the overlay/daemon pages can connect.
#
# bash scripts/setup.sh
#
# Re-run if you change the WebSocket port or password in OBS.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
SRC="$OBS_DIR/plugin_config/obs-websocket/config.json"
OUT="$OBS_DIR/vendor/obs-config.js"
if [[ ! -f "$SRC" ]]; then
echo "$SRC not found." >&2
echo " Open OBS once with the obs-websocket plugin loaded so it generates the config." >&2
exit 1
fi
# Pull port + password out of the JSON
read -r PORT PASS ENABLED <<<"$(python3 -c "
import json, sys
d = json.load(open('$SRC'))
print(d.get('server_port', 4455), d.get('server_password', ''), str(d.get('server_enabled', False)).lower())
")"
mkdir -p "$OBS_DIR/vendor"
cat > "$OUT" <<JS
// Auto-generated by scripts/setup.sh — gitignored. Reflects the current contents
// of plugin_config/obs-websocket/config.json. Re-run scripts/setup.sh if it changes.
window.__OBSWS = {
url: 'ws://localhost:$PORT',
password: '$PASS',
};
JS
echo " ✓ wrote $OUT"
echo " url = ws://localhost:$PORT"
if [[ -z "$PASS" ]]; then
echo " password = (none)"
else
echo " password = (set, ${#PASS} chars)"
fi
# Check the live socket, not the config file — OBS lags writes to plugin_config
# until shutdown, so server_enabled in JSON often disagrees with reality.
echo
if ss -lnt 2>/dev/null | grep -q ":$PORT "; then
echo " ✓ OBS WebSocket server is listening on :$PORT"
elif nc -z localhost "$PORT" 2>/dev/null; then
echo " ✓ OBS WebSocket server is listening on :$PORT"
else
echo " ⚠ Nothing listening on :$PORT yet."
echo " In OBS: Tools → WebSocket Server Settings → ✅ Enable WebSocket server → OK"
echo " (config.json may already say 'enabled: $ENABLED' — that file lags actual state)"
fi

336
scripts/telemetry.sh Executable file
View File

@@ -0,0 +1,336 @@
#!/usr/bin/env bash
# Static device + OBS config snapshot for the OBS Landing overlay.
# Output JSON also carries the `rig` field consumed by the Project Loading
# overlay's "detected rig :: …" line.
#
# Modes:
# ./telemetry.sh — wrap telemetry.json → telemetry.js
# (idempotent; preserves hand edits to JSON)
# ./telemetry.sh --collect — re-read hardware + OBS + hostname,
# interactively review every field, then wrap
# ./telemetry.sh --collect --no-review
# — collect, skip prompts (unattended)
#
# Use --collect after a hardware change, kernel update, or OBS settings change.
# For cosmetic tweaks just edit telemetry.json by hand and re-run with no args.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
JSON="$OBS_DIR/landing/telemetry.json"
JS="$OBS_DIR/landing/telemetry.js"
sanitize() {
printf '%s' "${1:-}" \
| tr -d '"\\' \
| tr '\n\r\t' ' ' \
| sed 's/ */ /g; s/^ //; s/ $//'
}
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
emit_num() { [[ -z "${1:-}" ]] && printf 'null' || printf '%s' "$1"; }
# ── INI reader (section-scoped) ─────────────────────
ini_get() {
# Usage: ini_get FILE SECTION KEY → prints value (empty if missing)
local file="$1" section="$2" key="$3"
[[ -f "$file" ]] || return 0
awk -v section="[$section]" -v key="$key" '
$0 == section { in_section = 1; next }
/^\[/ { in_section = 0; next }
in_section && index($0, key"=") == 1 {
sub("^[^=]*=", "")
print
exit
}
' "$file"
}
# Friendlier names for the encoder ids OBS stores in basic.ini
prettify_encoder() {
case "${1:-}" in
obs_x264|x264) echo "x264";;
jim_nvenc|ffmpeg_nvenc|obs_nvenc_h264_tex|obs_nvenc_h264_soft) echo "NVENC H.264";;
obs_nvenc_hevc_tex|obs_nvenc_hevc_soft) echo "NVENC HEVC";;
obs_nvenc_av1_tex|obs_nvenc_av1_soft) echo "NVENC AV1";;
obs_qsv11) echo "QSV";;
*) echo "${1:-}";;
esac
}
# Pull a top-level field out of a flat JSON object via python3.
json_field() {
local file="$1" field="$2"
[[ -f "$file" ]] || return 0
python3 - "$file" "$field" <<'PY' 2>/dev/null || true
import json, sys
try:
with open(sys.argv[1]) as f:
d = json.load(f)
v = d.get(sys.argv[2])
print('' if v is None else v)
except Exception:
pass
PY
}
# ── OBS config block ────────────────────────────────
collect_obs() {
local user_ini="$OBS_DIR/user.ini"
[[ -f "$user_ini" ]] || { echo "null"; return; }
local profile renderer
profile=$(ini_get "$user_ini" "Basic" "Profile")
renderer=$(ini_get "$user_ini" "Video" "Renderer")
[[ -z "$profile" ]] && profile="Untitled"
local profile_dir="$OBS_DIR/basic/profiles/$profile"
local ini="$profile_dir/basic.ini"
[[ -f "$ini" ]] || { echo "null"; return; }
local mode base_w base_h out_w out_h fps
mode=$(ini_get "$ini" "Output" "Mode")
base_w=$(ini_get "$ini" "Video" "BaseCX")
base_h=$(ini_get "$ini" "Video" "BaseCY")
out_w=$(ini_get "$ini" "Video" "OutputCX")
out_h=$(ini_get "$ini" "Video" "OutputCY")
fps=$(ini_get "$ini" "Video" "FPSInt")
local color_fmt color_space color_range
color_fmt=$(ini_get "$ini" "Video" "ColorFormat")
color_space=$(ini_get "$ini" "Video" "ColorSpace")
color_range=$(ini_get "$ini" "Video" "ColorRange")
local sample_rate ch_setup
sample_rate=$(ini_get "$ini" "Audio" "SampleRate")
ch_setup=$(ini_get "$ini" "Audio" "ChannelSetup")
# Stream encoder + bitrate. Path differs Simple vs Advanced.
local enc_raw="" bitrate="" rc="" keyint="" h264_profile=""
if [[ "$mode" == "Advanced" ]]; then
enc_raw=$(ini_get "$ini" "AdvOut" "Encoder")
local sej="$profile_dir/streamEncoder.json"
rc=$(json_field "$sej" "rate_control")
bitrate=$(json_field "$sej" "bitrate")
keyint=$(json_field "$sej" "keyint_sec")
h264_profile=$(json_field "$sej" "profile")
else
enc_raw=$(ini_get "$ini" "SimpleOutput" "StreamEncoder")
bitrate=$(ini_get "$ini" "SimpleOutput" "VBitrate")
fi
local enc_pretty
enc_pretty=$(prettify_encoder "$enc_raw")
profile=$(sanitize "$profile")
renderer=$(sanitize "$renderer")
mode=$(sanitize "$mode")
color_fmt=$(sanitize "$color_fmt")
color_space=$(sanitize "$color_space")
color_range=$(sanitize "$color_range")
ch_setup=$(sanitize "$ch_setup")
enc_pretty=$(sanitize "$enc_pretty")
rc=$(sanitize "$rc")
h264_profile=$(sanitize "$h264_profile")
cat <<JSON
{
"profile": $(emit_str "$profile"),
"renderer": $(emit_str "$renderer"),
"outputMode": $(emit_str "$mode"),
"canvas": { "w": $(emit_num "$base_w"), "h": $(emit_num "$base_h") },
"output": { "w": $(emit_num "$out_w"), "h": $(emit_num "$out_h") },
"fps": $(emit_num "$fps"),
"color": {
"format": $(emit_str "$color_fmt"),
"space": $(emit_str "$color_space"),
"range": $(emit_str "$color_range")
},
"stream": {
"encoder": $(emit_str "$enc_pretty"),
"rateControl": $(emit_str "$rc"),
"bitrateKbps": $(emit_num "$bitrate"),
"keyintSec": $(emit_num "$keyint"),
"profile": $(emit_str "$h264_profile")
},
"audio": {
"sampleRateHz": $(emit_num "$sample_rate"),
"channels": $(emit_str "$ch_setup")
}
}
JSON
}
# ── Hardware specs ──────────────────────────────────
collect() {
local cpu_model cpu_threads
cpu_model=$(awk -F': ' '/^model name/ {print $2; exit}' /proc/cpuinfo)
cpu_threads=$(nproc)
local mem_total_kb mem_total_g
mem_total_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
mem_total_g=$(awk -v k="$mem_total_kb" 'BEGIN {printf "%.1f", k/1024/1024}')
local kernel; kernel=$(uname -r)
# Rig identity — title-cased hostname (ignia → Ignia, midgolem → Midgolem).
# Consumed by the Project Loading overlay's "detected rig :: …" line.
local rig; rig=$(hostname)
rig="${rig^}"
local gpu_name="" gpu_vram_total_mb=""
if command -v nvidia-smi >/dev/null 2>&1; then
local q
q=$(nvidia-smi --query-gpu=name,memory.total \
--format=csv,noheader,nounits 2>/dev/null | head -n1 || true)
if [[ -n "$q" ]]; then
IFS=',' read -r gpu_name gpu_vram_total_mb <<<"$q"
gpu_name=$(sanitize "$gpu_name")
gpu_vram_total_mb=$(printf '%s' "$gpu_vram_total_mb" | tr -d ' ')
fi
fi
cpu_model=$(sanitize "$cpu_model")
kernel=$(sanitize "$kernel")
rig=$(sanitize "$rig")
local now_iso; now_iso=$(date -u +%FT%TZ)
local obs_block; obs_block=$(collect_obs)
local tmp="$JSON.tmp.$$"
cat > "$tmp" <<JSON
{
"collectedAt": "$now_iso",
"rig": $(emit_str "$rig"),
"cpu": {
"model": $(emit_str "$cpu_model"),
"threads": $(emit_num "$cpu_threads")
},
"mem": {
"totalGB": $(emit_num "$mem_total_g")
},
"host": {
"kernel": $(emit_str "$kernel")
},
"gpu": {
"name": $(emit_str "$gpu_name"),
"vramTotalMB": $(emit_num "$gpu_vram_total_mb")
},
"obs": $obs_block
}
JSON
# Validate before promoting — catches any malformed substitution.
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
echo "collect: produced invalid JSON, leaving $tmp for inspection" >&2
exit 1
fi
mv -f "$tmp" "$JSON"
echo "wrote $JSON"
}
# Walk every leaf in telemetry.json and prompt for a per-field override.
# Preserves JSON types (numbers stay numbers, strings stay strings, the
# literal word `null` becomes JSON null). Empty input keeps the current value.
# Skips `collectedAt` — it's auto-generated.
review() {
[[ -f "$JSON" ]] || { echo "review: $JSON not found" >&2; exit 1; }
echo
echo "── Review telemetry fields ─────────────────────────────"
echo " Press Enter to keep the value shown."
echo " Type a new value to override (use 'null' to clear)."
echo " Ctrl-C to abort without writing."
echo
local tmp="$JSON.review.$$"
if ! python3 - "$JSON" "$tmp" <<'PY'
import json, sys
src, dst = sys.argv[1], sys.argv[2]
with open(src) as f:
doc = json.load(f)
SKIP = {'collectedAt'}
def coerce(raw, original):
if raw == '':
return original
if raw.strip().lower() == 'null':
return None
if isinstance(original, bool):
return raw.strip().lower() in ('1', 'true', 'yes', 'y', 'on')
if isinstance(original, int) and not isinstance(original, bool):
try: return int(raw)
except: return raw
if isinstance(original, float):
try: return float(raw)
except: return raw
return raw
def walk(node, prefix=''):
if isinstance(node, dict):
for k in list(node.keys()):
path = f'{prefix}.{k}' if prefix else k
if k in SKIP:
continue
v = node[k]
if isinstance(v, dict):
walk(v, path)
else:
shown = 'null' if v is None else json.dumps(v, ensure_ascii=False)
try:
raw = input(f' {path:28s} = {shown:30s} override: ')
except EOFError:
raw = ''
node[k] = coerce(raw, v)
walk(doc)
with open(dst, 'w') as f:
json.dump(doc, f, indent=2, ensure_ascii=False)
f.write('\n')
PY
then
echo "review: aborted (or python error) — leaving $JSON unchanged" >&2
rm -f "$tmp"
exit 1
fi
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
echo "review: produced invalid JSON, leaving $tmp for inspection" >&2
exit 1
fi
mv -f "$tmp" "$JSON"
echo
echo " ✓ updated $JSON"
}
# Wraps telemetry.json as `window.__TEL = {...};` so index.html can load it
# via a <script> tag. fetch() on file:// is blocked in CEF; script-tag isn't.
wrap_js() {
if [[ ! -f "$JSON" ]]; then
echo "telemetry.json not found — run with --collect first" >&2
exit 1
fi
if ! python3 -m json.tool "$JSON" >/dev/null 2>&1; then
echo "telemetry.json is not valid JSON — fix it before re-wrapping" >&2
exit 1
fi
local tmp="$JS.tmp.$$"
{ printf 'window.__TEL = '; cat "$JSON"; printf ';\n'; } > "$tmp"
mv -f "$tmp" "$JS"
echo "wrote $JS"
}
# ── Argument parsing ────────────────────────────────
DO_COLLECT=0
DO_REVIEW=1
for arg in "$@"; do
case "$arg" in
--collect) DO_COLLECT=1 ;;
--no-review) DO_REVIEW=0 ;;
-h|--help) sed -n 's/^# \?//p' "$0" | head -16; exit 0 ;;
*) echo "unknown flag: $arg" >&2; exit 2 ;;
esac
done
if (( DO_COLLECT )); then
collect
(( DO_REVIEW )) && review
fi
wrap_js