Scripts, scenes, bridges, bots and more
This commit is contained in:
18
scripts/clean.sh
Executable file
18
scripts/clean.sh
Executable 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
80
scripts/convert.sh
Executable 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
581
scripts/deploy-rig.sh
Executable 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
172
scripts/loading.sh
Executable 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
185
scripts/playlist.sh
Executable 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
57
scripts/setup.sh
Executable 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
336
scripts/telemetry.sh
Executable 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
|
||||
Reference in New Issue
Block a user