Scripts, scenes, bridges, bots and more
This commit is contained in:
7
.gitignore
vendored
7
.gitignore
vendored
@@ -87,3 +87,10 @@ Thumbs.db
|
|||||||
*~
|
*~
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Python bytecode (bridges/, twitch-bot/)
|
||||||
|
# ============================================================
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "station"]
|
||||||
|
path = station
|
||||||
|
url = git@github.com:golem15com/html-ophi118-rig.git
|
||||||
244
CLAUDE.md
244
CLAUDE.md
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
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
|
## What this directory is
|
||||||
|
|
||||||
Active OBS Studio user config for the **Flatpak** install (`com.obsproject.Studio` from Flathub). Path: `~/.config/obs-studio/`.
|
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.
|
**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
|
## 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
|
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)
|
## 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`).
|
- **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-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 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-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/`.
|
||||||
- **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.
|
|
||||||
- If you encounter `linuxbrowser-source` anywhere — in a backup, a doc, a paste — assume it's pre-migration and migrate the same way.
|
- 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
|
## 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
|
## 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.
|
`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
|
## 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).
|
- `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.
|
- `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
|
- `scene` — a scene
|
||||||
- `pipewire-screen-capture-source` — Wayland/PipeWire screen/window capture
|
- `pipewire-screen-capture-source` — Wayland/PipeWire screen/window capture
|
||||||
- `pipewire-camera-source` — PipeWire camera
|
|
||||||
- `v4l2_input` — V4L2 webcam (legacy path)
|
- `v4l2_input` — V4L2 webcam (legacy path)
|
||||||
- `xshm_input` — X11 screen capture (legacy path)
|
- `browser_source` — CEF browser (post-migration)
|
||||||
- `browser_source` — CEF browser (post-migration; was `linuxbrowser-source`)
|
|
||||||
- `image_source` — static image
|
- `image_source` — static image
|
||||||
- `text_ft2_source` — FreeType2 text
|
- `text_ft2_source` — FreeType2 text
|
||||||
- `ffmpeg_source` — media file (audio/video) via ffmpeg
|
- `ffmpeg_source` — media file (audio/video) via ffmpeg
|
||||||
|
- `pulse_output_capture` / `pulse_input_capture` — PulseAudio sink/source capture (used for `mpd_stream.monitor` and the mic)
|
||||||
Active scenes: `Landing` (current), `Game`, `Desktop`.
|
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
## Working with this directory
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **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)
|
## User-specific conventions (from global instructions)
|
||||||
|
|
||||||
- Never add co-author tags to commit messages.
|
- Never add co-author tags to commit messages.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[General]
|
[General]
|
||||||
Name=Untitled
|
Name=ophi118
|
||||||
|
|
||||||
[Video]
|
[Video]
|
||||||
BaseCX=2560
|
BaseCX=2560
|
||||||
@@ -54,7 +54,7 @@ FFURL=
|
|||||||
FFExtension=ts
|
FFExtension=ts
|
||||||
FFMCustom=content_type=video/mp2ts ice_genre=fun
|
FFMCustom=content_type=video/mp2ts ice_genre=fun
|
||||||
FFIgnoreCompat=true
|
FFIgnoreCompat=true
|
||||||
RecFilePath=/home/jin/Videos/QS
|
RecFilePath=/home/jin/Videos/
|
||||||
RecFileNameWithoutSpace=true
|
RecFileNameWithoutSpace=true
|
||||||
RecFormat=mkv
|
RecFormat=mkv
|
||||||
RecEncoder=ffmpeg_nvenc
|
RecEncoder=ffmpeg_nvenc
|
||||||
File diff suppressed because it is too large
Load Diff
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
251
bridges/mpd-state.py
Executable 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)
|
||||||
120
game/index.html
120
game/index.html
@@ -19,9 +19,18 @@
|
|||||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||||
--pad-y: 40px;
|
--pad-y: 40px;
|
||||||
--pad-x: 72px;
|
--pad-x: 72px;
|
||||||
/* Camera frame size — match this in OBS when positioning the camera source */
|
/* Camera frame transform — auto-synced from OBS at runtime via
|
||||||
--cam-w: 480px;
|
GetSceneItemTransform + SceneItemTransformChanged. These defaults
|
||||||
--cam-h: 270px;
|
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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
@@ -55,32 +64,44 @@
|
|||||||
.hud > .group:nth-child(2) { justify-self: center; }
|
.hud > .group:nth-child(2) { justify-self: center; }
|
||||||
.hud > .group:nth-child(3) { justify-self: end; }
|
.hud > .group:nth-child(3) { justify-self: end; }
|
||||||
.hud .dim { color: var(--hud-dim); }
|
.hud .dim { color: var(--hud-dim); }
|
||||||
|
.hud .group > span { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
.hud .led {
|
.hud .led {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 10px; height: 10px;
|
width: 10px; height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
box-shadow: 0 0 10px var(--accent);
|
box-shadow: 0 0 10px var(--accent);
|
||||||
margin-right: 10px;
|
/* Small upward nudge: flex centers on line-box mid, but caps-only text
|
||||||
vertical-align: middle;
|
reads centered around cap-height mid which sits a touch higher. */
|
||||||
|
margin-top: -0.08em;
|
||||||
animation: rec-blink 1.6s ease-in-out infinite;
|
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 {
|
@keyframes rec-blink {
|
||||||
0%, 55% { opacity: 1; }
|
0%, 55% { opacity: 1; }
|
||||||
65%, 100% { opacity: 0.2; }
|
65%, 100% { opacity: 0.2; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Camera frame (top-right) ─────────
|
/* ───────── Camera frame ─────────
|
||||||
Decorative only — drop your camera OBS source ON TOP of this frame
|
Decorative chrome drawn UNDER the OBS Camera scene item. The four
|
||||||
(positioned to match --cam-w / --cam-h). The frame has its own dark
|
--cam-* CSS vars are auto-synced from the Camera item's transform via
|
||||||
backdrop + a centered placeholder so you can SEE where to put the
|
OBS WebSocket (see syncCamera below), so wherever you drag/resize the
|
||||||
camera; once the camera source covers it, the placeholder disappears. */
|
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 {
|
.camera-frame {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(var(--pad-y) + 60px); /* clear of the HUD */
|
top: calc(var(--cam-y) - var(--cam-pad));
|
||||||
right: var(--pad-x);
|
left: calc(var(--cam-x) - var(--cam-pad));
|
||||||
width: var(--cam-w);
|
width: calc(var(--cam-w) + var(--cam-pad) * 2);
|
||||||
height: var(--cam-h);
|
height: calc(var(--cam-h) + var(--cam-pad) * 2);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: rgba(6, 10, 18, 0.85);
|
background: rgba(6, 10, 18, 0.85);
|
||||||
border: 1.5px solid rgba(79, 210, 255, 0.55);
|
border: 1.5px solid rgba(79, 210, 255, 0.55);
|
||||||
@@ -200,28 +221,6 @@
|
|||||||
100% { opacity: 1.00; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -253,8 +252,8 @@
|
|||||||
<path d="M46 18 L60 10 L60 38 L46 30 Z" stroke="#4fd2ff" stroke-width="2" stroke-linejoin="round"/>
|
<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"/>
|
<circle cx="14" cy="14" r="2" fill="#e63a2e"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ph-text">— PLACE CAMERA HERE —</span>
|
<span class="ph-text">— WAITING FOR CAMERA —</span>
|
||||||
<span class="ph-coords">2008, 100 · 480 × 270</span>
|
<span class="ph-coords">auto-syncs from OBS scene transform</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -284,9 +283,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<canvas id="static"></canvas>
|
|
||||||
<div class="scanlines"></div>
|
|
||||||
|
|
||||||
<!-- Manifest from Project Loading. Same loading.js the Project Loading scene
|
<!-- Manifest from Project Loading. Same loading.js the Project Loading scene
|
||||||
uses, so the Game scene picks up the game / subtitle / camera / mic the
|
uses, so the Game scene picks up the game / subtitle / camera / mic the
|
||||||
streamer already entered. -->
|
streamer already entered. -->
|
||||||
@@ -344,23 +340,6 @@
|
|||||||
}
|
}
|
||||||
tickElapsed(); setInterval(tickElapsed, 1000);
|
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 ──
|
// ── OBS WS: music sync + live camera visibility ──
|
||||||
// URL params let you rename the camera source / scene without editing JS:
|
// URL params let you rename the camera source / scene without editing JS:
|
||||||
// ?camera=Webcam&scene=Game
|
// ?camera=Webcam&scene=Game
|
||||||
@@ -376,7 +355,21 @@
|
|||||||
const npTitle = document.getElementById('np-title');
|
const npTitle = document.getElementById('np-title');
|
||||||
const npTime = document.getElementById('np-time');
|
const npTime = document.getElementById('np-time');
|
||||||
const camEl = document.getElementById('cam');
|
const camEl = document.getElementById('cam');
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
function setCameraVisible(visible) { camEl.classList.toggle('off', !visible); }
|
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) {
|
async function syncCamera(obs) {
|
||||||
let camItemId = null;
|
let camItemId = null;
|
||||||
@@ -387,6 +380,9 @@
|
|||||||
const e = await obs.call('GetSceneItemEnabled',
|
const e = await obs.call('GetSceneItemEnabled',
|
||||||
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
||||||
setCameraVisible(e.sceneItemEnabled);
|
setCameraVisible(e.sceneItemEnabled);
|
||||||
|
const t = await obs.call('GetSceneItemTransform',
|
||||||
|
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
||||||
|
setCameraTransform(t.sceneItemTransform);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Source not found — keep manifest value, don't block other features.
|
// Source not found — keep manifest value, don't block other features.
|
||||||
console.warn(`[game] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
|
console.warn(`[game] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
|
||||||
@@ -394,10 +390,12 @@
|
|||||||
}
|
}
|
||||||
obs.addEventListener('event', (ev) => {
|
obs.addEventListener('event', (ev) => {
|
||||||
const d = ev.detail || {};
|
const d = ev.detail || {};
|
||||||
if (d.eventType === 'SceneItemEnableStateChanged'
|
if (d.eventData?.sceneName !== CAM_SCENE
|
||||||
&& d.eventData?.sceneName === CAM_SCENE
|
|| d.eventData?.sceneItemId !== camItemId) return;
|
||||||
&& d.eventData?.sceneItemId === camItemId) {
|
if (d.eventType === 'SceneItemEnableStateChanged') {
|
||||||
setCameraVisible(d.eventData.sceneItemEnabled);
|
setCameraVisible(d.eventData.sceneItemEnabled);
|
||||||
|
} else if (d.eventType === 'SceneItemTransformChanged') {
|
||||||
|
setCameraTransform(d.eventData.sceneItemTransform);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
545
goodbye/index.html
Normal file
545
goodbye/index.html
Normal 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 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>
|
||||||
@@ -50,16 +50,25 @@ body {
|
|||||||
.hud > .group:nth-child(2) { justify-self: center; }
|
.hud > .group:nth-child(2) { justify-self: center; }
|
||||||
.hud > .group:nth-child(3) { justify-self: end; }
|
.hud > .group:nth-child(3) { justify-self: end; }
|
||||||
.hud .dim { color: var(--hud-dim); }
|
.hud .dim { color: var(--hud-dim); }
|
||||||
|
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
|
||||||
.hud .led {
|
.hud .led {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 11px; height: 11px;
|
width: 11px; height: 11px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
box-shadow: 0 0 10px var(--accent);
|
box-shadow: 0 0 10px var(--accent);
|
||||||
margin-right: 12px;
|
/* Small upward nudge: flex centers on line-box mid, but caps-only text
|
||||||
vertical-align: middle;
|
reads centered around cap-height mid which sits a touch higher. */
|
||||||
|
margin-top: -0.08em;
|
||||||
animation: rec-blink 1.6s ease-in-out infinite;
|
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 {
|
@keyframes rec-blink {
|
||||||
0%, 55% { opacity: 1; }
|
0%, 55% { opacity: 1; }
|
||||||
65%, 100% { opacity: 0.2; }
|
65%, 100% { opacity: 0.2; }
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"collectedAt": "2026-04-25T22:10:52Z",
|
"collectedAt": "2026-04-26T20:17:38Z",
|
||||||
|
"rig": "Ignia",
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"model": "AMD Ryzen",
|
"model": "AMD Ryzen 5 2600 Six-Core Processor",
|
||||||
"threads": 12
|
"threads": 12
|
||||||
},
|
},
|
||||||
"mem": {
|
"mem": {
|
||||||
"totalGB": 32
|
"totalGB": 31.3
|
||||||
},
|
},
|
||||||
"host": {
|
"host": {
|
||||||
"kernel": "Arch Linux 6.18.23-1-lts"
|
"kernel": "6.18.23-1-lts"
|
||||||
},
|
},
|
||||||
"gpu": {
|
"gpu": {
|
||||||
"name": "NVIDIA GeForce RTX 3060 Ti",
|
"name": "NVIDIA GeForce RTX 3060 Ti",
|
||||||
"vramTotalMB": 8192
|
"vramTotalMB": 8192
|
||||||
},
|
},
|
||||||
"obs": {
|
"obs": {
|
||||||
"profile": "Untitled",
|
"profile": "ophi118",
|
||||||
"renderer": "OpenGL",
|
"renderer": "OpenGL",
|
||||||
"outputMode": "Advanced",
|
"outputMode": "Advanced",
|
||||||
"canvas": { "w": 2560, "h": 1336 },
|
"canvas": { "w": 2560, "h": 1336 },
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
--term-fg-bright: #97f99a;
|
--term-fg-bright: #97f99a;
|
||||||
--term-fg-dim: #2c8d2f;
|
--term-fg-dim: #2c8d2f;
|
||||||
--term-glow: rgba(80, 220, 100, 0.55);
|
--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;
|
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
--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(2) { justify-self: center; }
|
||||||
.hud > .group:nth-child(3) { justify-self: end; }
|
.hud > .group:nth-child(3) { justify-self: end; }
|
||||||
.hud .dim { color: var(--hud-dim); }
|
.hud .dim { color: var(--hud-dim); }
|
||||||
|
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
|
||||||
.hud .led {
|
.hud .led {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 11px; height: 11px;
|
width: 11px; height: 11px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
box-shadow: 0 0 10px var(--accent);
|
box-shadow: 0 0 10px var(--accent);
|
||||||
margin-right: 12px;
|
/* Small upward nudge: flex centers on line-box mid, but caps-only text
|
||||||
vertical-align: middle;
|
reads centered around cap-height mid which sits a touch higher. */
|
||||||
|
margin-top: -0.08em;
|
||||||
animation: rec-blink 1.6s ease-in-out infinite;
|
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 {
|
@keyframes rec-blink {
|
||||||
0%, 55% { opacity: 1; }
|
0%, 55% { opacity: 1; }
|
||||||
65%, 100% { opacity: 0.2; }
|
65%, 100% { opacity: 0.2; }
|
||||||
@@ -81,16 +90,17 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.terminal {
|
.terminal {
|
||||||
width: 64vw;
|
width: 60vw;
|
||||||
max-width: 1500px;
|
max-width: 1380px;
|
||||||
height: 100%;
|
height: 58vh;
|
||||||
min-height: 380px;
|
max-height: 740px;
|
||||||
|
min-height: 400px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--term-bg);
|
background: var(--term-bg);
|
||||||
border: 1px solid var(--term-edge);
|
border: 2px solid var(--term-edge);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 32px 40px;
|
padding: 28px 36px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@@ -158,11 +168,15 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ───────── Big countdown ───────── */
|
/* ───────── 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 { text-align: center; }
|
||||||
.countdown .label {
|
.countdown .label {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
letter-spacing: 0.45em;
|
letter-spacing: 0.45em;
|
||||||
|
text-indent: 0.45em;
|
||||||
color: var(--hud-dim);
|
color: var(--hud-dim);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
@@ -171,6 +185,7 @@ body {
|
|||||||
font-size: 132px;
|
font-size: 132px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
letter-spacing: 0.10em;
|
letter-spacing: 0.10em;
|
||||||
|
text-indent: 0.10em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
|
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
|
||||||
@@ -181,6 +196,7 @@ body {
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
letter-spacing: 0.45em;
|
letter-spacing: 0.45em;
|
||||||
|
text-indent: 0.45em;
|
||||||
color: var(--hud-dim);
|
color: var(--hud-dim);
|
||||||
}
|
}
|
||||||
@keyframes cd-pulse {
|
@keyframes cd-pulse {
|
||||||
@@ -191,6 +207,7 @@ body {
|
|||||||
color: var(--term-fg);
|
color: var(--term-fg);
|
||||||
text-shadow: 0 0 32px var(--term-glow);
|
text-shadow: 0 0 32px var(--term-glow);
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.18em;
|
||||||
|
text-indent: 0.18em;
|
||||||
}
|
}
|
||||||
.countdown.ready .sub { color: var(--term-fg-bright); }
|
.countdown.ready .sub { color: var(--term-fg-bright); }
|
||||||
.countdown.ready .label { color: var(--term-fg-dim); }
|
.countdown.ready .label { color: var(--term-fg-dim); }
|
||||||
@@ -301,9 +318,12 @@ body {
|
|||||||
<div class="vignette"></div>
|
<div class="vignette"></div>
|
||||||
<div class="flicker"></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>
|
<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
|
just for the track count in the terminal output; actual playback runs
|
||||||
in the separate Music Daemon source. -->
|
in the separate Music Daemon source. -->
|
||||||
<script src="playlist.js"></script>
|
<script src="playlist.js"></script>
|
||||||
@@ -356,9 +376,10 @@ body {
|
|||||||
const cdMin = data.countdownMin ?? 5;
|
const cdMin = data.countdownMin ?? 5;
|
||||||
|
|
||||||
const PROMPT = 'OPHI-118://> ';
|
const PROMPT = 'OPHI-118://> ';
|
||||||
|
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
|
||||||
const lines = [
|
const lines = [
|
||||||
{ kind: 'cmd', text: 'loadproject --manifest' },
|
{ kind: 'cmd', text: 'loadproject --manifest' },
|
||||||
{ kind: 'out', text: 'initializing transmission envelope...' },
|
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
|
||||||
{ kind: 'gap' },
|
{ kind: 'gap' },
|
||||||
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
|
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
|
||||||
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
|
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
|
||||||
@@ -450,7 +471,7 @@ body {
|
|||||||
let lastTrackIndex = null;
|
let lastTrackIndex = null;
|
||||||
async function connectMusic() {
|
async function connectMusic() {
|
||||||
if (!window.__OBSWS) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -482,7 +503,7 @@ body {
|
|||||||
await typeCommand('mpd --queue ~/playlists/stream-mix');
|
await typeCommand('mpd --queue ~/playlists/stream-mix');
|
||||||
|
|
||||||
if (allTracks.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
let obs = null;
|
let obs = null;
|
||||||
async function connectOBS() {
|
async function connectOBS() {
|
||||||
if (!window.__OBSWS) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
playCurrent();
|
playCurrent();
|
||||||
} else {
|
} else {
|
||||||
console.warn('[music] no tracks — run loading/playlist.sh');
|
console.warn('[music] no tracks — run scripts/playlist.sh');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
30
profile/Ignia Spec.md
Normal file
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
32
profile/Midgolem Spec.md
Normal file
File diff suppressed because one or more lines are too long
BIN
profile/logitech.png
Normal file
BIN
profile/logitech.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
BIN
profile/steelseries.png
Normal file
BIN
profile/steelseries.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
18
scripts/clean.sh
Executable file
18
scripts/clean.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/clean.sh — audit which .webm sources in playlist/ already have a
|
||||||
|
# converted .m4a sibling (produced by scripts/convert.sh). Read-only; deletes
|
||||||
|
# nothing. Run from anywhere — operates on $OBS_DIR/playlist/.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||||
|
PLAYLIST="$OBS_DIR/playlist"
|
||||||
|
|
||||||
|
[[ -d "$PLAYLIST" ]] || { echo " ✗ $PLAYLIST does not exist" >&2; exit 1; }
|
||||||
|
cd "$PLAYLIST"
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in *.webm; do
|
||||||
|
m="${f%.webm}.m4a"
|
||||||
|
[[ -f "$m" ]] && echo "OK $f → $m" || echo "MISS $f (no m4a yet, keep!)"
|
||||||
|
done
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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.
|
# ~/.config/obs-studio/playlist/ and re-encode to AAC m4a in place.
|
||||||
#
|
#
|
||||||
# WHY: yt-dlp .webm downloads include 1080p video. Chromium's <audio>
|
# WHY: yt-dlp .webm downloads include 1080p video. Chromium's <audio>
|
||||||
581
scripts/deploy-rig.sh
Executable file
581
scripts/deploy-rig.sh
Executable file
@@ -0,0 +1,581 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/deploy-rig.sh — bring up the ophi118 OBS rig from scratch.
|
||||||
|
# Idempotent: re-running is safe, every phase checks before acting.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/deploy-rig.sh # interactive, full install
|
||||||
|
# bash scripts/deploy-rig.sh --yes # non-interactive, accept all prompts
|
||||||
|
# bash scripts/deploy-rig.sh --check # validate state, write nothing
|
||||||
|
# bash scripts/deploy-rig.sh --rig-name X # override hostname-derived rig name
|
||||||
|
#
|
||||||
|
# Run on a fresh Midgolem (or re-run on Ignia to verify). After:
|
||||||
|
# git clone <repo> ~/.config/obs-studio
|
||||||
|
# cd ~/.config/obs-studio
|
||||||
|
# bash scripts/deploy-rig.sh
|
||||||
|
#
|
||||||
|
# What it covers:
|
||||||
|
# 1. Arch system packages (pacman + AUR for sansation-font)
|
||||||
|
# 2. Python user packages (python-mpd2, websockets)
|
||||||
|
# 3. Flatpak OBS Studio + filesystems=host override
|
||||||
|
# 4. Git submodules + asset directories under $HOME
|
||||||
|
# 5. PipeWire mpd_stream null sink + loopback
|
||||||
|
# 6. MPD config (zeroconf_name = this rig) + system units
|
||||||
|
# 7. Custom systemd --user units (bridge, bot)
|
||||||
|
# 8. OBS first-launch to seed obs-websocket plugin config
|
||||||
|
# 9. Secrets stub + manual next-steps report
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── globals ────────────────────────────────────────────────────────────────
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||||
|
RIG_NAME="${RIG_NAME:-$(hostname)}"
|
||||||
|
CHECK_ONLY=0
|
||||||
|
ASSUME_YES=0
|
||||||
|
|
||||||
|
# ── arg parsing ────────────────────────────────────────────────────────────
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--check) CHECK_ONLY=1; shift ;;
|
||||||
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
|
--rig-name) RIG_NAME="${2:?--rig-name needs an argument}"; shift 2 ;;
|
||||||
|
-h|--help) sed -n 's/^# \?//p' "$0" | head -28; exit 0 ;;
|
||||||
|
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
RIG_NAME_TC="${RIG_NAME^}" # title-case: ignia → Ignia
|
||||||
|
|
||||||
|
# ── output helpers ─────────────────────────────────────────────────────────
|
||||||
|
PHASE=0
|
||||||
|
TOTAL=9
|
||||||
|
step() { PHASE=$((PHASE+1)); printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$PHASE" "$TOTAL" "$*"; }
|
||||||
|
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||||
|
skip() { printf ' \033[90m·\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf ' \033[33m!\033[0m %s\n' "$*" >&2; }
|
||||||
|
err() { printf ' \033[31m✗\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { err "$*"; exit 1; }
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
(( ASSUME_YES )) && return 0
|
||||||
|
local q="$1"
|
||||||
|
read -rp " ? $q [y/N]: " a
|
||||||
|
[[ "${a,,}" =~ ^(y|yes)$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
would() {
|
||||||
|
# In --check mode, prefix the message and skip the action.
|
||||||
|
(( CHECK_ONLY )) && { skip "would: $*"; return 1; }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── pre-flight ─────────────────────────────────────────────────────────────
|
||||||
|
[[ "$OBS_DIR" == "$HOME/.config/obs-studio" ]] \
|
||||||
|
|| die "Run this from \$HOME/.config/obs-studio (current OBS_DIR=$OBS_DIR)"
|
||||||
|
|
||||||
|
cat <<BANNER
|
||||||
|
╔═══════════════════════════════════════════════════════════════╗
|
||||||
|
║ ophi118 rig deployment ║
|
||||||
|
║ rig name : $RIG_NAME_TC
|
||||||
|
║ config : $OBS_DIR
|
||||||
|
║ mode : $( ((CHECK_ONLY)) && echo '--check (no writes)' || echo 'install' )
|
||||||
|
║ confirms : $( ((ASSUME_YES)) && echo 'auto-yes' || echo 'interactive' )
|
||||||
|
╚═══════════════════════════════════════════════════════════════╝
|
||||||
|
BANNER
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 1 — system packages
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "System packages (pacman)"
|
||||||
|
|
||||||
|
PACMAN_PKGS=(
|
||||||
|
flatpak
|
||||||
|
python python-pip
|
||||||
|
pipewire pipewire-pulse pipewire-alsa wireplumber
|
||||||
|
ffmpeg
|
||||||
|
mpd mpc
|
||||||
|
avahi nss-mdns
|
||||||
|
fontconfig
|
||||||
|
)
|
||||||
|
|
||||||
|
if command -v pacman >/dev/null; then
|
||||||
|
MISSING=()
|
||||||
|
for p in "${PACMAN_PKGS[@]}"; do
|
||||||
|
pacman -Qq "$p" >/dev/null 2>&1 || MISSING+=("$p")
|
||||||
|
done
|
||||||
|
if (( ${#MISSING[@]} == 0 )); then
|
||||||
|
ok "all base packages installed"
|
||||||
|
elif would "sudo pacman -S --needed ${MISSING[*]}"; then
|
||||||
|
sudo pacman -S --needed $( ((ASSUME_YES)) && echo --noconfirm ) "${MISSING[@]}"
|
||||||
|
ok "installed ${#MISSING[@]} package(s)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NVIDIA branch
|
||||||
|
if lspci 2>/dev/null | grep -qi 'vga.*nvidia'; then
|
||||||
|
NV_MISSING=()
|
||||||
|
for p in nvidia nvidia-utils; do
|
||||||
|
pacman -Qq "$p" >/dev/null 2>&1 || NV_MISSING+=("$p")
|
||||||
|
done
|
||||||
|
if (( ${#NV_MISSING[@]} == 0 )); then
|
||||||
|
ok "NVIDIA driver present"
|
||||||
|
elif would "sudo pacman -S --needed ${NV_MISSING[*]}"; then
|
||||||
|
sudo pacman -S --needed $( ((ASSUME_YES)) && echo --noconfirm ) "${NV_MISSING[@]}"
|
||||||
|
ok "installed NVIDIA driver"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
skip "no NVIDIA GPU detected (skipping nvidia/nvidia-utils)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# AUR: sansation-font (used by the `info` text source on Game scene).
|
||||||
|
if pacman -Qq sansation-font >/dev/null 2>&1 \
|
||||||
|
|| fc-list 2>/dev/null | grep -qi 'sansation'; then
|
||||||
|
ok "Sansation font present"
|
||||||
|
else
|
||||||
|
AUR_HELPER=""
|
||||||
|
for h in paru yay; do command -v "$h" >/dev/null && { AUR_HELPER="$h"; break; }; done
|
||||||
|
if [[ -z "$AUR_HELPER" ]]; then
|
||||||
|
warn "Sansation font missing and no AUR helper (paru/yay) found"
|
||||||
|
warn " → install manually from AUR or accept the system-default fallback"
|
||||||
|
elif would "$AUR_HELPER -S sansation-font"; then
|
||||||
|
"$AUR_HELPER" -S $( ((ASSUME_YES)) && echo --noconfirm ) sansation-font || \
|
||||||
|
warn "AUR install failed — install sansation-font manually if you want the Game scene 'info' text to render exactly"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "pacman not found — this script targets Arch. Skipping system-package phase."
|
||||||
|
warn " Make sure these are installed by other means: ${PACMAN_PKGS[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 2 — Python user packages
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "Python user packages"
|
||||||
|
|
||||||
|
PIP_PKGS=(python-mpd2 websockets)
|
||||||
|
PY_MISSING=()
|
||||||
|
for p in "${PIP_PKGS[@]}"; do
|
||||||
|
python -c "import importlib, sys; importlib.import_module('${p//-/_}'.replace('python_mpd2','mpd'))" 2>/dev/null \
|
||||||
|
|| PY_MISSING+=("$p")
|
||||||
|
done
|
||||||
|
if (( ${#PY_MISSING[@]} == 0 )); then
|
||||||
|
ok "python-mpd2 and websockets importable"
|
||||||
|
elif would "pip install --user ${PY_MISSING[*]}"; then
|
||||||
|
python -m pip install --user --upgrade "${PY_MISSING[@]}"
|
||||||
|
ok "installed ${PY_MISSING[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 3 — OBS Flatpak
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "OBS Studio (Flatpak)"
|
||||||
|
|
||||||
|
if ! command -v flatpak >/dev/null; then
|
||||||
|
die "flatpak not installed — phase 1 should have done this"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! flatpak remotes --user 2>/dev/null | grep -q '^flathub' \
|
||||||
|
&& ! flatpak remotes 2>/dev/null | grep -q '^flathub'; then
|
||||||
|
if would "flatpak remote-add flathub"; then
|
||||||
|
flatpak remote-add --if-not-exists --user flathub \
|
||||||
|
https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||||
|
ok "added Flathub remote"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "Flathub remote present"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if flatpak info com.obsproject.Studio >/dev/null 2>&1; then
|
||||||
|
ok "OBS Studio flatpak installed"
|
||||||
|
else
|
||||||
|
if would "flatpak install flathub com.obsproject.Studio"; then
|
||||||
|
flatpak install $( ((ASSUME_YES)) && echo -y ) flathub com.obsproject.Studio
|
||||||
|
ok "installed OBS Studio flatpak"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if flatpak info --show-permissions com.obsproject.Studio 2>/dev/null \
|
||||||
|
| grep -q 'filesystems=.*host'; then
|
||||||
|
ok "filesystems=host override present (config dir reads from ~/.config/)"
|
||||||
|
else
|
||||||
|
if would "flatpak override --user com.obsproject.Studio --filesystems=host"; then
|
||||||
|
flatpak override --user com.obsproject.Studio --filesystems=host
|
||||||
|
ok "added filesystems=host override"
|
||||||
|
else
|
||||||
|
warn "without filesystems=host, OBS will read from ~/.var/app/... not from this dir!"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 4 — submodules + asset dirs
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "Submodules + asset directories"
|
||||||
|
|
||||||
|
if [[ -f "$OBS_DIR/.gitmodules" ]]; then
|
||||||
|
if would "git submodule update --init --recursive"; then
|
||||||
|
( cd "$OBS_DIR" && git submodule update --init --recursive )
|
||||||
|
ok "submodules initialized"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ASSET_DIRS=(
|
||||||
|
"$HOME/Videos"
|
||||||
|
"$HOME/HDD/Music"
|
||||||
|
"$HOME/HDD/Images/Gifs"
|
||||||
|
"$HOME/cloud.jakubzych.com/_img/logos"
|
||||||
|
)
|
||||||
|
for d in "${ASSET_DIRS[@]}"; do
|
||||||
|
if [[ -d "$d" ]]; then
|
||||||
|
ok "exists: $d"
|
||||||
|
elif would "mkdir -p $d"; then
|
||||||
|
mkdir -p "$d"
|
||||||
|
ok "created: $d"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
ASSET_FILES=(
|
||||||
|
"$HOME/HDD/Images/Gifs/stamd.png:Game-scene 'Starting Soon' image"
|
||||||
|
"$HOME/cloud.jakubzych.com/_img/logos/dgw.png:Doomguard watermark"
|
||||||
|
)
|
||||||
|
MISSING_ASSETS=0
|
||||||
|
for entry in "${ASSET_FILES[@]}"; do
|
||||||
|
path="${entry%%:*}"; what="${entry##*:}"
|
||||||
|
if [[ -f "$path" ]]; then
|
||||||
|
ok "asset present: $path"
|
||||||
|
else
|
||||||
|
warn "missing asset: $path ($what)"
|
||||||
|
MISSING_ASSETS=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
(( MISSING_ASSETS )) && warn " → bring these over manually from Ignia; OBS will surface 'missing source' errors otherwise"
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 5 — PipeWire mpd_stream sink
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "PipeWire null sink (mpd_stream)"
|
||||||
|
|
||||||
|
PW_CONF_DIR="$HOME/.config/pipewire/pipewire-pulse.conf.d"
|
||||||
|
PW_CONF="$PW_CONF_DIR/mpd-stream.conf"
|
||||||
|
|
||||||
|
if [[ -f "$PW_CONF" ]]; then
|
||||||
|
ok "$PW_CONF already present"
|
||||||
|
else
|
||||||
|
if would "write $PW_CONF"; then
|
||||||
|
mkdir -p "$PW_CONF_DIR"
|
||||||
|
cat > "$PW_CONF" <<'PWCONF'
|
||||||
|
# Persistent virtual sink for streaming MPD into OBS without mixing in
|
||||||
|
# desktop/browser/Discord audio.
|
||||||
|
#
|
||||||
|
# MPD ──► null sink "mpd_stream"
|
||||||
|
# │
|
||||||
|
# ├──► loopback ──► default sink (so you still hear it on speakers)
|
||||||
|
# │
|
||||||
|
# └──► monitor ──► OBS "Audio Output Capture (PulseAudio) →
|
||||||
|
# Monitor of MPD-Stream"
|
||||||
|
#
|
||||||
|
# Wired on the MPD side via target "mpd_stream" in ~/.config/mpd/mpd.conf.
|
||||||
|
# Loaded automatically when pipewire-pulse starts.
|
||||||
|
|
||||||
|
pulse.cmd = [
|
||||||
|
{
|
||||||
|
cmd = "load-module"
|
||||||
|
args = "module-null-sink sink_name=mpd_stream sink_properties=device.description=MPD-Stream"
|
||||||
|
}
|
||||||
|
{
|
||||||
|
cmd = "load-module"
|
||||||
|
args = "module-loopback source=mpd_stream.monitor latency_msec=50"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
PWCONF
|
||||||
|
ok "wrote $PW_CONF"
|
||||||
|
if would "systemctl --user restart pipewire-pulse"; then
|
||||||
|
systemctl --user restart pipewire-pulse.service 2>/dev/null || \
|
||||||
|
warn "could not restart pipewire-pulse (will load on next login anyway)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the sink is actually loaded (only meaningful in install mode).
|
||||||
|
if (( ! CHECK_ONLY )); then
|
||||||
|
sleep 1
|
||||||
|
if pactl list short sinks 2>/dev/null | grep -q '^[0-9]*[[:space:]]\+mpd_stream\b'; then
|
||||||
|
ok "mpd_stream sink loaded"
|
||||||
|
else
|
||||||
|
warn "mpd_stream sink NOT visible to pactl — restart pipewire-pulse or relog and re-check"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 6 — MPD
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "MPD config + service"
|
||||||
|
|
||||||
|
MPD_DIR="$HOME/.config/mpd"
|
||||||
|
MPD_CONF="$MPD_DIR/mpd.conf"
|
||||||
|
|
||||||
|
write_mpd_conf() {
|
||||||
|
mkdir -p "$MPD_DIR/playlists"
|
||||||
|
cat > "$MPD_CONF" <<MPDCONF
|
||||||
|
# ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh.
|
||||||
|
# Re-run the deploy script to regenerate, or hand-edit for local tweaks.
|
||||||
|
|
||||||
|
music_directory "~/HDD/Music"
|
||||||
|
playlist_directory "~/.config/mpd/playlists"
|
||||||
|
db_file "~/.config/mpd/database"
|
||||||
|
state_file "~/.config/mpd/state"
|
||||||
|
sticker_file "~/.config/mpd/sticker.sql"
|
||||||
|
|
||||||
|
log_file "syslog"
|
||||||
|
auto_update "yes"
|
||||||
|
restore_paused "yes"
|
||||||
|
filesystem_charset "UTF-8"
|
||||||
|
|
||||||
|
zeroconf_enabled "yes"
|
||||||
|
zeroconf_name "MPD on $RIG_NAME_TC"
|
||||||
|
|
||||||
|
audio_output {
|
||||||
|
type "pipewire"
|
||||||
|
target "mpd_stream"
|
||||||
|
name "MPD"
|
||||||
|
}
|
||||||
|
MPDCONF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -f "$MPD_CONF" ]]; then
|
||||||
|
if grep -q "MPD on $RIG_NAME_TC" "$MPD_CONF"; then
|
||||||
|
ok "$MPD_CONF already configured for $RIG_NAME_TC"
|
||||||
|
else
|
||||||
|
warn "$MPD_CONF exists but zeroconf_name doesn't match $RIG_NAME_TC"
|
||||||
|
if confirm "overwrite $MPD_CONF?" && would "rewrite $MPD_CONF"; then
|
||||||
|
cp "$MPD_CONF" "$MPD_CONF.before-deploy.$$"
|
||||||
|
write_mpd_conf
|
||||||
|
ok "rewrote $MPD_CONF (backup: $MPD_CONF.before-deploy.$$)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if would "write $MPD_CONF"; then
|
||||||
|
write_mpd_conf
|
||||||
|
ok "wrote $MPD_CONF"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if systemctl --user is-enabled mpd.socket >/dev/null 2>&1; then
|
||||||
|
ok "mpd.socket enabled"
|
||||||
|
else
|
||||||
|
if would "systemctl --user enable --now mpd.socket mpd.service"; then
|
||||||
|
systemctl --user enable --now mpd.socket mpd.service
|
||||||
|
ok "enabled mpd.socket + mpd.service"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "$HOME/HDD/Music" ]] && [[ -n "$(find "$HOME/HDD/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then
|
||||||
|
if would "mpc update"; then
|
||||||
|
mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)"
|
||||||
|
ok "MPD library scan kicked off"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "~/HDD/Music is empty — MPD will have no tracks until you populate it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Avahi for zeroconf advertisement (system service).
|
||||||
|
if systemctl is-enabled avahi-daemon.service >/dev/null 2>&1; then
|
||||||
|
ok "avahi-daemon enabled (system)"
|
||||||
|
else
|
||||||
|
warn "avahi-daemon not enabled — MPD zeroconf won't advertise. To fix:"
|
||||||
|
warn " sudo systemctl enable --now avahi-daemon.service"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 7 — custom systemd --user units
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "Systemd --user units (bridge + bot)"
|
||||||
|
|
||||||
|
UNIT_DIR="$HOME/.config/systemd/user"
|
||||||
|
mkdir -p "$UNIT_DIR"
|
||||||
|
|
||||||
|
write_unit() {
|
||||||
|
local name body path
|
||||||
|
name="$1"
|
||||||
|
body="$2"
|
||||||
|
path="$UNIT_DIR/$name"
|
||||||
|
if [[ -f "$path" ]] && diff -q <(printf '%s' "$body") "$path" >/dev/null 2>&1; then
|
||||||
|
ok "$name already up to date"
|
||||||
|
elif would "write $path"; then
|
||||||
|
printf '%s' "$body" > "$path"
|
||||||
|
ok "wrote $path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_unit obs-mpd-bridge.service "[Unit]
|
||||||
|
Description=MPD → OBS WebSocket state bridge
|
||||||
|
Documentation=file:%h/.config/obs-studio/bridges/mpd-state.py
|
||||||
|
After=mpd.service mpd.socket pipewire.service
|
||||||
|
Wants=mpd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 %h/.config/obs-studio/bridges/mpd-state.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
"
|
||||||
|
|
||||||
|
write_unit obs-twitch-bot.service "[Unit]
|
||||||
|
Description=Twitch chat → MPD control bot
|
||||||
|
Documentation=file:%h/.config/obs-studio/twitch-bot/bot.py
|
||||||
|
After=mpd.service mpd.socket network-online.target
|
||||||
|
Wants=mpd.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/bin/python3 %h/.config/obs-studio/twitch-bot/bot.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
"
|
||||||
|
|
||||||
|
if would "systemctl --user daemon-reload"; then
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
fi
|
||||||
|
|
||||||
|
# bridge can start now (no secrets needed)
|
||||||
|
if systemctl --user is-enabled obs-mpd-bridge.service >/dev/null 2>&1; then
|
||||||
|
ok "obs-mpd-bridge.service enabled"
|
||||||
|
else
|
||||||
|
if would "systemctl --user enable --now obs-mpd-bridge.service"; then
|
||||||
|
systemctl --user enable --now obs-mpd-bridge.service || \
|
||||||
|
warn "obs-mpd-bridge failed to start — needs vendor/obs-config.js (see phase 8)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# bot must NOT auto-start until .env is populated; just enable it
|
||||||
|
if systemctl --user is-enabled obs-twitch-bot.service >/dev/null 2>&1; then
|
||||||
|
ok "obs-twitch-bot.service enabled (start manually after .env is filled)"
|
||||||
|
else
|
||||||
|
if would "systemctl --user enable obs-twitch-bot.service"; then
|
||||||
|
systemctl --user enable obs-twitch-bot.service
|
||||||
|
ok "enabled obs-twitch-bot.service (NOT started — needs .env)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 8 — OBS first-launch + WebSocket bootstrap
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "OBS first-launch + WebSocket bootstrap"
|
||||||
|
|
||||||
|
WS_CFG="$OBS_DIR/plugin_config/obs-websocket/config.json"
|
||||||
|
|
||||||
|
if [[ -f "$WS_CFG" ]]; then
|
||||||
|
ok "obs-websocket config exists ($WS_CFG)"
|
||||||
|
else
|
||||||
|
if would "launch OBS once to seed plugin_config"; then
|
||||||
|
echo " → launching OBS once to seed plugin configs."
|
||||||
|
echo " Close the OBS window when it appears (or wait — script auto-stops it)."
|
||||||
|
flatpak run com.obsproject.Studio >/dev/null 2>&1 &
|
||||||
|
OBS_PID=$!
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
[[ -f "$WS_CFG" ]] && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
kill "$OBS_PID" 2>/dev/null || true
|
||||||
|
wait "$OBS_PID" 2>/dev/null || true
|
||||||
|
if [[ -f "$WS_CFG" ]]; then
|
||||||
|
ok "obs-websocket config seeded"
|
||||||
|
else
|
||||||
|
warn "config not seeded after 60s — start OBS manually once and re-run"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$WS_CFG" ]]; then
|
||||||
|
if python3 -c "import json, sys; sys.exit(0 if json.load(open('$WS_CFG')).get('server_enabled') else 1)" 2>/dev/null; then
|
||||||
|
ok "obs-websocket server_enabled = true"
|
||||||
|
else
|
||||||
|
if would "set server_enabled=true in $WS_CFG"; then
|
||||||
|
python3 - "$WS_CFG" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
p = sys.argv[1]
|
||||||
|
d = json.load(open(p))
|
||||||
|
d['server_enabled'] = True
|
||||||
|
json.dump(d, open(p, 'w'), indent=4)
|
||||||
|
PY
|
||||||
|
ok "set server_enabled=true (takes effect on next OBS launch)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "$OBS_DIR/vendor/obs-config.js" ]]; then
|
||||||
|
ok "vendor/obs-config.js present"
|
||||||
|
else
|
||||||
|
if would "bash scripts/setup.sh"; then
|
||||||
|
bash "$DIR/setup.sh" || warn "setup.sh failed — re-run after OBS WS port is reachable"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 9 — secrets stub + final report
|
||||||
|
# ────────────────────────────────────────────────────────────────────────────
|
||||||
|
step "Secrets stub + final report"
|
||||||
|
|
||||||
|
stub_env() {
|
||||||
|
local target="$1"
|
||||||
|
if [[ -f "$target" ]]; then
|
||||||
|
ok "$target exists (leaving alone)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -f "$OBS_DIR/twitch-bot/.env.example" ]] && would "stub $target from .env.example"; then
|
||||||
|
cp "$OBS_DIR/twitch-bot/.env.example" "$target"
|
||||||
|
chmod 600 "$target"
|
||||||
|
ok "stubbed $target (chmod 600)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
stub_env "$OBS_DIR/twitch-bot/.env"
|
||||||
|
stub_env "$OBS_DIR/twitch-bot/.env.ophi118"
|
||||||
|
|
||||||
|
# Refresh telemetry so the rig name is correct on this machine.
|
||||||
|
if (( ! CHECK_ONLY )); then
|
||||||
|
if would "scripts/telemetry.sh --collect --no-review (rig snapshot)"; then
|
||||||
|
bash "$DIR/telemetry.sh" --collect --no-review || \
|
||||||
|
warn "telemetry.sh --collect failed — re-run interactively to review"
|
||||||
|
ok "telemetry refreshed (rig=$RIG_NAME_TC)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<DONE
|
||||||
|
|
||||||
|
╔═══════════════════════════════════════════════════════════════╗
|
||||||
|
║ Deployment complete — manual steps remaining: ║
|
||||||
|
╚═══════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
1. Twitch bot token (chat:read chat:edit)
|
||||||
|
https://twitchtokengenerator.com
|
||||||
|
→ fill twitch-bot/.env
|
||||||
|
|
||||||
|
2. Broadcaster token (channel:manage:broadcast)
|
||||||
|
https://twitchtokengenerator.com
|
||||||
|
→ fill twitch-bot/.env.ophi118
|
||||||
|
|
||||||
|
3. Twitch stream key
|
||||||
|
Twitch Dashboard → Settings → Stream → Copy stream key
|
||||||
|
→ paste into OBS Settings → Stream
|
||||||
|
(saved to basic/profiles/ophi118/service.json)
|
||||||
|
|
||||||
|
4. Once .env is populated:
|
||||||
|
systemctl --user restart obs-twitch-bot.service
|
||||||
|
|
||||||
|
5. Review telemetry interactively:
|
||||||
|
bash scripts/telemetry.sh --collect
|
||||||
|
|
||||||
|
6. Launch OBS:
|
||||||
|
flatpak run com.obsproject.Studio
|
||||||
|
|
||||||
|
Sanity checks:
|
||||||
|
pactl list short sinks | grep mpd_stream
|
||||||
|
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
|
||||||
|
mpc status
|
||||||
|
ss -tlnp | grep 4455
|
||||||
|
|
||||||
|
DONE
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
JSON="$DIR/loading.json"
|
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||||
JS="$DIR/loading.js"
|
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;
|
# Reads a top-level field from the previous loading.json. Always exits 0;
|
||||||
# prints empty string if file/field is missing.
|
# 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 ───────────────
|
# ── Pull previous answers as defaults ───────────────
|
||||||
prev_game=$(prev game)
|
prev_game=$(prev game)
|
||||||
|
prev_game_id=$(prev gameId)
|
||||||
prev_subtitle=$(prev subtitle)
|
prev_subtitle=$(prev subtitle)
|
||||||
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
|
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
|
||||||
prev_camera=$(prev camera); [[ -z "$prev_camera" ]] && prev_camera="y"
|
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 " (press Enter to keep [defaults])"
|
||||||
echo
|
echo
|
||||||
|
|
||||||
game=$(ask "Game" "$prev_game")
|
# Resolve Game via Twitch search → exact directory entry. The picker prints
|
||||||
while [[ -z "$game" ]]; do
|
# "<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
|
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
|
done
|
||||||
|
|
||||||
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
|
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
|
||||||
@@ -98,6 +132,7 @@ cat > "$tmp" <<JSON
|
|||||||
{
|
{
|
||||||
"compiledAt": "$now_iso",
|
"compiledAt": "$now_iso",
|
||||||
"game": $(emit_str "$game"),
|
"game": $(emit_str "$game"),
|
||||||
|
"gameId": $(emit_str "$game_id"),
|
||||||
"subtitle": $(emit_str "$subtitle"),
|
"subtitle": $(emit_str "$subtitle"),
|
||||||
"countdownMin": $countdown,
|
"countdownMin": $countdown,
|
||||||
"camera": $camera,
|
"camera": $camera,
|
||||||
@@ -116,4 +151,22 @@ mv -f "$JS.tmp.$$" "$JS"
|
|||||||
echo
|
echo
|
||||||
echo " ✓ wrote $JSON"
|
echo " ✓ wrote $JSON"
|
||||||
echo " ✓ wrote $JS"
|
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."
|
echo " → refresh the Project Loading browser source in OBS."
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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:
|
# Usage:
|
||||||
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
||||||
@@ -20,10 +20,16 @@ set -euo pipefail
|
|||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||||
AUDIO_DIR="$OBS_DIR/playlist"
|
JSON="$OBS_DIR/loading/playlist.json"
|
||||||
JSON="$DIR/playlist.json"
|
JS="$OBS_DIR/loading/playlist.js"
|
||||||
JS="$DIR/playlist.js"
|
CMD_JS="$OBS_DIR/loading/cmd.js"
|
||||||
CMD_JS="$DIR/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() {
|
usage() {
|
||||||
sed -n 's/^# \?//p' "$0" | head -20
|
sed -n 's/^# \?//p' "$0" | head -20
|
||||||
@@ -31,23 +37,22 @@ usage() {
|
|||||||
|
|
||||||
# ─────────────── sync ───────────────
|
# ─────────────── sync ───────────────
|
||||||
cmd_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
|
if ! command -v ffprobe >/dev/null 2>&1; then
|
||||||
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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.$$"
|
local TMP="$JSON.tmp.$$"
|
||||||
python3 - "$AUDIO_DIR" "$TMP" <<'PY'
|
python3 - "${ROOTS[@]}" "$TMP" <<'PY'
|
||||||
import json, subprocess, sys, os, datetime, re
|
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')
|
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
|
||||||
|
|
||||||
def ffprobe_meta(path):
|
def ffprobe_meta(path):
|
||||||
@@ -87,28 +92,32 @@ def is_audio_file(name):
|
|||||||
if INTERMEDIATE.search(base): return False
|
if INTERMEDIATE.search(base): return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
YT_ID_RX = re.compile(r'\[([A-Za-z0-9_-]{11})\]')
|
# Absolute file:// URI — works across roots since the daemon HTML and the audio
|
||||||
files = sorted(f for f in os.listdir(audio_dir) if is_audio_file(f))
|
# 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()
|
tracks = []
|
||||||
for fname in files:
|
for root in roots:
|
||||||
m = YT_ID_RX.search(fname)
|
if not os.path.isdir(root):
|
||||||
yt_id = m.group(1) if m else None
|
print(f' ! skipping missing root: {root}', file=sys.stderr)
|
||||||
if yt_id:
|
continue
|
||||||
if yt_id in seen_ids: continue
|
for dirpath, _, files in os.walk(root):
|
||||||
seen_ids.add(yt_id)
|
for fname in sorted(files):
|
||||||
path = os.path.join(audio_dir, fname)
|
if not is_audio_file(fname): continue
|
||||||
|
path = os.path.join(dirpath, fname)
|
||||||
meta = ffprobe_meta(path)
|
meta = ffprobe_meta(path)
|
||||||
display = clean_title(os.path.splitext(fname)[0])
|
display = clean_title(os.path.splitext(fname)[0])
|
||||||
tracks.append({
|
tracks.append({
|
||||||
'title': display,
|
'title': display,
|
||||||
'file': f"../playlist/{fname}",
|
'file': file_uri(path),
|
||||||
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
doc = {
|
doc = {
|
||||||
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
'sourceDir': audio_dir,
|
'sourceDirs': roots,
|
||||||
'trackCount': len(tracks),
|
'trackCount': len(tracks),
|
||||||
'tracks': tracks,
|
'tracks': tracks,
|
||||||
}
|
}
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
# One-time setup: builds vendor/obs-config.js from your existing OBS WebSocket
|
# One-time setup: builds vendor/obs-config.js from your existing OBS WebSocket
|
||||||
# plugin config so the overlay/daemon pages can connect.
|
# 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.
|
# Re-run if you change the WebSocket port or password in OBS.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SRC="$DIR/plugin_config/obs-websocket/config.json"
|
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||||
OUT="$DIR/vendor/obs-config.js"
|
SRC="$OBS_DIR/plugin_config/obs-websocket/config.json"
|
||||||
|
OUT="$OBS_DIR/vendor/obs-config.js"
|
||||||
|
|
||||||
if [[ ! -f "$SRC" ]]; then
|
if [[ ! -f "$SRC" ]]; then
|
||||||
echo " ✗ $SRC not found." >&2
|
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())
|
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
|
cat > "$OUT" <<JS
|
||||||
// Auto-generated by setup.sh — gitignored. Reflects the current contents of
|
// Auto-generated by scripts/setup.sh — gitignored. Reflects the current contents
|
||||||
// plugin_config/obs-websocket/config.json. Re-run setup.sh if it changes.
|
// of plugin_config/obs-websocket/config.json. Re-run scripts/setup.sh if it changes.
|
||||||
window.__OBSWS = {
|
window.__OBSWS = {
|
||||||
url: 'ws://localhost:$PORT',
|
url: 'ws://localhost:$PORT',
|
||||||
password: '$PASS',
|
password: '$PASS',
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Static device + OBS config snapshot for the OBS Landing overlay.
|
# 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
|
# ./telemetry.sh — wrap telemetry.json → telemetry.js
|
||||||
# (idempotent; preserves any hand edits to the JSON)
|
# (idempotent; preserves hand edits to JSON)
|
||||||
# ./telemetry.sh --collect — re-read hardware + OBS config, overwrite
|
# ./telemetry.sh --collect — re-read hardware + OBS + hostname,
|
||||||
# telemetry.json, then wrap → telemetry.js
|
# 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.
|
# 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.
|
# 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)"
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||||
JSON="$DIR/telemetry.json"
|
JSON="$OBS_DIR/landing/telemetry.json"
|
||||||
JS="$DIR/telemetry.js"
|
JS="$OBS_DIR/landing/telemetry.js"
|
||||||
|
|
||||||
sanitize() {
|
sanitize() {
|
||||||
printf '%s' "${1:-}" \
|
printf '%s' "${1:-}" \
|
||||||
@@ -167,6 +171,11 @@ collect() {
|
|||||||
|
|
||||||
local kernel; kernel=$(uname -r)
|
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=""
|
local gpu_name="" gpu_vram_total_mb=""
|
||||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||||
local q
|
local q
|
||||||
@@ -181,6 +190,7 @@ collect() {
|
|||||||
|
|
||||||
cpu_model=$(sanitize "$cpu_model")
|
cpu_model=$(sanitize "$cpu_model")
|
||||||
kernel=$(sanitize "$kernel")
|
kernel=$(sanitize "$kernel")
|
||||||
|
rig=$(sanitize "$rig")
|
||||||
local now_iso; now_iso=$(date -u +%FT%TZ)
|
local now_iso; now_iso=$(date -u +%FT%TZ)
|
||||||
|
|
||||||
local obs_block; obs_block=$(collect_obs)
|
local obs_block; obs_block=$(collect_obs)
|
||||||
@@ -189,6 +199,7 @@ collect() {
|
|||||||
cat > "$tmp" <<JSON
|
cat > "$tmp" <<JSON
|
||||||
{
|
{
|
||||||
"collectedAt": "$now_iso",
|
"collectedAt": "$now_iso",
|
||||||
|
"rig": $(emit_str "$rig"),
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"model": $(emit_str "$cpu_model"),
|
"model": $(emit_str "$cpu_model"),
|
||||||
"threads": $(emit_num "$cpu_threads")
|
"threads": $(emit_num "$cpu_threads")
|
||||||
@@ -216,6 +227,79 @@ JSON
|
|||||||
echo "wrote $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
|
# 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.
|
# via a <script> tag. fetch() on file:// is blocked in CEF; script-tag isn't.
|
||||||
wrap_js() {
|
wrap_js() {
|
||||||
@@ -233,5 +317,20 @@ wrap_js() {
|
|||||||
echo "wrote $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
|
wrap_js
|
||||||
1
station
Submodule
1
station
Submodule
Submodule station added at b92e514a2a
112
twitch-bot/_twitch.py
Normal file
112
twitch-bot/_twitch.py
Normal 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
104
twitch-bot/search-game.py
Executable 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
102
twitch-bot/set-channel.py
Executable 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)
|
||||||
12
user.ini
12
user.ini
@@ -14,10 +14,10 @@ ConfirmOnExit=true
|
|||||||
HotkeyFocusType=NeverDisableHotkeys
|
HotkeyFocusType=NeverDisableHotkeys
|
||||||
|
|
||||||
[Basic]
|
[Basic]
|
||||||
SceneCollection=Untitled
|
SceneCollection=Default Stream HUD
|
||||||
SceneCollectionFile=Untitled.json
|
SceneCollectionFile=Default_Stream_HUD.json
|
||||||
Profile=Untitled
|
Profile=ophi118
|
||||||
ProfileDir=Untitled
|
ProfileDir=ophi118
|
||||||
ConfigOnNewProfile=true
|
ConfigOnNewProfile=true
|
||||||
|
|
||||||
[PropertiesWindow]
|
[PropertiesWindow]
|
||||||
@@ -25,8 +25,8 @@ cx=720
|
|||||||
cy=580
|
cy=580
|
||||||
|
|
||||||
[BasicWindow]
|
[BasicWindow]
|
||||||
geometry=AdnQywADAAAAAAAAAAAAAAAACf8AAAVDAAACgQAAABkAAAn+AAAEHAAAAAECAAAACgAAAAAAAAAAAAAACf8AAAVD
|
geometry=AdnQywADAAAAAAoAAAAAAAAAEX8AAAQDAAAAAAAAAAAAAAO/AAAEAwAAAAACAAAAB4AAAAoAAAAAAAAAEX8AAAQD
|
||||||
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAoAAAABLPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAfMAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAH3AAAB6AAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAAD4wAAAn0AAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABmQAAAHQAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAg4AAAByAAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAACgAAAAPYAAAABAAAAAQAAAAIAAAACPwAAAAA
|
DockState=AAAA/wAAAAD9AAAAAQAAAAMAAAeAAAABKPwBAAAABvsAAAAUAHMAYwBlAG4AZQBzAEQAbwBjAGsBAAAAAAAAAWEAAACYAP////sAAAAWAHMAbwB1AHIAYwBlAHMARABvAGMAawEAAAFlAAABXwAAAJgA////+wAAABIAbQBpAHgAZQByAEQAbwBjAGsBAAACyAAAAgoAAAECAP////sAAAAeAHQAcgBhAG4AcwBpAHQAaQBvAG4AcwBEAG8AYwBrAQAABNYAAAFdAAAArQD////7AAAAGABjAG8AbgB0AHIAbwBsAHMARABvAGMAawEAAAY3AAABSQAAAKIA////+wAAABIAcwB0AGEAdABzAEQAbwBjAGsCAAAExAAAAXAAAAK8AAAAyAAAB4AAAAKcAAAABAAAAAQAAAAIAAAACPwAAAAA
|
||||||
PreviewEnabled=true
|
PreviewEnabled=true
|
||||||
AlwaysOnTop=false
|
AlwaysOnTop=false
|
||||||
SceneDuplicationMode=true
|
SceneDuplicationMode=true
|
||||||
|
|||||||
Reference in New Issue
Block a user