webapp: archive pre-migration scene dirs + bash scripts, update docs

Moves the seven HTML scene dirs (landing/, loading/, game/, desktop/,
goodbye/, music-box/, music/) and the superseded bash helpers (setup.sh,
loading.sh, playlist.sh, telemetry.sh) into archived/ rather than deleting.
The Laravel webapp/ replaces all of them; archived/README.md spells out
the rollback procedure.

Also:
 - rewrite the relevant sections of CLAUDE.md so it points at webapp/
   blade views, the Artisan commands, and the supervisord lifecycle
   (`supervisorctl restart obs-webapp` after route / controller / .env
   changes; Blade view edits are still safe-while-running via OBS's
   Refresh cache).
 - extend scripts/deploy-rig.sh to install php + composer + supervisor,
   run `composer install`, copy obs-webapp.supervisord.conf into
   /etc/supervisor.d/, start the program, and call `php artisan
   rig:setup` + `rig:telemetry --collect` instead of the old bash.
 - .gitignore catches the generated machine-local files that came along
   when the old scene dirs moved (telemetry.js, loading.json, playlist.js,
   cmd.js, obs-config.js).
 - daemon.blade.php is now passive — listens to mpd:state but does not
   play audio or broadcast its own queue, so it stops fighting with
   bridges/mpd-state.py (the post-browser-daemon-migration source of
   truth for mpd:state).
 - nc.blade.php overrides .terminal { overflow: hidden } from hud.css
   so the `┤ TERMINAL ├` and `┤ NOW PLAYING ├` pane-tabs stick above
   the pane border instead of being clipped.
This commit is contained in:
Jakub Zych
2026-05-21 13:08:49 +02:00
parent 059f069ef4
commit 0b4f121a92
23 changed files with 229 additions and 156 deletions

View File

@@ -47,7 +47,7 @@ RIG_NAME_TC="${RIG_NAME^}" # title-case: ignia → Ignia
# ── output helpers ─────────────────────────────────────────────────────────
PHASE=0
TOTAL=9
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' "$*"; }
@@ -95,6 +95,8 @@ PACMAN_PKGS=(
mpd mpc
avahi nss-mdns
fontconfig
php composer
supervisor
)
if command -v pacman >/dev/null; then
@@ -558,17 +560,87 @@ PY
fi
fi
if [[ -f "$OBS_DIR/vendor/obs-config.js" ]]; then
ok "vendor/obs-config.js present"
if [[ -f "$OBS_DIR/webapp/public/js/obs-config.js" ]]; then
ok "webapp/public/js/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"
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 — secrets stub + final report
# 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"
@@ -590,9 +662,9 @@ 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"
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
@@ -620,7 +692,7 @@ cat <<DONE
systemctl --user restart obs-twitch-bot.service
5. Review telemetry interactively:
bash scripts/telemetry.sh --collect
cd webapp && php artisan rig:telemetry --collect
6. Launch OBS:
flatpak run com.obsproject.Studio
@@ -633,6 +705,8 @@ cat <<DONE
Sanity checks:
pactl list short sinks | grep -E 'mpd_stream|discord_stream'
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
sudo supervisorctl status obs-webapp
curl -fsS http://127.0.0.1:1118/landing | head -1
mpc status
ss -tlnp | grep 4455

View File

@@ -1,171 +0,0 @@
#!/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="${subtitle:-$game}"
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."

View File

@@ -1,185 +0,0 @@
#!/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

View File

@@ -1,57 +0,0 @@
#!/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

View File

@@ -1,336 +0,0 @@
#!/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