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

7
.gitignore vendored
View File

@@ -87,3 +87,10 @@ Thumbs.db
*~
.idea/
.vscode/
# ============================================================
# Python bytecode (bridges/, twitch-bot/)
# ============================================================
__pycache__/
*.pyc
*.pyo

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "station"]
path = station
url = git@github.com:golem15com/html-ophi118-rig.git

244
CLAUDE.md
View File

@@ -2,15 +2,19 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Current state (read this first)
This is the active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: five scenes (Landing, Project Loading, Game, Desktop, Good Bye), four custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, and a Twitch chat bot that controls MPD via `!skip`. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target). Bring up a new rig by running `bash scripts/deploy-rig.sh` after `git clone`. All `.sh` automation lives in `scripts/`; everything Python lives where it serves (`bridges/`, `twitch-bot/`).
## What this directory is
Active OBS Studio user config for the **Flatpak** install (`com.obsproject.Studio` from Flathub). Path: `~/.config/obs-studio/`.
**Why `~/.config/` and not `~/.var/app/com.obsproject.Studio/config/obs-studio/`?** The Flatpak ships with `filesystems=host` (full host access, see `flatpak info --show-permissions com.obsproject.Studio`). With that permission, Flatpak does **not** indirect XDG paths into the per-app private dir — OBS sees the real `$HOME/.config/` and uses `~/.config/obs-studio/` directly, like a native install would.
A ghost copy lives at `~/.var/app/com.obsproject.Studio/config/obs-studio/` — that's the path Flatpak *would* use if `filesystems=host` weren't set. **OBS does not read or write there.** Some files (older CLAUDE.md, an aborted 2026-04 source-id migration, a `landing/` asset duplicate) accumulated there from work that targeted the wrong path. Treat it as orphaned; don't edit it expecting OBS to pick changes up. The 2026-04 migration script described below ran against the ghost dir and left this active dir un-migrated until 2026-04-25 23:09.
A ghost copy lives at `~/.var/app/com.obsproject.Studio/config/obs-studio/` — that's the path Flatpak *would* use if `filesystems=host` weren't set. **OBS does not read or write there.** Some files (older CLAUDE.md, an aborted 2026-04 source-id migration, a `landing/` asset duplicate) accumulated there from work that targeted the wrong path. Treat it as orphaned; don't edit it expecting OBS to pick changes up.
The user works here to (a) edit scene collections directly as JSON and (b) build/iterate stream overlays as local HTML/CSS/JS rendered via the browser source.
The user works here to (a) edit scene collections directly as JSON, (b) build/iterate stream overlays as local HTML/CSS/JS rendered via the browser source, and (c) maintain the audio + chat-bot infrastructure that surrounds OBS.
## How OBS is launched
@@ -18,16 +22,175 @@ The user works here to (a) edit scene collections directly as JSON and (b) build
flatpak run com.obsproject.Studio
```
Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `pacman` but should be considered dead — `obs-studio` and `obs-pipewire-audio-capture-bin{,-debug}` are pending removal once the user is fully comfortable with Flatpak.
Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `pacman` but should be considered dead — `obs-studio` and `obs-pipewire-audio-capture-bin{,-debug}` are pending removal.
## Layout — what lives where
**OBS-owned (don't edit while OBS is running — see "OBS overwrites on exit"):**
- `user.ini` / `global.ini` — selects active scene collection and profile, plus window state. `user.ini` is the live one (currently collection `Default Stream HUD`, profile `ophi118`). Don't touch geometry/dock-state lines (huge base64 blobs OBS owns).
- `basic/scenes/<name>.json`**the scene collection**. One file = one full set of scenes + sources + transitions + audio routing. Active: `Default_Stream_HUD.json` (display name `Default Stream HUD`). Scenes inside: `Landing`, `Project Loading`, `Game`, `Desktop`, `Good Bye`.
- `basic/profiles/<name>/` — encoder, streaming service, recording settings (separate from scenes). Active profile: `ophi118`.
- `plugin_config/<plugin-id>/` — per-plugin settings. `obs-websocket/config.json` holds the WebSocket password — secret.
- `plugin_manager/modules.json` — third-party plugins OBS loads. Currently lists only `linux-pipewire-audio` (the bundled-into-flatpak version, same source ids as the AUR plugin it replaced).
- `logs/YYYY-MM-DD HH-MM-SS.txt` — one per launch. Most recent is the source of truth for "why did source X fail to load" or "what plugin crashed". `MaxLogs=10`.
- `profiler_data/` — perf traces. Ignore unless profiling.
- `.sentinel/run_<uuid>` — running-instance lockfile; safe to delete only if no OBS process is alive.
**Custom overlays (browser sources rendering local HTML):**
- `landing/index.html` — Fallout-style "PLEASE STAND BY" overlay. Self-contained: HUD + cyan ring + accent triangle + footer + animated static/scanlines/vignette. Reads `landing/telemetry.js` for the rig and hardware info shown on screen.
- `landing/static-hum.wav` — 12-second seamless CRT static loop, generated via `ffmpeg -filter_complex` (white+brown noise + 60Hz hum, filtered + crossfaded). Loaded by an `ffmpeg_source`.
- `landing/telemetry.json` / `telemetry.js` — hardware + OBS + rig snapshot. JSON is hand-editable (or interactively curated via `scripts/telemetry.sh --collect`), JS is the auto-generated `<script>`-injectable wrapper. The JSON's `rig` field is consumed by the Loading overlay too.
- `loading/index.html` — terminal-style Project Loading overlay. Reads `loading/loading.json` (manifest), `loading/playlist.json` (track count), `landing/telemetry.js` (rig name), and listens on OBS WS for `mpd:state` updates from the bridge.
- `loading/loading.json` / `loading.js` — per-session manifest (game, subtitle, countdown, camera/mic flags). Written by `scripts/loading.sh`.
- `loading/playlist.json` / `playlist.js` — flat library index built by `scripts/playlist.sh`.
- `loading/cmd.js` — legacy daemon command file (`skip`/`prev`/`pause`/`resume`), polled at 250 ms by `music/index.html`. Written by `scripts/playlist.sh skip` etc. Functionally superseded by direct MPD control (see Twitch bot) but still wired for browser-side commands.
- `game/index.html` — Game scene HUD. TRANSMISSION ID + SIGNAL + UTC clock (top), game/subtitle (top center), camera frame (right), status bar with mic + now-playing (bottom). Live-syncs the camera-frame transform via OBS WS (`SceneItemTransformChanged`). See "Game scene HUD — browser-resolution constraint" below.
- `goodbye/index.html` — sign-off overlay (Good Bye scene). Same terminal style as Loading; subscribes to `mpd:state`.
- `music/index.html` — shared "Music Daemon" browser source. Predates the MPD bridge; today serves mainly to receive `cmd.js` polling for legacy command paths.
- `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client (HMAC-SHA256 auth, CEF `file://` `crypto.subtle` fallback). Used by every overlay.
- `vendor/obs-config.js` — auto-generated WS connection config (URL + password) extracted by `scripts/setup.sh`. Gitignored.
**Audio + automation surfaces (no OBS involvement until events flow through the bus):**
- `bridges/mpd-state.py` — polls MPD at 1 Hz + on track change, broadcasts `mpd:state` CustomEvents to the OBS WebSocket bus. Run as `obs-mpd-bridge.service` (systemd --user).
- `twitch-bot/bot.py` — connects to Twitch IRC as `vault118` (separate from broadcaster `ophi118`), listens for `!skip`, calls MPD directly. Run as `obs-twitch-bot.service` (systemd --user).
- `twitch-bot/search-game.py`, `set-channel.py`, `_twitch.py` — Twitch Helix helpers used by `scripts/loading.sh` to resolve game IDs and update the channel title/category.
- `twitch-bot/.env`, `.env.ophi118` — secrets for the bot and broadcaster tokens. **Per-rig, regenerate on each machine, never copy.**
**Scripts (all `.sh` in one place):**
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig (Midgolem). 9 phases: pacman + pip + flatpak + submodules + PipeWire sink + MPD + systemd units + OBS first-launch + secrets stub. See file header.
- `scripts/setup.sh` — one-time generator for `vendor/obs-config.js` from the OBS WebSocket plugin config. Re-run after WS password/port changes.
- `scripts/telemetry.sh` — see file header. `--collect` re-reads hardware + OBS + hostname into `landing/telemetry.json`, walks every field interactively for review/override, then wraps to `telemetry.js`. Plain run just re-wraps.
- `scripts/loading.sh` — interactive `loading.json` builder with previous-answer defaults; pushes title/game to Twitch via the broadcaster token if available.
- `scripts/playlist.sh` — playlist sync (`scripts/playlist.sh sync` / default) and legacy daemon control (`skip`/`prev`/`pause`/`resume`/`status`).
- `scripts/convert.sh` — yt-dlp `.webm` → AAC `.m4a` re-encode in `playlist/`. Drop-in: chromium decodes `.webm` video tracks even when only the `<audio>` element renders, which pegs CPU mid-stream.
- `scripts/clean.sh` — read-only audit of `playlist/*.webm` vs sibling `.m4a` (which are converted, which still need conversion).
**Reference / data:**
- `playlist/` — local audio library indexed by `scripts/playlist.sh`. Gitignored. Big.
- `profile/Ignia Spec.md`, `Midgolem Spec.md` — hardware spec docs for both rigs.
- `station/` — git submodule (`html-ophi118-rig`) — the public rig-spec page.
- `.gitmodules` — submodule registry.
## Audio architecture
```
┌────────────┐
│ MPD │ user systemd: mpd.socket + mpd.service
└──────┬─────┘
│ PipeWire output: target = "mpd_stream"
┌────────────────────────┐
│ null sink "mpd_stream" │ defined in
└──────┬──────────┬──────┘ ~/.config/pipewire/
│ │ pipewire-pulse.conf.d/mpd-stream.conf
loopback │ │ monitor
(so you │ │ (isolated stream feed —
hear it) │ │ no Discord, no desktop bleed)
▼ ▼
default sink OBS pulse_output_capture
(speakers) "Audio Output Capture → Monitor of MPD-Stream"
Twitch
```
The dedicated null sink is the load-bearing piece: without it, OBS would either pick up *all* desktop audio (Discord, browser, system) or *none*. The loopback module mirrors the null sink to the user's actual default sink so MPD is still audible locally while OBS captures the isolated monitor.
If `pactl list short sinks | grep mpd_stream` returns nothing after login, `pipewire-pulse` didn't load the conf. Try `systemctl --user restart pipewire-pulse.service` and re-check.
## MPD setup
Config: `~/.config/mpd/mpd.conf`. Library: `~/HDD/Music`. Output target: `mpd_stream` (the null sink above). State files (`database`, `state`, `sticker.sql`, `playlists/`) live in `~/.config/mpd/`. Run via the system-provided units: `systemctl --user enable --now mpd.socket mpd.service`.
The `zeroconf_name` in `mpd.conf` is per-rig (`"MPD on Ignia"` / `"MPD on Midgolem"`) — `scripts/deploy-rig.sh` sets it to the local hostname when seeding the config.
`auto_update "yes"` means `inotify`-watch on the library — drop a file or `yt-dlp -P ~/HDD/Music/...` and MPD picks it up without `mpc update`.
## OBS WebSocket bus
Port 4455 (default). Password lives in `plugin_config/obs-websocket/config.json` (gitignored — secret). `scripts/setup.sh` extracts URL + password to `vendor/obs-config.js`, which every overlay loads as a `<script>` tag (no `fetch()` because CEF blocks it on `file://`).
Auth is HMAC-SHA256, handled by `vendor/obs-ws-mini.js`. The lib falls back to a pure-JS SHA-256 if `crypto.subtle` is unavailable (CEF on `file://` origins sometimes lacks it).
If `ss -tlnp | grep 4455` shows nothing, OBS's WS server is disabled — flip `Tools → WebSocket Server Settings → Enable` in the UI. The JSON file's `server_enabled` lags actual state until OBS shutdown, so trust the socket, not the file.
### Custom events on the bus
| Event | Publisher | Consumers |
|---|---|---|
| `mpd:state` | `bridges/mpd-state.py` (1 Hz + on track change) | `loading/index.html`, `game/index.html`, `goodbye/index.html` |
| `mpd:cmd` | `music/index.html` (legacy; reads `loading/cmd.js`) | self-handled inside the music daemon |
`mpd:state` payload: `{_type, index, total, title, file, currentTime, duration, paused}` — kept identical to the original pre-bridge daemon's broadcast so overlays didn't need to change.
## Systemd user units
Live in `~/.config/systemd/user/`. Two custom + two system-provided:
| Unit | Source | Purpose |
|---|---|---|
| `obs-mpd-bridge.service` | `bridges/mpd-state.py` | MPD → OBS WS state bridge |
| `obs-twitch-bot.service` | `twitch-bot/bot.py` | Twitch chat → MPD control |
| `mpd.service` + `mpd.socket` | system | MPD itself (socket-activated) |
Common ops:
```bash
systemctl --user status obs-mpd-bridge obs-twitch-bot mpd
systemctl --user restart obs-mpd-bridge
journalctl --user -u obs-twitch-bot -f
```
`scripts/deploy-rig.sh` ships the two custom units inline (heredoc) — single source of truth for unit content lives in the deploy script, not in this repo as a separate file.
## Twitch identity & secrets
| Account | Purpose | Token file | Scopes |
|---|---|---|---|
| `ophi118` (broadcaster) | Channel title/category updates by `set-channel.py` | `twitch-bot/.env.ophi118` | `channel:manage:broadcast` |
| `vault118` (bot) | Reads `!skip`, posts now-playing | `twitch-bot/.env` | `chat:read`, `chat:edit` |
| `ophi118` (stream key) | RTMP stream key | `basic/profiles/ophi118/service.json` | n/a |
All three are gitignored. **Do not copy between rigs** — regenerate via https://twitchtokengenerator.com (for OAuth tokens) and the Twitch Dashboard (for the stream key).
## External assets referenced by absolute path
These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fresh rig they need to exist or OBS will surface "missing source" errors:
| Path | Purpose |
|---|---|
| `/home/jin/HDD/Images/Gifs/stamd.png` | Game-scene "Starting Soon" image |
| `/home/jin/cloud.jakubzych.com/_img/logos/dgw.png` | Doomguard watermark (Game/Landing) |
| `/home/jin/HDD/Music/` | MPD library root |
| `/home/jin/Videos/` | Recording output |
| `/home/jin/.config/obs-studio/landing/static-hum.wav` | Landing audio |
`scripts/deploy-rig.sh` creates the directories and warns about missing image files; the user has to bring those over manually.
## Per-rig vs portable state
**Differs per rig** (don't copy from one to the other; let the deploy script or OBS itself regenerate):
- `global.ini` — ALSA device IDs, monitor IDs
- `~/.config/mpd/mpd.conf``zeroconf_name`
- `twitch-bot/.env*` — OAuth tokens
- `basic/profiles/ophi118/service.json` — stream key
- `plugin_config/obs-websocket/config.json` — WebSocket password
- `landing/telemetry.json``rig`, hardware specs, kernel
- `vendor/obs-config.js` — derived from WS password
**Identical across rigs** (commit and reuse):
- `basic/scenes/Default_Stream_HUD.json` — scenes + transitions + audio routing
- `basic/profiles/ophi118/{basic,streamEncoder,recordEncoder}.json` — encoder/output settings (minus `service.json`)
- All HTML overlays under `landing/`, `loading/`, `game/`, `goodbye/`, `music/`
- All Python in `bridges/`, `twitch-bot/`
- All scripts in `scripts/`
- `vendor/obs-ws-mini.js`
## Migration history (matters when reading old scene JSON or AI-written files)
- **Was:** native Arch `obs-studio` (no browser support) + AUR `obs-linuxbrowser-source` (id: `linuxbrowser-source`).
- **Is:** Flatpak `com.obsproject.Studio` with bundled CEF browser source (id: `browser_source`).
- **2026-04 attempt (misdirected):** A scripted migration rewrote four sources (`Tip4Stream Scena Domyślna`, `Twitch Viewers`, `StreamLabs Jar`, `Landing — Stand By (Browser)`) from `linuxbrowser-source``browser_source` — but ran against the **ghost** `~/.var/app/.../obs-studio/` directory. OBS never read those edits.
- **2026-04-25 23:09 (correct):** Same migration applied to the **active** `~/.config/obs-studio/basic/scenes/Untitled.json`. Backup at `basic/scenes/Untitled.json.pre-cef-migration`. Settings keys: `url`, `width`, `height`, `fps` map directly; CEF requires `fps_custom: true`, `reroute_audio: false`, `restart_when_active: false`, `shutdown: false`, `webpage_control_level: 1`. Stale `linuxbrowser.reloadpage` hotkey keys were also dropped.
- **2026-04-25:** migrated to Flatpak `com.obsproject.Studio` with bundled CEF browser source (id: `browser_source`). Settings keys: `url`, `width`, `height`, `fps` map directly; CEF requires `fps_custom: true`, `reroute_audio: false`, `restart_when_active: false`, `shutdown: false`, `webpage_control_level: 1`. Stale `linuxbrowser.reloadpage` hotkey keys dropped. Pre-migration backups have been deleted as of 2026-04-26.
- **2026-04-26:** music architecture moved from a browser-side daemon (`music/index.html`) to MPD + a Python state bridge (`bridges/mpd-state.py`). Twitch chat bot (`twitch-bot/bot.py`) added with `!skip → MPD` integration. `station/` submodule added for the public rig-spec page. Game scene HUD added with live OBS WS sync. All `.sh` scripts consolidated under `scripts/`.
- If you encounter `linuxbrowser-source` anywhere — in a backup, a doc, a paste — assume it's pre-migration and migrate the same way.
- Older backup of pre-rebuild scene JSON: `basic/scenes/Untitled.json.pre-landing-rebuild`.
## Critical gotcha: OBS overwrites on exit
@@ -37,27 +200,13 @@ OBS itself maintains `.bak` (previous save) and `.json.v1` (pre-version-migratio
## Flatpak permissions — what the sandbox allows
OBS flatpak ships with `filesystems=host` by default — full host access. Asset paths like `/home/jin/HDD/Images/Gifs/stamd.png` and `/home/jin/cloud.jakubzych.com/_img/logos/dgw.png` work without `flatpak override`. PipeWire audio works via `xdg-run/pipewire-0`. NVIDIA acceleration works via `org.freedesktop.Platform.GL.nvidia-595-58-03` (auto-installed). If you ever need to widen permissions: `flatpak override --user com.obsproject.Studio --<flag>=<value>`.
OBS flatpak ships with `filesystems=host` by default — full host access. Asset paths under `/home/jin/HDD/`, `/home/jin/cloud.jakubzych.com/`, etc. work without `flatpak override`. PipeWire audio works via `xdg-run/pipewire-0`. NVIDIA acceleration works via `org.freedesktop.Platform.GL.nvidia-*` (auto-installed). If you ever need to widen permissions: `flatpak override --user com.obsproject.Studio --<flag>=<value>`.
`filesystems=host` is also the reason this config lives at `~/.config/obs-studio/` instead of the per-app sandboxed path — see "What this directory is" above.
## Layout — what lives where
- `user.ini` / `global.ini` — selects active scene collection and profile, plus window state. Currently both `Untitled`. Don't touch geometry/dock-state lines (huge base64 blobs OBS owns).
- `basic/scenes/<name>.json`**the scene collection**. One file = one full set of scenes + sources + transitions + audio routing. Active: `Untitled.json`.
- `basic/profiles/<name>/` — encoder, streaming service, recording settings (separate from scenes).
- `plugin_config/<plugin-id>/` — per-plugin settings. `obs-websocket/config.json` holds the WebSocket password — secret.
- `plugin_manager/modules.json` — third-party plugins OBS loads. Currently lists only `linux-pipewire-audio` (the bundled-into-flatpak version, same source ids as the AUR plugin it replaced).
- `logs/YYYY-MM-DD HH-MM-SS.txt` — one per launch. Most recent is the source of truth for "why did source X fail to load" or "what plugin crashed". `MaxLogs=10`.
- `landing/` — overlays for the Landing scene (custom; not an OBS convention):
- `index.html` — Fallout-style "PLEASE STAND BY" rendered by browser source. Self-contained, no external deps. Edit, then click "Refresh cache" on the browser source to see changes.
- `static-hum.wav` — 12s seamless CRT static loop, generated via `ffmpeg -filter_complex` (anoisesrc white+brown+60Hz hum, filtered + crossfaded). Loaded by a Media source.
- `profiler_data/` — perf traces. Ignore unless profiling.
- `.sentinel/run_<uuid>` — running-instance lockfile; safe to delete only if no OBS process is alive.
## Scene collection JSON — structure to know before editing
Top-level keys in `basic/scenes/Untitled.json`:
Top-level keys in `basic/scenes/Default_Stream_HUD.json`:
- `name` — collection name (matches filename stem).
- `sources`**flat array of every source, scene, and group**. Scenes are entries here with `"id": "scene"`; their `settings.items[]` references child sources **by name string**, not uuid. Renaming a source means updating every scene that references it.
@@ -74,38 +223,59 @@ Source `id` values currently in use:
- `scene` — a scene
- `pipewire-screen-capture-source` — Wayland/PipeWire screen/window capture
- `pipewire-camera-source` — PipeWire camera
- `v4l2_input` — V4L2 webcam (legacy path)
- `xshm_input` — X11 screen capture (legacy path)
- `browser_source` — CEF browser (post-migration; was `linuxbrowser-source`)
- `browser_source` — CEF browser (post-migration)
- `image_source` — static image
- `text_ft2_source` — FreeType2 text
- `ffmpeg_source` — media file (audio/video) via ffmpeg
Active scenes: `Landing` (current), `Game`, `Desktop`.
- `pulse_output_capture` / `pulse_input_capture` — PulseAudio sink/source capture (used for `mpd_stream.monitor` and the mic)
When editing scene JSON: keep `uuid` values stable across edits, preserve 4-space indentation, and validate with `python3 -m json.tool` before re-launching OBS.
## Landing scene — current state
## Game scene HUD — browser-resolution constraint
Composition (full-bleed at 2560×1336):
The Game scene has a `HUD` browser source (`game/index.html`) stretched (`bounds_type: 1`) to fill the 2560×1336 canvas. **Its `width` / `height` settings must match the canvas (2560×1336)**, not 16:9 like the output resolution. If the browser viewport is 1920×1080, the canvas stretch is non-uniform (1.333× horizontal, 1.237× vertical), which distorts every CSS box relative to the camera's transform (camera is positioned in canvas coords directly, so a CSS-positioned frame around it looks visibly off — wider/squashed than the camera). Symptom: cyan "camera frame" doesn't align with the camera feed inside it. Fix: set browser source `width: 2560, height: 1336` so 1 CSS px = 1 canvas px, then position any frame using canvas coords.
1. `Landing — Stand By (Browser)` (`browser_source`) — points at `file:///home/jin/.config/obs-studio/landing/index.html`. Renders: top HUD (`TRANSMISSION OPHI-118 / SIGNAL bars / UTC clock`), centered cyan-ring + red-orange triangle mark, big `PLEASE STAND BY`, footer (`CH 118.0 MHz · NTSC · AUDIO ACTIVE`), animated static + scanlines + vignette + occasional flicker.
2. `Landing — Static Hum` (`ffmpeg_source`) — looped 12s WAV at volume 0.55.
Palette (in CSS vars at top of `landing/index.html`): `--bg #07080d`, `--hud #4fd2ff`, `--accent #e63a2e`, `--warn #ffd000` (matches the user's ophi118 Twitch identity).
To iterate: edit HTML → in OBS, right-click the browser source → "Refresh cache". No restart needed.
The Camera frame in `game/index.html` auto-syncs via OBS WebSocket: `GetSceneItemTransform` on connect + `SceneItemTransformChanged` event listener update four CSS vars (`--cam-x/y/w/h`). Move/resize the Camera item in OBS and the frame follows live. Assumes Camera item alignment 5 (top-left) — OBS default; if you change alignment, the math in `setCameraTransform()` needs adjusting.
## Working with this directory
- **Confirm OBS is using THIS dir, not the ghost** before assuming an edit will land. While OBS is running: `ls -la /proc/$(pgrep -fx obs)/cwd` and check `lsof -p $(pgrep -fx obs) | grep scenes`. Or compare mtimes after an OBS session — the file OBS rewrites is the live one.
- **Inspecting a scene collection:** `python3 -m json.tool basic/scenes/Untitled.json | less`. List scenes: `python3 -c "import json; d=json.load(open('basic/scenes/Untitled.json')); print([s['name'] for s in d['scene_order']])"`.
- **Inspecting a scene collection:** `python3 -m json.tool basic/scenes/Default_Stream_HUD.json | less`. List scenes: `python3 -c "import json; d=json.load(open('basic/scenes/Default_Stream_HUD.json')); print([s['name'] for s in d['scene_order']])"`.
- **Diffing after an OBS session:** OBS rewrites the file even when nothing visibly changed. Expect noisy diffs; focus on `sources[]` and `scene_order` for meaningful changes.
- **Backing up before edits:** copy the active `.json` somewhere outside this dir — OBS's own `.bak` will be overwritten on the next save.
- **Plugin development:** Flatpak OBS plugins live inside the runtime; you can't drop `.so` files into the host filesystem and have them load. Plugin extensions for the OBS flatpak come as `com.obsproject.Studio.Plugin.<name>` flatpak extensions (search Flathub). If a plugin only has source/AUR distribution, it won't work in Flatpak — that's the tradeoff of this distribution choice.
- **WebSocket control:** `plugin_config/obs-websocket/config.json` holds connection settings. Programmatic scene edits while OBS is running should go through obs-websocket — file edits get clobbered on save.
## One-liners
```bash
# Refresh a browser source after editing HTML
# In OBS: right-click the source → Refresh cache (no restart needed)
# Regenerate vendor/obs-config.js after WS password change
bash scripts/setup.sh
# Refresh telemetry (interactive — review every field)
bash scripts/telemetry.sh --collect
# Rebuild the playlist index after dropping new files into ~/HDD/Music
bash scripts/playlist.sh
# Deploy this rig from scratch (run on Midgolem)
git clone <repo> ~/.config/obs-studio && cd ~/.config/obs-studio && bash scripts/deploy-rig.sh
# Bridge / bot status + tail
systemctl --user status obs-mpd-bridge obs-twitch-bot
journalctl --user -u obs-twitch-bot -f
# Verify the audio sink exists
pactl list short sinks | grep mpd_stream
# Check OBS WS port is listening
ss -tlnp | grep 4455
```
## User-specific conventions (from global instructions)
- Never add co-author tags to commit messages.

View File

@@ -1,5 +1,5 @@
[General]
Name=Untitled
Name=ophi118
[Video]
BaseCX=2560
@@ -54,7 +54,7 @@ FFURL=
FFExtension=ts
FFMCustom=content_type=video/mp2ts ice_genre=fun
FFIgnoreCompat=true
RecFilePath=/home/jin/Videos/QS
RecFilePath=/home/jin/Videos/
RecFileNameWithoutSpace=true
RecFormat=mkv
RecEncoder=ffmpeg_nvenc

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

251
bridges/mpd-state.py Executable file
View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
MPD → OBS WebSocket bridge.
Re-broadcasts MPD playback state as `mpd:state` CustomEvents on the OBS
WebSocket bus. The loading overlay (loading/index.html) already listens for
those events to drive its now-playing terminal line — this script replaces
the custom music/index.html browser daemon as the source of truth.
Payload shape matches the original daemon's broadcastState() so the overlay
needs zero changes:
{ _type, index, total, title, file, currentTime, duration, paused }
Reads OBS WS credentials from ../vendor/obs-config.js (auto-generated by
scripts/setup.sh from plugin_config/obs-websocket/config.json — single source of truth).
Run via systemd: systemctl --user enable --now obs-mpd-bridge.service
Tail logs with: journalctl --user -u obs-mpd-bridge -f
"""
import asyncio
import base64
import hashlib
import json
import os
import re
import sys
import time
from pathlib import Path
import websockets
from mpd.asyncio import MPDClient
HERE = Path(__file__).resolve().parent
OBS_CONFIG_JS = HERE.parent / "vendor" / "obs-config.js"
MPD_HOST = os.environ.get("MPD_HOST", "localhost")
MPD_PORT = int(os.environ.get("MPD_PORT", "6600"))
TICK_SECONDS = 1.0 # broadcast cadence; matches the old daemon's setInterval(broadcastState, 1000)
# ─── config + auth ──────────────────────────────────────────────────────────
def load_obs_config(path: Path):
"""Extract `url` and `password` from the JS config file by regex —
avoids requiring a JS parser for two trivial string assignments."""
text = path.read_text()
url_m = re.search(r"url:\s*'([^']+)'", text)
pw_m = re.search(r"password:\s*'([^']*)'", text)
if not url_m:
raise RuntimeError(f"no `url` field in {path}")
return url_m.group(1), (pw_m.group(1) if pw_m else "")
def obs_auth_response(password: str, salt: str, challenge: str) -> str:
"""OBS WebSocket v5 auth: base64(sha256(b64(sha256(pw+salt)) + challenge))."""
secret_b64 = base64.b64encode(
hashlib.sha256((password + salt).encode()).digest()
).decode()
return base64.b64encode(
hashlib.sha256((secret_b64 + challenge).encode()).digest()
).decode()
# ─── state translation ──────────────────────────────────────────────────────
def _flatten_tag(v):
"""MPD returns multi-value tags as lists (e.g. two ARTIST lines for a
feature collab). Join with `, ` and trim; treat empty as None."""
if v is None:
return None
if isinstance(v, list):
v = ", ".join(x for x in v if x)
v = v.strip()
return v or None
def mpd_to_event(status: dict, song: dict) -> dict:
"""Translate `status` + `currentsong` into the overlay's expected payload.
Display string in `title` is built `Artist - Title` from ID3/Vorbis tags
when both are present, then degrades through tag-only → filename stem
(which is already "Artist - Title" for the NCS Directory naming convention)
→ None. The separate `artist` field is emitted for future overlay uses
that want to style artist + title differently."""
paused = status.get("state", "stop") != "play"
elapsed = float(status.get("elapsed") or 0)
duration = float(status.get("duration") or song.get("duration") or 0)
artist = _flatten_tag(song.get("artist") or song.get("albumartist"))
track_name = _flatten_tag(song.get("title"))
if artist and track_name:
display = f"{artist} - {track_name}"
elif track_name:
display = track_name
elif song.get("file"):
display = Path(song["file"]).stem.strip() or None
else:
display = None
return {
"index": int(status["song"]) if "song" in status else -1,
"total": int(status.get("playlistlength") or 0),
"title": display,
"artist": artist,
"trackTitle": track_name,
"file": song.get("file"),
"currentTime": elapsed,
"duration": duration,
"paused": paused,
}
class SharedState:
def __init__(self):
self.data = {
"index": -1, "total": 0,
"title": None, "artist": None, "trackTitle": None, "file": None,
"currentTime": 0, "duration": 0, "paused": True,
}
self.changed = asyncio.Event()
# ─── MPD side ───────────────────────────────────────────────────────────────
async def mpd_loop(state: SharedState):
"""Poll MPD status every TICK_SECONDS, push into shared state."""
while True:
client = MPDClient()
try:
await client.connect(MPD_HOST, MPD_PORT)
print(f"[mpd] connected → {MPD_HOST}:{MPD_PORT} "
f"(proto {client.mpd_version})", flush=True)
while True:
status = await client.status()
song = await client.currentsong()
state.data = mpd_to_event(status, song)
state.changed.set()
await asyncio.sleep(TICK_SECONDS)
except Exception as e:
print(f"[mpd] {type(e).__name__}: {e} — reconnect in 2s", flush=True)
try: client.disconnect()
except Exception: pass
await asyncio.sleep(2)
# ─── OBS WS side ────────────────────────────────────────────────────────────
async def obs_loop(state: SharedState, url: str, password: str):
"""Connect to OBS WS, identify, broadcast state changes as mpd:state.
Quiet when OBS isn't running: logs only state transitions
(down→up, up→down) and uses exponential backoff so the journal doesn't
fill with reconnect spam during long OBS-off windows.
"""
backoff = 2 # current retry delay in seconds
BACKOFF_MAX = 30 # cap; OBS doesn't take long to wake up once started
prev_status = None # None | "up" | "down" — only log on transitions
while True:
try:
async with websockets.connect(url, ping_interval=20) as ws:
# ── Hello (op 0) → Identify (op 1) → Identified (op 2) ──
hello = json.loads(await ws.recv())
if hello.get("op") != 0:
raise RuntimeError(f"expected Hello, got op={hello.get('op')}")
identify = {"rpcVersion": 1, "eventSubscriptions": 0}
if hello["d"].get("authentication"):
a = hello["d"]["authentication"]
identify["authentication"] = obs_auth_response(
password, a["salt"], a["challenge"],
)
await ws.send(json.dumps({"op": 1, "d": identify}))
ident = json.loads(await ws.recv())
if ident.get("op") != 2:
raise RuntimeError(f"expected Identified, got op={ident.get('op')}")
if prev_status != "up":
print(f"[obs] connected → {url} (identified ✓)", flush=True)
prev_status = "up"
backoff = 2 # reset for next disconnect
# ── Broadcast loop + recv drain ──
# We send ~1 BroadcastCustomEvent per second; OBS replies to
# each with an op=7 RequestResponse we don't care about. If we
# never recv, the socket's read buffer fills and eventually
# TCP backpressure stalls our sends — so a parallel task
# consumes and discards everything inbound.
async def drain():
async for _ in ws:
pass
async def broadcast():
# Wake on every state change. A safety timeout slightly
# larger than TICK_SECONDS guards against a stalled MPD
# loop — we still emit a heartbeat so the overlay knows
# we're up.
while True:
try:
await asyncio.wait_for(
state.changed.wait(), timeout=TICK_SECONDS + 1.0,
)
except asyncio.TimeoutError:
pass
state.changed.clear()
payload = dict(state.data)
payload["_type"] = "mpd:state"
request = {
"op": 6,
"d": {
"requestType": "BroadcastCustomEvent",
"requestId": f"r_{time.monotonic_ns()}",
"requestData": {"eventData": payload},
},
}
await ws.send(json.dumps(request))
await asyncio.gather(drain(), broadcast())
except Exception as e:
# Only log the first time OBS becomes unreachable, or when an
# established connection drops. Routine "still down" retries
# are silent.
if prev_status == "up":
print(f"[obs] disconnected ({type(e).__name__}: {e}) — "
f"retrying with backoff", flush=True)
elif prev_status is None:
print(f"[obs] unreachable ({type(e).__name__}) — "
f"will keep retrying silently", flush=True)
prev_status = "down"
await asyncio.sleep(backoff)
backoff = min(backoff * 2, BACKOFF_MAX)
# ─── entrypoint ─────────────────────────────────────────────────────────────
async def main():
if not OBS_CONFIG_JS.exists():
print(f"[err] missing {OBS_CONFIG_JS} — run scripts/setup.sh first",
file=sys.stderr, flush=True)
sys.exit(1)
url, password = load_obs_config(OBS_CONFIG_JS)
state = SharedState()
await asyncio.gather(mpd_loop(state), obs_loop(state, url, password))
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)

View File

@@ -19,9 +19,18 @@
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
--pad-y: 40px;
--pad-x: 72px;
/* Camera frame size — match this in OBS when positioning the camera source */
--cam-w: 480px;
--cam-h: 270px;
/* Camera frame transform — auto-synced from OBS at runtime via
GetSceneItemTransform + SceneItemTransformChanged. These defaults
are only the first-paint fallback; once the WS connects the box
snaps to wherever the Camera source actually is in the Game scene. */
--cam-x: 1855px;
--cam-y: 128px;
--cam-w: 580px;
--cam-h: 326px;
/* Visual outset around the camera so the cyan border + corner ticks
breathe and stay visible on all four sides (rather than getting
overdrawn by the camera image). Applied symmetrically. */
--cam-pad: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -55,32 +64,44 @@
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 10px; }
.hud .led {
display: inline-block;
width: 10px; height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
margin-right: 10px;
vertical-align: middle;
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 1.6s ease-in-out infinite;
}
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) ≈ 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; }
}
/* ───────── Camera frame (top-right) ─────────
Decorative only — drop your camera OBS source ON TOP of this frame
(positioned to match --cam-w / --cam-h). The frame has its own dark
backdrop + a centered placeholder so you can SEE where to put the
camera; once the camera source covers it, the placeholder disappears. */
/* ───────── Camera frame ─────────
Decorative chrome drawn UNDER the OBS Camera scene item. The four
--cam-* CSS vars are auto-synced from the Camera item's transform via
OBS WebSocket (see syncCamera below), so wherever you drag/resize the
camera in OBS, this frame snaps to match. The dark backdrop + centered
placeholder show through only until the WS connects or while the camera
is hidden. Requires the HUD browser source to render at canvas
resolution (2560×1336) so 1 CSS px == 1 canvas px. */
.camera-frame {
position: absolute;
top: calc(var(--pad-y) + 60px); /* clear of the HUD */
right: var(--pad-x);
width: var(--cam-w);
height: var(--cam-h);
top: calc(var(--cam-y) - var(--cam-pad));
left: calc(var(--cam-x) - var(--cam-pad));
width: calc(var(--cam-w) + var(--cam-pad) * 2);
height: calc(var(--cam-h) + var(--cam-pad) * 2);
pointer-events: none;
background: rgba(6, 10, 18, 0.85);
border: 1.5px solid rgba(79, 210, 255, 0.55);
@@ -200,28 +221,6 @@
100% { opacity: 1.00; }
}
/* ───────── Subtle CRT overlays (lighter than Landing — game must stay readable) ───────── */
#static {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
opacity: 0.022;
mix-blend-mode: screen;
image-rendering: pixelated;
}
.scanlines {
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.13) 3px,
rgba(0,0,0,0.13) 4px
);
opacity: 0.40;
mix-blend-mode: multiply;
}
</style>
</head>
<body>
@@ -253,8 +252,8 @@
<path d="M46 18 L60 10 L60 38 L46 30 Z" stroke="#4fd2ff" stroke-width="2" stroke-linejoin="round"/>
<circle cx="14" cy="14" r="2" fill="#e63a2e"/>
</svg>
<span class="ph-text">PLACE CAMERA HERE </span>
<span class="ph-coords">2008, 100 · 480 × 270</span>
<span class="ph-text">WAITING FOR CAMERA —</span>
<span class="ph-coords">auto-syncs from OBS scene transform</span>
</div>
</div>
@@ -284,9 +283,6 @@
</div>
</footer>
<canvas id="static"></canvas>
<div class="scanlines"></div>
<!-- Manifest from Project Loading. Same loading.js the Project Loading scene
uses, so the Game scene picks up the game / subtitle / camera / mic the
streamer already entered. -->
@@ -344,23 +340,6 @@
}
tickElapsed(); setInterval(tickElapsed, 1000);
// ── Static noise (very subtle; gameplay must remain readable) ──
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
const W = 320, H = 180;
cv.width = W; cv.height = H;
const img = ctx.createImageData(W, H);
function drawNoise() {
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = (Math.random() * 255) | 0;
d[i] = d[i+1] = d[i+2] = v;
d[i+3] = 255;
}
ctx.putImageData(img, 0, 0);
}
drawNoise();
setInterval(drawNoise, 1000 / 8);
// ── OBS WS: music sync + live camera visibility ──
// URL params let you rename the camera source / scene without editing JS:
// ?camera=Webcam&scene=Game
@@ -376,7 +355,21 @@
const npTitle = document.getElementById('np-title');
const npTime = document.getElementById('np-time');
const camEl = document.getElementById('cam');
const rootStyle = document.documentElement.style;
function setCameraVisible(visible) { camEl.classList.toggle('off', !visible); }
// Map an OBS sceneItemTransform into the four CSS vars driving the frame.
// Assumes alignment 5 (top-left) so positionX/Y is the top-left corner —
// OBS's default for newly added items, and what the Camera source uses.
function setCameraTransform(t) {
if (!t) return;
const w = (t.sourceWidth ?? 0) * (t.scaleX ?? 1);
const h = (t.sourceHeight ?? 0) * (t.scaleY ?? 1);
if (!(w > 0 && h > 0)) return;
rootStyle.setProperty('--cam-x', `${t.positionX}px`);
rootStyle.setProperty('--cam-y', `${t.positionY}px`);
rootStyle.setProperty('--cam-w', `${w}px`);
rootStyle.setProperty('--cam-h', `${h}px`);
}
async function syncCamera(obs) {
let camItemId = null;
@@ -387,6 +380,9 @@
const e = await obs.call('GetSceneItemEnabled',
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
setCameraVisible(e.sceneItemEnabled);
const t = await obs.call('GetSceneItemTransform',
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
setCameraTransform(t.sceneItemTransform);
} catch (err) {
// Source not found — keep manifest value, don't block other features.
console.warn(`[game] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
@@ -394,10 +390,12 @@
}
obs.addEventListener('event', (ev) => {
const d = ev.detail || {};
if (d.eventType === 'SceneItemEnableStateChanged'
&& d.eventData?.sceneName === CAM_SCENE
&& d.eventData?.sceneItemId === camItemId) {
if (d.eventData?.sceneName !== CAM_SCENE
|| d.eventData?.sceneItemId !== camItemId) return;
if (d.eventType === 'SceneItemEnableStateChanged') {
setCameraVisible(d.eventData.sceneItemEnabled);
} else if (d.eventType === 'SceneItemTransformChanged') {
setCameraTransform(d.eventData.sceneItemTransform);
}
});
}

545
goodbye/index.html Normal file
View File

@@ -0,0 +1,545 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / SIGN-OFF</title>
<style>
:root {
--bg: #07080d;
--ink: #e8e8e0;
--hud: #4fd2ff;
--hud-dim: #2a7fa6;
--accent: #e63a2e;
--warn: #ffd000;
--offair: #ff6f5a;
--term-bg: #04120a;
--term-fg: #5fdc62;
--term-fg-bright: #97f99a;
--term-fg-dim: #2c8d2f;
--term-glow: rgba(80, 220, 100, 0.55);
--term-edge: rgba(80, 220, 100, 0.45);
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
background: var(--bg);
color: var(--ink);
font-family: var(--display);
overflow: hidden;
user-select: none;
}
body {
position: relative;
display: grid;
grid-template-rows: auto 1fr auto auto;
padding: 40px 72px;
gap: 28px;
}
/* ───────── HUD top strip ───────── */
.hud {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 22px;
font-weight: 700;
color: var(--hud);
letter-spacing: 0.08em;
}
.hud .group { display: flex; gap: 28px; align-items: center; }
.hud > .group:nth-child(1) { justify-self: start; }
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
.hud .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--offair);
box-shadow: 0 0 10px var(--offair);
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 2.4s ease-in-out infinite;
}
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) ≈ 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 65% { opacity: 1; }
75%, 100% { opacity: 0.15; }
}
/* ───────── Terminal stage ───────── */
.stage {
display: flex;
align-items: center;
justify-content: center;
}
.terminal {
width: 60vw;
max-width: 1380px;
height: 58vh;
max-height: 740px;
min-height: 400px;
position: relative;
overflow: hidden;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 6px;
padding: 28px 36px;
font-family: var(--mono);
font-size: 26px;
line-height: 1.55;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
box-shadow:
0 0 60px rgba(80, 220, 100, 0.10),
inset 0 0 90px rgba(0, 30, 0, 0.65);
display: flex;
flex-direction: column;
}
#termOutput {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
}
.terminal::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.28) 3px,
rgba(0,0,0,0.28) 4px
);
opacity: 0.55;
mix-blend-mode: multiply;
}
.term-line { white-space: pre-wrap; word-break: break-word; }
.term-prompt { color: var(--term-fg-bright); }
.term-out { color: var(--term-fg); }
.term-dim { color: var(--term-fg-dim); }
.term-warn { color: var(--warn); }
.term-cursor {
display: inline-block;
width: 0.55em;
height: 1em;
background: var(--term-fg);
margin-left: 6px;
vertical-align: -0.15em;
box-shadow: 0 0 8px var(--term-glow);
animation: term-blink 1.05s steps(2) infinite;
}
@keyframes term-blink { 50% { opacity: 0; } }
.term-now-playing { margin-top: 10px; }
.term-now-playing .np-arrow {
color: var(--term-fg-bright);
margin-right: 10px;
animation: np-pulse 1.05s ease-in-out infinite alternate;
}
.term-now-playing .np-title { color: var(--term-fg-bright); }
.term-now-playing .np-time { color: var(--term-fg-dim); margin-left: 12px; }
@keyframes np-pulse {
0% { opacity: 0.45; }
100% { opacity: 1.00; }
}
/* ───────── Big sign-off banner ───────── */
/* text-indent compensates for trailing letter-spacing on the last char,
which would otherwise be included in the centered inline box and shift
visible text left by half the letter-spacing value. */
.banner { text-align: center; }
.banner .label {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
margin-bottom: 12px;
}
.banner .title {
font-family: var(--mono);
font-size: 116px;
font-weight: 900;
letter-spacing: 0.18em;
text-indent: 0.18em;
line-height: 1;
color: var(--ink);
text-shadow:
0 0 22px rgba(255, 111, 90, 0.18),
0 0 48px rgba(230, 58, 46, 0.10);
animation: sign-pulse 3.6s ease-in-out infinite;
}
.banner .sub {
margin-top: 18px;
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
}
@keyframes sign-pulse {
0%, 100% { opacity: 0.88; }
50% { opacity: 1.00; }
}
/* ───────── Footer ───────── */
.foot {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 19px;
color: var(--hud-dim);
letter-spacing: 0.12em;
}
.foot > *:nth-child(1) { justify-self: start; }
.foot > *:nth-child(2) { justify-self: center; }
.foot > *:nth-child(3) { justify-self: end; }
.foot .offair { color: var(--offair); }
/* ───────── Body overlays ───────── */
#static {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
opacity: 0.045;
mix-blend-mode: screen;
image-rendering: pixelated;
}
.scanlines {
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.20) 3px,
rgba(0,0,0,0.20) 4px
);
opacity: 0.5;
mix-blend-mode: multiply;
}
.vignette {
position: absolute; inset: 0;
pointer-events: none;
background: radial-gradient(
ellipse at center,
rgba(0,0,0,0) 45%,
rgba(0,0,0,0.60) 100%
);
}
.flicker {
position: absolute; inset: 0;
pointer-events: none;
background: rgba(0,0,0,0);
animation: flicker 11s ease-in-out infinite;
}
@keyframes flicker {
0%, 100% { background: rgba(0,0,0,0); }
18% { background: rgba(0,0,0,0); }
18.3% { background: rgba(0,0,0,0.06); }
18.5% { background: rgba(0,0,0,0); }
47% { background: rgba(0,0,0,0); }
47.3% { background: rgba(0,0,0,0.08); }
47.5% { background: rgba(0,0,0,0); }
78% { background: rgba(0,0,0,0); }
78.4% { background: rgba(0,0,0,0.05); }
78.7% { background: rgba(0,0,0,0); }
}
</style>
</head>
<body>
<header class="hud">
<div class="group">
<span><span class="led"></span>OFF AIR</span>
<span>OPHI-118 // SIGN-OFF</span>
</div>
<div class="group">
<span class="dim">SIGNAL</span>
<span id="signal">▮▮▯▯▯</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>
<main class="stage">
<div class="terminal">
<div id="termOutput"></div>
</div>
</main>
<section class="banner" id="banner">
<div class="label">— TRANSMISSION ENDED —</div>
<div class="title" id="bannerTitle">GOOD&nbsp;BYE</div>
<div class="sub" id="bannerSub">— THANKS FOR TUNING IN —</div>
</section>
<footer class="foot">
<span>CH 118.0 MHz</span>
<span id="rig">— PROJECT MANIFEST UNLOADED —</span>
<span class="offair">■ OFF AIR</span>
</footer>
<canvas id="static"></canvas>
<div class="scanlines"></div>
<div class="vignette"></div>
<div class="flicker"></div>
<!-- Playlist manifest written by scripts/playlist.sh — used here just for the
track count in the closing terminal output. Actual playback continues in
the shared Music Daemon source. -->
<script src="../loading/playlist.js"></script>
<!-- OBS WebSocket — receives mpd:state broadcasts from the daemon so the
now-playing line keeps updating during sign-off. -->
<script src="../vendor/obs-config.js"></script>
<script src="../vendor/obs-ws-mini.js"></script>
<script>
const pad = n => String(n).padStart(2, '0');
const sleep = ms => new Promise(r => setTimeout(r, ms));
// ── UTC clock ──
const clockEl = document.getElementById('clock');
const tickClock = () => {
const d = new Date();
clockEl.textContent = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
};
tickClock();
setInterval(tickClock, 1000);
// ── Signal bar — degrading: more 1s and 2s than during loading ──
const sigEl = document.getElementById('signal');
const drawSig = n => '▮'.repeat(n) + '▯'.repeat(5 - n);
setInterval(() => {
const r = Math.random();
sigEl.textContent = drawSig(r < 0.45 ? 1 : r < 0.85 ? 2 : r < 0.97 ? 3 : 0);
}, 1500);
// ── Static (CRT noise) ──
const cv = document.getElementById('static'), ctx = cv.getContext('2d');
const W = 320, H = 180;
cv.width = W; cv.height = H;
const img = ctx.createImageData(W, H);
const drawNoise = () => {
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = (Math.random() * 255) | 0;
d[i] = d[i+1] = d[i+2] = v;
d[i+3] = 255;
}
ctx.putImageData(img, 0, 0);
};
drawNoise();
setInterval(drawNoise, 1000 / 8);
// ── Sign-off sequence (terminal output) ──
const PROMPT = 'OPHI-118://> ';
const lines = [
{ kind: 'cmd', text: 'unloadproject --flush --persist-state' },
{ kind: 'out', text: 'closing transmission envelope...' },
{ kind: 'gap' },
{ kind: 'out', text: 'session :: archived' },
{ kind: 'out', text: 'camera :: released' },
{ kind: 'out', text: 'microphone :: released' },
{ kind: 'out', text: 'capture :: released' },
{ kind: 'gap' },
{ kind: 'dim', text: 'flushing buffers...' },
{ kind: 'gap' },
{ kind: 'out', text: 'TRANSMISSION ENDED.' },
{ kind: 'warn', text: '— see you on the next channel —' },
];
const term = document.getElementById('termOutput');
const MAX_TERM_LINES = 200;
function pruneTerminal() {
// Cap total node count (memory).
while (term.children.length > MAX_TERM_LINES) {
const first = term.firstElementChild;
if (!first || first === npLine) break;
term.removeChild(first);
}
// Sum rendered heights of children and drop top until they fit. Container
// is flex/justify-end with overflow:hidden, so neither scrollHeight nor
// child positions reliably indicate top overflow — but each child's own
// rendered height does, regardless of clip.
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
const first = term.firstElementChild;
if (!first || first === npLine) break;
term.removeChild(first);
}
}
function append(prompt, text, cls) {
const div = document.createElement('div');
div.className = 'term-line';
if (prompt) {
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = prompt;
div.appendChild(p);
}
const t = document.createElement('span');
if (cls) t.className = cls;
t.textContent = text;
div.appendChild(t);
term.appendChild(div);
pruneTerminal();
return t;
}
async function typeCommand(text) {
const t = append(PROMPT, '', 'term-out');
for (const c of text) {
t.textContent += c;
await sleep(38);
}
await sleep(280);
}
// ── Music: passive listener; shared Music Daemon plays the audio ──
const playlistData = window.__PLAYLIST || { tracks: [] };
const allTracks = playlistData.tracks || [];
let npLine = null;
function ensureNowPlayingLine() {
if (npLine) return npLine;
npLine = document.createElement('div');
npLine.className = 'term-line term-now-playing';
npLine.innerHTML =
'<span class="np-arrow">▶</span>' +
'<span class="np-title">connecting to daemon…</span>' +
'<span class="np-time">[--:-- / --:--]</span>';
term.appendChild(npLine);
return npLine;
}
function logBeforeNp(text, cls = 'term-out') {
if (!npLine) { append('> ', text, cls); return; }
const div = document.createElement('div');
div.className = 'term-line';
const p = document.createElement('span'); p.className = 'term-prompt'; p.textContent = '> ';
const t = document.createElement('span'); t.className = cls; t.textContent = text;
div.appendChild(p); div.appendChild(t);
term.insertBefore(div, npLine);
pruneTerminal();
}
function fmtClock(s) {
if (!isFinite(s) || s < 0) return '--:--';
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
}
let lastTrackIndex = null;
async function connectMusic() {
if (!window.__OBSWS) {
logBeforeNp('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
return;
}
try {
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
await obs.connect();
obs.addEventListener('close', () => {
logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim');
setTimeout(connectMusic, 2000);
});
obs.onCustom('mpd:state', (s) => {
if (!npLine) ensureNowPlayingLine();
if (lastTrackIndex !== null && s.index !== lastTrackIndex) {
logBeforeNp(`[audio] next: ${s.title || '?'}`);
}
lastTrackIndex = s.index;
npLine.querySelector('.np-title').textContent = s.title || '—';
npLine.querySelector('.np-time').textContent =
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
});
} catch (e) {
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
setTimeout(connectMusic, 2500);
}
}
async function startMusic() {
await sleep(400);
await typeCommand('mpd --keep-alive');
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued', 'term-dim');
return;
}
append('> ', `[audio] queue retained: ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
ensureNowPlayingLine();
connectMusic();
}
// ── Run sequence: type sign-off → music status ──
(async () => {
await sleep(450);
for (const ln of lines) {
if (ln.kind === 'cmd') {
await typeCommand(ln.text);
} else if (ln.kind === 'gap') {
append('', ' ', '');
await sleep(80);
} else {
const cls =
ln.kind === 'dim' ? 'term-dim' :
ln.kind === 'warn' ? 'term-warn' :
'term-out';
append('> ', ln.text, cls);
await sleep(170);
}
}
await startMusic();
})();
// ── Live "OFFLINE FOR" counter under the banner sub-line ──
// Scene-load = sign-off start. Counts up indefinitely; if you switch back to
// a live scene and return, it resets (browser source reload).
const startMs = Date.now();
const subEl = document.getElementById('bannerSub');
const tickOffline = () => {
const sec = Math.floor((Date.now() - startMs) / 1000);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const t = h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
subEl.textContent = `— OFFLINE FOR ${t}`;
};
// Wait a few seconds before the counter takes over the "thanks" sub-line,
// so the message has time to register.
setTimeout(() => { tickOffline(); setInterval(tickOffline, 500); }, 6000);
</script>
</body>
</html>

View File

@@ -50,16 +50,25 @@ body {
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
.hud .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
margin-right: 12px;
vertical-align: middle;
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 1.6s ease-in-out infinite;
}
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) ≈ 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; }

View File

@@ -1,21 +1,22 @@
{
"collectedAt": "2026-04-25T22:10:52Z",
"collectedAt": "2026-04-26T20:17:38Z",
"rig": "Ignia",
"cpu": {
"model": "AMD Ryzen",
"model": "AMD Ryzen 5 2600 Six-Core Processor",
"threads": 12
},
"mem": {
"totalGB": 32
"totalGB": 31.3
},
"host": {
"kernel": "Arch Linux 6.18.23-1-lts"
"kernel": "6.18.23-1-lts"
},
"gpu": {
"name": "NVIDIA GeForce RTX 3060 Ti",
"vramTotalMB": 8192
},
"obs": {
"profile": "Untitled",
"profile": "ophi118",
"renderer": "OpenGL",
"outputMode": "Advanced",
"canvas": { "w": 2560, "h": 1336 },

View File

@@ -17,7 +17,7 @@
--term-fg-bright: #97f99a;
--term-fg-dim: #2c8d2f;
--term-glow: rgba(80, 220, 100, 0.55);
--term-edge: rgba(80, 220, 100, 0.30);
--term-edge: rgba(80, 220, 100, 0.45);
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
@@ -58,16 +58,25 @@ body {
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
.hud .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
margin-right: 12px;
vertical-align: middle;
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 1.6s ease-in-out infinite;
}
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) ≈ 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; }
@@ -81,16 +90,17 @@ body {
}
.terminal {
width: 64vw;
max-width: 1500px;
height: 100%;
min-height: 380px;
width: 60vw;
max-width: 1380px;
height: 58vh;
max-height: 740px;
min-height: 400px;
position: relative;
overflow: hidden;
background: var(--term-bg);
border: 1px solid var(--term-edge);
border: 2px solid var(--term-edge);
border-radius: 6px;
padding: 32px 40px;
padding: 28px 36px;
font-family: var(--mono);
font-size: 26px;
line-height: 1.55;
@@ -158,11 +168,15 @@ body {
}
/* ───────── Big countdown ───────── */
/* text-indent compensates for trailing letter-spacing on the last char,
which would otherwise be included in the centered inline box and shift
visible text left by half the letter-spacing value. */
.countdown { text-align: center; }
.countdown .label {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
margin-bottom: 12px;
}
@@ -171,6 +185,7 @@ body {
font-size: 132px;
font-weight: 900;
letter-spacing: 0.10em;
text-indent: 0.10em;
line-height: 1;
color: var(--ink);
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
@@ -181,6 +196,7 @@ body {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
}
@keyframes cd-pulse {
@@ -191,6 +207,7 @@ body {
color: var(--term-fg);
text-shadow: 0 0 32px var(--term-glow);
letter-spacing: 0.18em;
text-indent: 0.18em;
}
.countdown.ready .sub { color: var(--term-fg-bright); }
.countdown.ready .label { color: var(--term-fg-dim); }
@@ -301,9 +318,12 @@ body {
<div class="vignette"></div>
<div class="flicker"></div>
<!-- Manifest data written by loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
<!-- Rig + hardware telemetry written by scripts/telemetry.sh — exposes window.__TEL.rig
for the "detected rig :: …" terminal line below. Same wrapper trick. -->
<script src="../landing/telemetry.js"></script>
<!-- Manifest data written by scripts/loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
<script src="loading.js"></script>
<!-- Playlist manifest written by playlist.sh — same wrapper trick. Used here
<!-- Playlist manifest written by scripts/playlist.sh — same wrapper trick. Used here
just for the track count in the terminal output; actual playback runs
in the separate Music Daemon source. -->
<script src="playlist.js"></script>
@@ -356,9 +376,10 @@ body {
const cdMin = data.countdownMin ?? 5;
const PROMPT = 'OPHI-118://> ';
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
const lines = [
{ kind: 'cmd', text: 'loadproject --manifest' },
{ kind: 'out', text: 'initializing transmission envelope...' },
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
{ kind: 'gap' },
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
@@ -450,7 +471,7 @@ body {
let lastTrackIndex = null;
async function connectMusic() {
if (!window.__OBSWS) {
logBeforeNp('[audio] vendor/obs-config.js missing — run setup.sh', 'term-dim');
logBeforeNp('[audio] vendor/obs-config.js missing — run scripts/setup.sh', 'term-dim');
return;
}
try {
@@ -482,7 +503,7 @@ body {
await typeCommand('mpd --queue ~/playlists/stream-mix');
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued — run loading/playlist.sh', 'term-dim');
append('> ', '[audio] no tracks queued — run scripts/playlist.sh', 'term-dim');
return;
}

View File

@@ -86,7 +86,7 @@
let obs = null;
async function connectOBS() {
if (!window.__OBSWS) {
setWs('vendor/obs-config.js MISSING — run setup.sh', 'err');
setWs('vendor/obs-config.js MISSING — run scripts/setup.sh', 'err');
return;
}
try {
@@ -221,7 +221,7 @@
if (queue.length > 0) {
playCurrent();
} else {
console.warn('[music] no tracks — run loading/playlist.sh');
console.warn('[music] no tracks — run scripts/playlist.sh');
}
</script>
</body>

30
profile/Ignia Spec.md Normal file

File diff suppressed because one or more lines are too long

32
profile/Midgolem Spec.md Normal file

File diff suppressed because one or more lines are too long

BIN
profile/logitech.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
profile/steelseries.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

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

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# loading/convert.sh — strip video tracks out of audio files in
# 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>

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

View File

@@ -9,8 +9,9 @@
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
JSON="$DIR/loading.json"
JS="$DIR/loading.js"
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.
@@ -69,6 +70,7 @@ 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"
@@ -78,10 +80,42 @@ echo "── Project Loading manifest ──"
echo " (press Enter to keep [defaults])"
echo
game=$(ask "Game" "$prev_game")
while [[ -z "$game" ]]; do
# 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
game=$(ask "Game" "")
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")
@@ -98,6 +132,7 @@ cat > "$tmp" <<JSON
{
"compiledAt": "$now_iso",
"game": $(emit_str "$game"),
"gameId": $(emit_str "$game_id"),
"subtitle": $(emit_str "$subtitle"),
"countdownMin": $countdown,
"camera": $camera,
@@ -116,4 +151,22 @@ 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."

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# loading/playlist.sh — playlist sync + control API for the music daemon.
# scripts/playlist.sh — playlist sync + control API for the music daemon.
#
# Usage:
# ./playlist.sh → sync (default; index audio in ../playlist/)
@@ -20,10 +20,16 @@ set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
AUDIO_DIR="$OBS_DIR/playlist"
JSON="$DIR/playlist.json"
JS="$DIR/playlist.js"
CMD_JS="$DIR/cmd.js"
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
@@ -31,23 +37,22 @@ usage() {
# ─────────────── sync ───────────────
cmd_sync() {
if [[ ! -d "$AUDIO_DIR" ]]; then
echo "$AUDIO_DIR does not exist" >&2
echo " Drop your audio files there first." >&2
exit 1
fi
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_DIR ──"
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 - "$AUDIO_DIR" "$TMP" <<'PY'
import json, subprocess, sys, os, datetime, re
python3 - "${ROOTS[@]}" "$TMP" <<'PY'
import json, subprocess, sys, os, datetime, re, urllib.parse
audio_dir, out = sys.argv[1], sys.argv[2]
roots = sys.argv[1:-1]
out = sys.argv[-1]
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
def ffprobe_meta(path):
@@ -87,28 +92,32 @@ def is_audio_file(name):
if INTERMEDIATE.search(base): return False
return True
YT_ID_RX = re.compile(r'\[([A-Za-z0-9_-]{11})\]')
files = sorted(f for f in os.listdir(audio_dir) if is_audio_file(f))
# 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, seen_ids = [], set()
for fname in files:
m = YT_ID_RX.search(fname)
yt_id = m.group(1) if m else None
if yt_id:
if yt_id in seen_ids: continue
seen_ids.add(yt_id)
path = os.path.join(audio_dir, fname)
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': f"../playlist/{fname}",
'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'),
'sourceDir': audio_dir,
'sourceDirs': roots,
'trackCount': len(tracks),
'tracks': tracks,
}

View File

@@ -2,14 +2,15 @@
# One-time setup: builds vendor/obs-config.js from your existing OBS WebSocket
# plugin config so the overlay/daemon pages can connect.
#
# ./setup.sh
# 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)"
SRC="$DIR/plugin_config/obs-websocket/config.json"
OUT="$DIR/vendor/obs-config.js"
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
@@ -24,10 +25,10 @@ d = json.load(open('$SRC'))
print(d.get('server_port', 4455), d.get('server_password', ''), str(d.get('server_enabled', False)).lower())
")"
mkdir -p "$DIR/vendor"
mkdir -p "$OBS_DIR/vendor"
cat > "$OUT" <<JS
// Auto-generated by setup.sh — gitignored. Reflects the current contents of
// plugin_config/obs-websocket/config.json. Re-run setup.sh if it changes.
// 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',

View File

@@ -1,11 +1,15 @@
#!/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.
#
# Two modes:
# Modes:
# ./telemetry.sh — wrap telemetry.json → telemetry.js
# (idempotent; preserves any hand edits to the JSON)
# ./telemetry.sh --collect — re-read hardware + OBS config, overwrite
# telemetry.json, then wrap → 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.
@@ -13,8 +17,8 @@ set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OBS_DIR="$(cd "$DIR/.." && pwd)"
JSON="$DIR/telemetry.json"
JS="$DIR/telemetry.js"
JSON="$OBS_DIR/landing/telemetry.json"
JS="$OBS_DIR/landing/telemetry.js"
sanitize() {
printf '%s' "${1:-}" \
@@ -167,6 +171,11 @@ collect() {
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
@@ -181,6 +190,7 @@ collect() {
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)
@@ -189,6 +199,7 @@ collect() {
cat > "$tmp" <<JSON
{
"collectedAt": "$now_iso",
"rig": $(emit_str "$rig"),
"cpu": {
"model": $(emit_str "$cpu_model"),
"threads": $(emit_num "$cpu_threads")
@@ -216,6 +227,79 @@ 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() {
@@ -233,5 +317,20 @@ wrap_js() {
echo "wrote $JS"
}
[[ "${1:-}" == "--collect" ]] && collect
# ── 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

1
station Submodule

Submodule station added at b92e514a2a

112
twitch-bot/_twitch.py Normal file
View File

@@ -0,0 +1,112 @@
"""
Shared helpers for set-channel.py and search-game.py.
Stdlib-only on purpose — the bot dir intentionally avoids a venv. If a third
caller appears that needs richer behavior (retries with backoff, async, etc.),
revisit; until then minimal urllib is fine.
Auth model: every Helix call goes through `auth_call`, which runs the request,
refreshes the access token via twitchtokengenerator on 401, persists the new
tokens to .env.ophi118, mutates the in-memory env dict, and retries once.
Callers stay agnostic about whether a refresh happened.
"""
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
HELIX = "https://api.twitch.tv/helix"
REFRESH_URL = "https://twitchtokengenerator.com/api/refresh/{refresh}"
ENV_FILE = Path(__file__).resolve().parent / ".env.ophi118"
# ─── env file I/O ───────────────────────────────────────────────────────────
def load_env(path: Path = ENV_FILE) -> dict:
out = {}
if not path.exists():
return out
for raw in path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
out[k.strip()] = v.strip().strip('"').strip("'")
return out
def update_env(updates: dict, path: Path = ENV_FILE) -> None:
"""Atomic in-place rewrite of KEY=VALUE lines, comments preserved."""
lines = path.read_text().splitlines() if path.exists() else []
seen, out_lines = set(), []
for line in lines:
s = line.strip()
if s and not s.startswith("#") and "=" in s:
k = s.split("=", 1)[0].strip()
if k in updates:
out_lines.append(f"{k}={updates[k]}")
seen.add(k)
continue
out_lines.append(line)
for k, v in updates.items():
if k not in seen:
out_lines.append(f"{k}={v}")
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text("\n".join(out_lines) + "\n")
tmp.replace(path)
# ─── HTTP ───────────────────────────────────────────────────────────────────
def http(method: str, url: str, *, headers=None, body=None):
"""Returns (status, parsed_json_or_text). Never raises on HTTP errors —
surfaces them as the status code so callers can branch on 401 cleanly."""
data = body.encode("utf-8") if isinstance(body, str) else body
req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
try:
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read().decode("utf-8")
try: return r.status, (json.loads(raw) if raw else None)
except json.JSONDecodeError: return r.status, raw
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
try: return e.code, (json.loads(raw) if raw else None)
except json.JSONDecodeError: return e.code, raw
# ─── auth ───────────────────────────────────────────────────────────────────
def refresh_token(env: dict) -> dict:
"""Mint new tokens via twitchtokengenerator. Returns the merge dict for
`update_env` — caller persists. Raises on refresh failure (no point
retrying — the refresh token is dead, the user must re-generate)."""
refresh = env["TWITCH_REFRESH_TOKEN"]
url = REFRESH_URL.format(refresh=urllib.parse.quote(refresh, safe=""))
status, body = http("GET", url)
if status != 200 or not isinstance(body, dict) or not body.get("success"):
raise RuntimeError(f"token refresh failed (status={status}): {body}")
return {
"TWITCH_ACCESS_TOKEN": body["token"],
"TWITCH_REFRESH_TOKEN": body["refresh"],
}
def auth_call(method: str, url: str, env: dict, *, body=None):
"""Authenticated Helix request with 401 → refresh → retry. Mutates `env`
in place on refresh so subsequent calls in the same process see the new
token without another reload."""
def headers_for(tok):
h = {"Authorization": f"Bearer {tok}", "Client-Id": env["TWITCH_CLIENT_ID"]}
if body is not None:
h["Content-Type"] = "application/json"
return h
status, resp = http(method, url, headers=headers_for(env["TWITCH_ACCESS_TOKEN"]), body=body)
if status == 401:
new = refresh_token(env)
update_env(new)
env.update(new)
status, resp = http(method, url, headers=headers_for(env["TWITCH_ACCESS_TOKEN"]), body=body)
return status, resp

104
twitch-bot/search-game.py Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""
Interactive Twitch category search. Used by loading.sh to resolve a free-form
query into an exact directory entry.
./search-game.py "Heroes"
stderr: numbered list of up to 20 matches + "Pick: " prompt
stdout: a single line "<game_id>\\t<canonical_name>" (only on success)
exit: 0 success | 1 no-match / cancel / error | 130 ctrl-c
Auto-picks when there's exactly one result. Tempting to also auto-pick on an
exact-name match within a larger result set (e.g. typing "Doom Eternal" and
having that exact entry near the top), but a real test surfaced the failure:
typing "Heroes" returns "Heroes" (TV show) plus all the Heroes-* games, and
auto-picking the standalone match silently steals from the user — who is
*using search* precisely because the query is ambiguous. So: only one rule.
Endpoint: /helix/search/categories — same one Twitch's dashboard autocomplete
uses. Accepts any user/app token; we reuse the broadcaster token from
.env.ophi118 because it's already there and refreshable.
"""
import sys
import urllib.parse
import _twitch as tw
MAX_RESULTS = 20
def err(msg=""):
print(msg, file=sys.stderr, flush=True)
def search(env: dict, query: str) -> list:
qs = urllib.parse.urlencode({"query": query, "first": MAX_RESULTS})
status, body = tw.auth_call("GET", f"{tw.HELIX}/search/categories?{qs}", env)
if status != 200:
raise RuntimeError(f"search failed (status={status}): {body}")
return (body or {}).get("data") or []
def pick(results: list, query: str) -> dict:
if not results:
raise RuntimeError(f"no Twitch categories match {query!r}")
if len(results) == 1:
err(f" ✓ only match: {results[0]['name']}")
return results[0]
err(f"── {len(results)} matches for {query!r} ──")
for i, g in enumerate(results, 1):
err(f" {i:2}) {g['name']}")
err(" 0) cancel")
while True:
# input()'s prompt arg writes to stdout — fatal here because bash
# captures stdout for the resolved <id>\t<name> line. Print to stderr
# explicitly, then call input() with no prompt.
print("Pick: ", end="", file=sys.stderr, flush=True)
try:
raw = input().strip()
except EOFError:
raise RuntimeError("cancelled (EOF)")
if not raw.isdigit():
err(" ! enter a number")
continue
n = int(raw)
if n == 0:
raise RuntimeError("cancelled")
if 1 <= n <= len(results):
return results[n - 1]
err(f" ! must be 0..{len(results)}")
def main():
if len(sys.argv) != 2 or not sys.argv[1].strip():
print("usage: search-game.py <query>", file=sys.stderr)
sys.exit(2)
query = sys.argv[1].strip()
env = tw.load_env()
needed = ("TWITCH_ACCESS_TOKEN", "TWITCH_REFRESH_TOKEN", "TWITCH_CLIENT_ID")
missing = [k for k in needed if not env.get(k)]
if missing:
err(f"[err] missing in {tw.ENV_FILE.name}: {', '.join(missing)}")
sys.exit(1)
try:
results = search(env, query)
chosen = pick(results, query)
except Exception as e:
err(f"[err] {type(e).__name__}: {e}")
sys.exit(1)
# ONLY the resolved tuple goes to stdout — bash captures this.
print(f"{chosen['id']}\t{chosen['name']}")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(130)

102
twitch-bot/set-channel.py Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""
Update Twitch channel info (game + title) for the broadcaster account.
./set-channel.py --game-id 517520 "Slayer is back — Nightmare run"
./set-channel.py "Doom Eternal" "..." # exact-name lookup, fails if no exact hit
./set-channel.py --dry-run --game-id 517520 "..."
For free-form / fuzzy game queries, use search-game.py first to resolve the
id (loading.sh does this automatically). The /helix/games?name= fallback in
this script is exact-and-case-sensitive.
Loads `.env.ophi118` — see _twitch.py for the env contract. The token MUST
belong to the broadcaster (Twitch enforces broadcaster_id == token user_id
on PATCH /helix/channels), so this script intentionally targets that file
and not `.env` (which holds the chat-bot's account).
"""
import json
import sys
import urllib.parse
import _twitch as tw
TITLE_LIMIT = 140
def lookup_game_id(env: dict, name: str) -> str:
qs = urllib.parse.urlencode({"name": name})
status, body = tw.auth_call("GET", f"{tw.HELIX}/games?{qs}", env)
if status != 200:
raise RuntimeError(f"games lookup failed (status={status}): {body}")
data = (body or {}).get("data") or []
if not data:
raise RuntimeError(
f"no Twitch game matches {name!r} exactly. /helix/games?name= is "
f"case-sensitive — use search-game.py for fuzzy lookup."
)
return data[0]["id"]
def patch_channel(env: dict, game_id: str, title: str) -> None:
qs = urllib.parse.urlencode({"broadcaster_id": env["TWITCH_BROADCASTER_ID"]})
payload = json.dumps({"game_id": game_id, "title": title})
status, body = tw.auth_call("PATCH", f"{tw.HELIX}/channels?{qs}", env, body=payload)
if status not in (200, 204):
raise RuntimeError(f"channel update failed (status={status}): {body}")
def usage_and_die():
print("usage: set-channel.py [--dry-run] (--game-id <id> | <game-name>) <title>",
file=sys.stderr)
sys.exit(2)
def main():
args = sys.argv[1:]
dry = False
if args and args[0] == "--dry-run":
dry, args = True, args[1:]
game_id = None
if args and args[0] == "--game-id":
if len(args) < 3:
usage_and_die()
game_id, args = args[1], args[2:]
expected = 1 if game_id else 2
if len(args) != expected:
usage_and_die()
if game_id:
title = args[0][:TITLE_LIMIT]
else:
game_name = args[0]
title = args[1][:TITLE_LIMIT]
env = tw.load_env()
needed = ("TWITCH_ACCESS_TOKEN", "TWITCH_REFRESH_TOKEN",
"TWITCH_CLIENT_ID", "TWITCH_BROADCASTER_ID")
missing = [k for k in needed if not env.get(k)]
if missing:
print(f"[err] missing in {tw.ENV_FILE.name}: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
if game_id is None:
game_id = lookup_game_id(env, game_name)
if dry:
print(f" ✓ dry-run: would set game_id={game_id}, title={title!r} (no PATCH issued)")
return
patch_channel(env, game_id, title)
print(f" ✓ Twitch channel updated → game_id={game_id}, title={title!r}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"[err] {type(e).__name__}: {e}", file=sys.stderr)
sys.exit(1)

View File

@@ -14,10 +14,10 @@ ConfirmOnExit=true
HotkeyFocusType=NeverDisableHotkeys
[Basic]
SceneCollection=Untitled
SceneCollectionFile=Untitled.json
Profile=Untitled
ProfileDir=Untitled
SceneCollection=Default Stream HUD
SceneCollectionFile=Default_Stream_HUD.json
Profile=ophi118
ProfileDir=ophi118
ConfigOnNewProfile=true
[PropertiesWindow]
@@ -25,8 +25,8 @@ cx=720
cy=580
[BasicWindow]
geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAACgQAAABkAAAn+AAAEHAAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABLPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAfMAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAH3AAAB6AAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAAD4wAAAn0AAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABmQAAAHQAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAg4AAAByAAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAPYAAAABAAAAAQAAAAIAAAACPwAAAAA
geometry=AdnQywADAAAAAAoAAAAAAAAAEX8AAAQDAAAAAAAAAAAAAAO/AAAEAwAAAAACAAAAB4AAAAoAAAAAAAAAEX8AAAQD
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAeAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAWEAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAFlAAABXwAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAACyAAAAgoAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABNYAAAFdAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAY3AAABSQAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAAB4AAAAKcAAAABAAAAAQAAAAIAAAACPwAAAAA
PreviewEnabled=true
AlwaysOnTop=false
SceneDuplicationMode=true