MPD and Scene fixes + twitch-bot cleanup

This commit is contained in:
Jakub Zych
2026-04-28 12:13:42 +02:00
parent 4c818e327b
commit 32be7e4073
16 changed files with 858 additions and 972 deletions

1
.gitignore vendored
View File

@@ -98,3 +98,4 @@ Thumbs.db
__pycache__/ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
temp

View File

@@ -76,30 +76,43 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
## Audio architecture ## Audio architecture
Two parallel isolated streams into OBS — one for MPD music, one for Discord voice — so neither mixes with the rest of the desktop, and OBS gets independent meters / per-source volume for each.
``` ```
┌────────────┐ ┌────────────┐ ┌────────────
MPDuser systemd: mpd.socket + mpd.service MPD systemd user Discord │ Flatpak / native
└──────┬───── └──────┬─────┘ unit └──────┬──────┘ app
PipeWire output: target = "mpd_stream" │ PipeWire target= │ pavucontrol → "Discord-Stream"
│ "mpd_stream" │ (one-time per rig)
┌────────────────────────┐
│ 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" │ null sink "mpd_stream" │ │ null sink "discord_stream"
└──────┬──────────┬──────┘ └──────┬─────────────┬───────┘
│ │ │ │
loopback│ │ monitor loopback│ │ monitor
▼ ▼ ▼ ▼
default OBS default OBS
sink pulse_output_ sink pulse_output_
capture capture
"MPD" "Discord"
(defined in pipewire-pulse.conf.d/
mpd-stream.conf, discord-stream.conf)
Twitch 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. The dedicated null sinks are the load-bearing piece: without them, OBS would either pick up *all* desktop audio (browser, system, every app) or *none*. The loopback modules mirror each null sink back to the user's actual default sink so MPD and Discord are still audible locally while OBS captures the isolated monitors.
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. **Routing the apps to their sinks:**
- **MPD** binds via `target "mpd_stream"` in `~/.config/mpd/mpd.conf` — automatic.
- **Discord** has no equivalent target setting. One-time per rig: open pavucontrol → Playback tab → while Discord plays sound, set its dropdown to **Discord-Stream**. PipeWire remembers across launches via `module-stream-restore`.
Sanity check after login:
```bash
pactl list short sinks | grep -E 'mpd_stream|discord_stream' # both should appear
```
If either is missing, `pipewire-pulse` didn't load the conf. Either relog or stop OBS and `systemctl --user restart pipewire-pulse.service` (do **not** restart pipewire-pulse while OBS is running — see "Critical gotcha" below).
## MPD setup ## MPD setup
@@ -188,6 +201,13 @@ These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fres
- All scripts in `scripts/` - All scripts in `scripts/`
- `vendor/obs-ws-mini.js` - `vendor/obs-ws-mini.js`
**Outside this repo but written by `scripts/deploy-rig.sh` (identical across rigs):**
- `~/.config/pipewire/pipewire-pulse.conf.d/mpd-stream.conf` — MPD null sink + loopback
- `~/.config/pipewire/pipewire-pulse.conf.d/discord-stream.conf` — Discord null sink + loopback
**Manual one-time per rig (no automation):**
- pavucontrol → Playback → Discord → set output to **Discord-Stream** (PipeWire remembers across launches; Discord has no equivalent of MPD's `target` config).
## 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`).
@@ -195,6 +215,7 @@ These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fres
- **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-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-26 (later):** Music Box scene added (`music-box/index.html`, full-screen) plus sidebar widget (`music-box/widget.html`) for the Desktop scene. Desktop scene reworked Game-style — camera in right sidebar with the Music Box widget directly below. Desktop HUD forked from Game HUD into `desktop/index.html` (no game-name slot). Bridge payload extended with `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile`. Bot gained `!queue` (next 3 tracks) and `!info` (full metadata) commands; `!skip` cooldown shifted from global 5s to per-user 60s. - **2026-04-26 (later):** Music Box scene added (`music-box/index.html`, full-screen) plus sidebar widget (`music-box/widget.html`) for the Desktop scene. Desktop scene reworked Game-style — camera in right sidebar with the Music Box widget directly below. Desktop HUD forked from Game HUD into `desktop/index.html` (no game-name slot). Bridge payload extended with `nextTitle`/`nextArtist`/`nextTrackTitle`/`nextFile`. Bot gained `!queue` (next 3 tracks) and `!info` (full metadata) commands; `!skip` cooldown shifted from global 5s to per-user 60s.
- **2026-04-27:** Music Box overlays rewritten as proper terminals (boot sequence + scrolling `[audio] now: …` history + static-HTML pinned block + ellipsis-clipped track titles). Music Box overlay gained a dedicated chat status strip between the terminal and the banner so the command help stays visible regardless of what's scrolling. Music Box widget resized to 549×880 to pair with camera width. Bridge payload gained `nextTitles[]` (array of next 3 display strings) so Music Box can show 3-up "coming next". Bot gained `live_watch()` — optional Mattermost going-live notifier driven by `MM_HOOK` env var. Landing top HUD identifier upgraded to `OPHI-118 // [<RIG>]` with cyberpunk decode/scramble reveal animation reading `window.__TEL.rig` from telemetry. - **2026-04-27:** Music Box overlays rewritten as proper terminals (boot sequence + scrolling `[audio] now: …` history + static-HTML pinned block + ellipsis-clipped track titles). Music Box overlay gained a dedicated chat status strip between the terminal and the banner so the command help stays visible regardless of what's scrolling. Music Box widget resized to 549×880 to pair with camera width. Bridge payload gained `nextTitles[]` (array of next 3 display strings) so Music Box can show 3-up "coming next". Bot gained `live_watch()` — optional Mattermost going-live notifier driven by `MM_HOOK` env var. Landing top HUD identifier upgraded to `OPHI-118 // [<RIG>]` with cyberpunk decode/scramble reveal animation reading `window.__TEL.rig` from telemetry.
- **2026-04-28:** Discord audio split out to its own isolated stream — `~/.config/pipewire/pipewire-pulse.conf.d/discord-stream.conf` adds a `discord_stream` null sink + loopback (mirrors the existing `mpd-stream.conf` pattern). New OBS source `Discord` (`pulse_output_capture` on `discord_stream.monitor`) added to scenes Game / Desktop / Desktop (No Cam) / Music Box. `scripts/deploy-rig.sh` phase 5 now provisions both sinks and skips the `pipewire-pulse` restart when OBS is running (a restart silently detaches OBS audio sources — fix needs an OBS process restart, not just a settings refresh). Discord routing to its sink is the only manual per-rig step (pavucontrol → Playback → Discord → Discord-Stream).
- 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.
## Critical gotcha: OBS overwrites on exit ## Critical gotcha: OBS overwrites on exit
@@ -212,6 +233,16 @@ Do NOT use `pgrep -fa 'flatpak run com.obsproject.Studio'` — it almost never m
OBS itself maintains `.bak` (previous save) and `.json.v1` (pre-version-migration) sidecars in `basic/scenes/`. Those are not git history — leave them alone unless explicitly recovering. OBS itself maintains `.bak` (previous save) and `.json.v1` (pre-version-migration) sidecars in `basic/scenes/`. Those are not git history — leave them alone unless explicitly recovering.
## Critical gotcha: don't restart pipewire-pulse while OBS is running
`systemctl --user restart pipewire-pulse.service` tears down every loopback / null-sink module, which silently detaches OBS's `pulse_output_capture` sources from their monitor devices. Even after the conf reloads and the sinks come back with the same names, OBS does **not** auto-rebind — meters go flat and stream audio cuts. A `SetInputSettings` round-trip over obs-websocket changes the stored settings but does NOT actually reattach the underlying audio stream; only an OBS process restart does.
If you need to apply a new conf in `~/.config/pipewire/pipewire-pulse.conf.d/`:
- **Preferred**: load the modules ad-hoc (`pactl load-module module-null-sink …` + `pactl load-module module-loopback …`) — the conf still takes over on the next pipewire-pulse start, no restart needed.
- **If unavoidable**: stop OBS first (`flatpak kill com.obsproject.Studio`), restart pipewire-pulse, then start OBS.
`scripts/deploy-rig.sh` phase 5 already guards against this: when OBS is running, it skips the restart and prints a warning. On a fresh Midgolem deploy OBS hasn't started yet, so the restart there is safe.
## 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 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>`. 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>`.
@@ -285,8 +316,8 @@ git clone <repo> ~/.config/obs-studio && cd ~/.config/obs-studio && bash scripts
systemctl --user status obs-mpd-bridge obs-twitch-bot systemctl --user status obs-mpd-bridge obs-twitch-bot
journalctl --user -u obs-twitch-bot -f journalctl --user -u obs-twitch-bot -f
# Verify the audio sink exists # Verify both audio sinks exist
pactl list short sinks | grep mpd_stream pactl list short sinks | grep -E 'mpd_stream|discord_stream'
# Check OBS WS port is listening # Check OBS WS port is listening
ss -tlnp | grep 4455 ss -tlnp | grep 4455

View File

@@ -217,7 +217,7 @@
"id": "pipewire-screen-capture-source", "id": "pipewire-screen-capture-source",
"versioned_id": "pipewire-screen-capture-source", "versioned_id": "pipewire-screen-capture-source",
"settings": { "settings": {
"RestoreToken": "f49aa90d-8c6a-4ed9-9002-b59ddeaade18" "RestoreToken": "3951c703-09cb-4e8a-9dfd-14b4090da608"
}, },
"mixers": 0, "mixers": 0,
"sync": 0, "sync": 0,
@@ -536,7 +536,7 @@
"mixers": 255, "mixers": 255,
"sync": 0, "sync": 0,
"flags": 0, "flags": 0,
"volume": 0.38266569375991821, "volume": 0.64026409387588501,
"balance": 0.5, "balance": 0.5,
"enabled": true, "enabled": true,
"muted": false, "muted": false,
@@ -776,7 +776,7 @@
"url": "file:///home/jin/.config/obs-studio/music-box/nc-music-box.html", "url": "file:///home/jin/.config/obs-studio/music-box/nc-music-box.html",
"width": 2560, "width": 2560,
"height": 1331, "height": 1331,
"fps": 9, "fps": 15,
"fps_custom": true "fps_custom": true
}, },
"mixers": 255, "mixers": 255,
@@ -809,12 +809,12 @@
"id": "phandasm_waveform_source", "id": "phandasm_waveform_source",
"versioned_id": "phandasm_waveform_source", "versioned_id": "phandasm_waveform_source",
"settings": { "settings": {
"pulse_mode": "peak_magnitude",
"audio_source": "MPD", "audio_source": "MPD",
"display_mode": "stepped_bars", "display_mode": "stepped_bars",
"color_base": 4278216704,
"render_mode": "solid",
"pulse_mode": "peak_magnitude",
"channel_mode": "mono", "channel_mode": "mono",
"render_mode": "solid",
"color_base": 4278216704,
"step_gap": 3 "step_gap": 3
}, },
"mixers": 0, "mixers": 0,
@@ -834,6 +834,68 @@
"monitoring_type": 0, "monitoring_type": 0,
"private_settings": {} "private_settings": {}
}, },
{
"prev_ver": 536936450,
"name": "Waveform Stereo",
"uuid": "bc63aaf3-2ae2-4de9-9c7a-c05e55fa2a11",
"id": "phandasm_waveform_source",
"versioned_id": "phandasm_waveform_source",
"settings": {
"audio_source": "MPD",
"display_mode": "stepped_bars",
"channel_mode": "single",
"temporal_smoothing": "exp_moving_avg",
"color_base": 4278216704,
"bar_width": 28
},
"mixers": 0,
"sync": 0,
"flags": 0,
"volume": 1.0,
"balance": 0.5,
"enabled": true,
"muted": false,
"push-to-mute": false,
"push-to-mute-delay": 0,
"push-to-talk": false,
"push-to-talk-delay": 0,
"hotkeys": {},
"deinterlace_mode": 0,
"deinterlace_field_order": 0,
"monitoring_type": 0,
"private_settings": {}
},
{
"prev_ver": 536936450,
"name": "Discord",
"uuid": "0d7d9693-0a0c-4a52-af3a-1ec742590358",
"id": "pulse_output_capture",
"versioned_id": "pulse_output_capture",
"settings": {
"device_id": "discord_stream.monitor"
},
"mixers": 255,
"sync": 0,
"flags": 0,
"volume": 1.0,
"balance": 0.5,
"enabled": true,
"muted": false,
"push-to-mute": false,
"push-to-mute-delay": 0,
"push-to-talk": false,
"push-to-talk-delay": 0,
"hotkeys": {
"libobs.mute": [],
"libobs.unmute": [],
"libobs.push-to-mute": [],
"libobs.push-to-talk": []
},
"deinterlace_mode": 0,
"deinterlace_field_order": 0,
"monitoring_type": 0,
"private_settings": {}
},
{ {
"prev_ver": 536936450, "prev_ver": 536936450,
"name": "Desktop", "name": "Desktop",
@@ -842,7 +904,7 @@
"versioned_id": "scene", "versioned_id": "scene",
"settings": { "settings": {
"custom_size": false, "custom_size": false,
"id_counter": 17, "id_counter": 18,
"items": [ "items": [
{ {
"name": "Screen Capture (PipeWire)", "name": "Screen Capture (PipeWire)",
@@ -865,11 +927,11 @@
"id": 13, "id": 13,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 566.0, "x": 534.0,
"y": 7.0 "y": 7.0
}, },
"pos_rel": { "pos_rel": {
"x": -1.0688623189926147, "x": -1.1167664527893066,
"y": -0.98952096700668335 "y": -0.98952096700668335
}, },
"scale": { "scale": {
@@ -881,11 +943,11 @@
"y": 1.0 "y": 1.0
}, },
"bounds": { "bounds": {
"x": 1985.0, "x": 2017.0,
"y": 1213.0 "y": 1213.0
}, },
"bounds_rel": { "bounds_rel": {
"x": 2.9715569019317627, "x": 3.0194611549377441,
"y": 1.8158682584762573 "y": 1.8158682584762573
}, },
"scale_filter": "disable", "scale_filter": "disable",
@@ -1038,12 +1100,12 @@
"y": 0.35029935836791992 "y": 0.35029935836791992
}, },
"scale": { "scale": {
"x": 0.4270491898059845, "x": 0.39754101634025574,
"y": 0.42727276682853699 "y": 0.39722222089767456
}, },
"scale_rel": { "scale_rel": {
"x": 0.34521940350532532, "x": 0.32136547565460205,
"y": 0.3454001247882843 "y": 0.32110777497291565
}, },
"bounds": { "bounds": {
"x": 0.0, "x": 0.0,
@@ -1093,12 +1155,12 @@
"y": -0.99101793766021729 "y": -0.99101793766021729
}, },
"scale": { "scale": {
"x": 0.99089258909225464, "x": 0.91985428333282471,
"y": 0.99090909957885742 "y": 0.91931825876235962
}, },
"scale_rel": { "scale_rel": {
"x": 0.65268373489379883, "x": 0.60589206218719482,
"y": 0.65269464254379272 "y": 0.60553896427154541
}, },
"bounds": { "bounds": {
"x": 0.0, "x": 0.0,
@@ -1118,6 +1180,61 @@
"duration": 300 "duration": 300
}, },
"private_settings": {} "private_settings": {}
},
{
"name": "Discord",
"source_uuid": "0d7d9693-0a0c-4a52-af3a-1ec742590358",
"visible": true,
"locked": false,
"rot": 0.0,
"scale_ref": {
"x": 2560.0,
"y": 1336.0
},
"align": 5,
"bounds_type": 0,
"bounds_align": 0,
"bounds_crop": false,
"crop_left": 0,
"crop_top": 0,
"crop_right": 0,
"crop_bottom": 0,
"id": 18,
"group_item_backup": false,
"pos": {
"x": 0.0,
"y": 0.0
},
"pos_rel": {
"x": -1.9161676168441772,
"y": -1.0
},
"scale": {
"x": 1.0,
"y": 1.0
},
"scale_rel": {
"x": 1.0,
"y": 1.0
},
"bounds": {
"x": 0.0,
"y": 0.0
},
"bounds_rel": {
"x": 0.0,
"y": 0.0
},
"scale_filter": "disable",
"blend_method": "default",
"blend_type": "normal",
"show_transition": {
"duration": 0
},
"hide_transition": {
"duration": 0
},
"private_settings": {}
} }
] ]
}, },
@@ -1148,7 +1265,9 @@
"libobs.show_scene_item.16": [], "libobs.show_scene_item.16": [],
"libobs.hide_scene_item.16": [], "libobs.hide_scene_item.16": [],
"libobs.show_scene_item.17": [], "libobs.show_scene_item.17": [],
"libobs.hide_scene_item.17": [] "libobs.hide_scene_item.17": [],
"libobs.show_scene_item.18": [],
"libobs.hide_scene_item.18": []
}, },
"deinterlace_mode": 0, "deinterlace_mode": 0,
"deinterlace_field_order": 0, "deinterlace_field_order": 0,
@@ -1164,7 +1283,7 @@
"versioned_id": "scene", "versioned_id": "scene",
"settings": { "settings": {
"custom_size": false, "custom_size": false,
"id_counter": 24, "id_counter": 25,
"items": [ "items": [
{ {
"name": "Screen Capture (PipeWire) 2", "name": "Screen Capture (PipeWire) 2",
@@ -1678,6 +1797,61 @@
"duration": 300 "duration": 300
}, },
"private_settings": {} "private_settings": {}
},
{
"name": "Discord",
"source_uuid": "0d7d9693-0a0c-4a52-af3a-1ec742590358",
"visible": true,
"locked": false,
"rot": 0.0,
"scale_ref": {
"x": 2560.0,
"y": 1336.0
},
"align": 5,
"bounds_type": 0,
"bounds_align": 0,
"bounds_crop": false,
"crop_left": 0,
"crop_top": 0,
"crop_right": 0,
"crop_bottom": 0,
"id": 25,
"group_item_backup": false,
"pos": {
"x": 0.0,
"y": 0.0
},
"pos_rel": {
"x": -1.9161676168441772,
"y": -1.0
},
"scale": {
"x": 1.0,
"y": 1.0
},
"scale_rel": {
"x": 1.0,
"y": 1.0
},
"bounds": {
"x": 0.0,
"y": 0.0
},
"bounds_rel": {
"x": 0.0,
"y": 0.0
},
"scale_filter": "disable",
"blend_method": "default",
"blend_type": "normal",
"show_transition": {
"duration": 0
},
"hide_transition": {
"duration": 0
},
"private_settings": {}
} }
] ]
}, },
@@ -1710,7 +1884,9 @@
"libobs.show_scene_item.16": [], "libobs.show_scene_item.16": [],
"libobs.hide_scene_item.16": [], "libobs.hide_scene_item.16": [],
"libobs.show_scene_item.24": [], "libobs.show_scene_item.24": [],
"libobs.hide_scene_item.24": [] "libobs.hide_scene_item.24": [],
"libobs.show_scene_item.25": [],
"libobs.hide_scene_item.25": []
}, },
"deinterlace_mode": 0, "deinterlace_mode": 0,
"deinterlace_field_order": 0, "deinterlace_field_order": 0,
@@ -2401,7 +2577,7 @@
"id": "scene", "id": "scene",
"versioned_id": "scene", "versioned_id": "scene",
"settings": { "settings": {
"id_counter": 6, "id_counter": 7,
"custom_size": false, "custom_size": false,
"items": [ "items": [
{ {
@@ -2690,6 +2866,61 @@
"duration": 300 "duration": 300
}, },
"private_settings": {} "private_settings": {}
},
{
"name": "Discord",
"source_uuid": "0d7d9693-0a0c-4a52-af3a-1ec742590358",
"visible": true,
"locked": false,
"rot": 0.0,
"scale_ref": {
"x": 2560.0,
"y": 1336.0
},
"align": 5,
"bounds_type": 0,
"bounds_align": 0,
"bounds_crop": false,
"crop_left": 0,
"crop_top": 0,
"crop_right": 0,
"crop_bottom": 0,
"id": 7,
"group_item_backup": false,
"pos": {
"x": 0.0,
"y": 0.0
},
"pos_rel": {
"x": -1.9161676168441772,
"y": -1.0
},
"scale": {
"x": 1.0,
"y": 1.0
},
"scale_rel": {
"x": 1.0,
"y": 1.0
},
"bounds": {
"x": 0.0,
"y": 0.0
},
"bounds_rel": {
"x": 0.0,
"y": 0.0
},
"scale_filter": "disable",
"blend_method": "default",
"blend_type": "normal",
"show_transition": {
"duration": 0
},
"hide_transition": {
"duration": 0
},
"private_settings": {}
} }
] ]
}, },
@@ -2720,7 +2951,9 @@
"libobs.show_scene_item.5": [], "libobs.show_scene_item.5": [],
"libobs.hide_scene_item.5": [], "libobs.hide_scene_item.5": [],
"libobs.show_scene_item.6": [], "libobs.show_scene_item.6": [],
"libobs.hide_scene_item.6": [] "libobs.hide_scene_item.6": [],
"libobs.show_scene_item.7": [],
"libobs.hide_scene_item.7": []
}, },
"deinterlace_mode": 0, "deinterlace_mode": 0,
"deinterlace_field_order": 0, "deinterlace_field_order": 0,
@@ -2735,7 +2968,7 @@
"id": "scene", "id": "scene",
"versioned_id": "scene", "versioned_id": "scene",
"settings": { "settings": {
"id_counter": 6, "id_counter": 10,
"custom_size": false, "custom_size": false,
"items": [ "items": [
{ {
@@ -2775,12 +3008,12 @@
"y": 1.0 "y": 1.0
}, },
"bounds": { "bounds": {
"x": 2010.0, "x": 2127.0,
"y": 1213.0 "y": 1289.0
}, },
"bounds_rel": { "bounds_rel": {
"x": 3.0089819431304932, "x": 3.1841316223144531,
"y": 1.8158682584762573 "y": 1.9296407699584961
}, },
"scale_filter": "disable", "scale_filter": "disable",
"blend_method": "default", "blend_method": "default",
@@ -2979,20 +3212,20 @@
"id": 5, "id": 5,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 2043.0, "x": 2156.0,
"y": 402.0 "y": 597.0
}, },
"pos_rel": { "pos_rel": {
"x": 1.1422156095504761, "x": 1.3113772869110107,
"y": -0.39820361137390137 "y": -0.10628741979598999
}, },
"scale": { "scale": {
"x": 0.90892529487609863, "x": 0.7012750506401062,
"y": 0.90909093618392944 "y": 0.70113635063171387
}, },
"scale_rel": { "scale_rel": {
"x": 0.90892529487609863, "x": 0.7012750506401062,
"y": 0.90909093618392944 "y": 0.70113635063171387
}, },
"bounds": { "bounds": {
"x": 0.0, "x": 0.0,
@@ -3034,20 +3267,20 @@
"id": 6, "id": 6,
"group_item_backup": false, "group_item_backup": false,
"pos": { "pos": {
"x": 2044.0, "x": 2158.0,
"y": 13.0 "y": 13.0
}, },
"pos_rel": { "pos_rel": {
"x": 1.1437125205993652, "x": 1.3143712282180786,
"y": -0.98053890466690063 "y": -0.98053890466690063
}, },
"scale": { "scale": {
"x": 0.62374997138977051, "x": 0.48124998807907104,
"y": 0.62333333492279053 "y": 0.48166665434837341
}, },
"scale_rel": { "scale_rel": {
"x": 0.62374997138977051, "x": 0.48124998807907104,
"y": 0.62333333492279053 "y": 0.48166665434837341
}, },
"bounds": { "bounds": {
"x": 0.0, "x": 0.0,
@@ -3067,6 +3300,116 @@
"duration": 300 "duration": 300
}, },
"private_settings": {} "private_settings": {}
},
{
"name": "Waveform Stereo",
"source_uuid": "bc63aaf3-2ae2-4de9-9c7a-c05e55fa2a11",
"visible": true,
"locked": false,
"rot": 0.0,
"scale_ref": {
"x": 2560.0,
"y": 1336.0
},
"align": 5,
"bounds_type": 0,
"bounds_align": 0,
"bounds_crop": false,
"crop_left": 0,
"crop_top": 0,
"crop_right": 0,
"crop_bottom": 0,
"id": 8,
"group_item_backup": false,
"pos": {
"x": 2160.0,
"y": 470.0
},
"pos_rel": {
"x": 1.317365288734436,
"y": -0.29640716314315796
},
"scale": {
"x": 0.49625000357627869,
"y": 0.4977777898311615
},
"scale_rel": {
"x": 0.49625000357627869,
"y": 0.4977777898311615
},
"bounds": {
"x": 0.0,
"y": 0.0
},
"bounds_rel": {
"x": 0.0,
"y": 0.0
},
"scale_filter": "disable",
"blend_method": "default",
"blend_type": "normal",
"show_transition": {
"duration": 300
},
"hide_transition": {
"duration": 300
},
"private_settings": {}
},
{
"name": "Discord",
"source_uuid": "0d7d9693-0a0c-4a52-af3a-1ec742590358",
"visible": true,
"locked": false,
"rot": 0.0,
"scale_ref": {
"x": 2560.0,
"y": 1336.0
},
"align": 5,
"bounds_type": 0,
"bounds_align": 0,
"bounds_crop": false,
"crop_left": 0,
"crop_top": 0,
"crop_right": 0,
"crop_bottom": 0,
"id": 10,
"group_item_backup": false,
"pos": {
"x": 0.0,
"y": 0.0
},
"pos_rel": {
"x": -1.9161676168441772,
"y": -1.0
},
"scale": {
"x": 1.0,
"y": 1.0
},
"scale_rel": {
"x": 1.0,
"y": 1.0
},
"bounds": {
"x": 0.0,
"y": 0.0
},
"bounds_rel": {
"x": 0.0,
"y": 0.0
},
"scale_filter": "disable",
"blend_method": "default",
"blend_type": "normal",
"show_transition": {
"duration": 0
},
"hide_transition": {
"duration": 0
},
"private_settings": {}
} }
] ]
}, },
@@ -3094,7 +3437,11 @@
"libobs.show_scene_item.5": [], "libobs.show_scene_item.5": [],
"libobs.hide_scene_item.5": [], "libobs.hide_scene_item.5": [],
"libobs.show_scene_item.6": [], "libobs.show_scene_item.6": [],
"libobs.hide_scene_item.6": [] "libobs.hide_scene_item.6": [],
"libobs.show_scene_item.8": [],
"libobs.hide_scene_item.8": [],
"libobs.show_scene_item.10": [],
"libobs.hide_scene_item.10": []
}, },
"deinterlace_mode": 0, "deinterlace_mode": 0,
"deinterlace_field_order": 0, "deinterlace_field_order": 0,
@@ -3336,8 +3683,8 @@
"name": "Good Bye" "name": "Good Bye"
} }
], ],
"current_scene": "Music Box", "current_scene": "Good Bye",
"current_program_scene": "Music Box", "current_program_scene": "Good Bye",
"canvases": [], "canvases": [],
"current_transition": "Luma Wipe", "current_transition": "Luma Wipe",
"transition_duration": 1000, "transition_duration": 1000,

View File

@@ -12,7 +12,12 @@ needs zero changes:
{ _type, index, total, title, artist, trackTitle, album, year, genre, { _type, index, total, title, artist, trackTitle, album, year, genre,
file, currentTime, duration, paused, file, currentTime, duration, paused,
nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles, nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles,
coverPath, coverHash } coverPath, coverHash,
audio: { format, samplerate, bits, channels, bitrate } }
File-level audio info comes from MPD `status` (`audio` is "rate:bits:ch",
`bitrate` is kbps) and the file extension (FORMAT label — FLAC/MP3/OPUS/…).
Lossless tracks (FLAC, ALAC, WAV) report their actual bit depth; MP3/Opus
report their decode word size, which is informational only.
Next-track fields are populated when MPD reports `nextsong` in its status. Next-track fields are populated when MPD reports `nextsong` in its status.
`nextTitles` is up to 3 display strings peeked from the queue starting at `nextTitles` is up to 3 display strings peeked from the queue starting at
the current song's successor — used by the Music Box overlay to show the current song's successor — used by the Music Box overlay to show
@@ -158,6 +163,72 @@ def _status_volume(status: dict) -> int | None:
return volume if volume >= 0 else None return volume if volume >= 0 else None
# Map file extension → display label. Anything not listed falls through to
# the uppercased extension so unusual codecs still render something readable.
_FORMAT_LABELS = {
"flac": "FLAC",
"mp3": "MP3",
"opus": "OPUS",
"ogg": "OGG",
"oga": "OGG",
"m4a": "AAC",
"mp4": "AAC",
"aac": "AAC",
"wav": "WAV",
"wv": "WAVPACK",
"ape": "APE",
"alac": "ALAC",
"webm": "WEBM",
"wma": "WMA",
}
def _detect_format(uri: str | None) -> str | None:
if not uri:
return None
ext = Path(uri).suffix.lower().lstrip(".")
if not ext:
return None
return _FORMAT_LABELS.get(ext, ext.upper())
def _parse_audio(audio_str: str | None) -> tuple[int | None, int | None, int | None]:
"""Decode MPD's `audio` status string `samplerate:bits:channels`.
`bits` may be `f` (float) or `dsd<N>` for DSD streams — return None for
anything we can't render as a plain integer; the overlay just skips it."""
if not audio_str:
return None, None, None
parts = audio_str.split(":")
if len(parts) < 3:
return None, None, None
def _maybe_int(v):
try: return int(v)
except (TypeError, ValueError): return None
return _maybe_int(parts[0]), _maybe_int(parts[1]), _maybe_int(parts[2])
def _audio_info(status: dict, song: dict) -> dict:
sr, bits, ch = _parse_audio(status.get("audio"))
bitrate = None
raw_br = status.get("bitrate")
if raw_br is not None:
try:
bitrate = int(raw_br)
except (TypeError, ValueError):
bitrate = None
# MPD reports 0 kbps for paused/stopped; surface as missing instead
# of a misleading "0 kbps".
if bitrate is not None and bitrate <= 0:
bitrate = None
return {
"format": _detect_format(song.get("file")),
"samplerate": sr,
"bits": bits,
"channels": ch,
"bitrate": bitrate,
}
# Module-level cache: MPD stats barely move between ticks (only on file # Module-level cache: MPD stats barely move between ticks (only on file
# add/remove), so we refresh every STATS_REFRESH_SEC and reuse otherwise. # add/remove), so we refresh every STATS_REFRESH_SEC and reuse otherwise.
_stats_cache = {"data": None, "next_refresh": 0.0} _stats_cache = {"data": None, "next_refresh": 0.0}
@@ -246,6 +317,7 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None,
"coverHash": cover_hash, "coverHash": cover_hash,
"playback": playback, "playback": playback,
"stats": stats, "stats": stats,
"audio": _audio_info(status, song),
} }
@@ -261,6 +333,7 @@ class SharedState:
"coverPath": None, "coverHash": None, "coverPath": None, "coverHash": None,
"playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False}, "playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False},
"stats": None, "stats": None,
"audio": {"format": None, "samplerate": None, "bits": None, "channels": None, "bitrate": None},
} }
self.changed = asyncio.Event() self.changed = asyncio.Event()

View File

@@ -359,6 +359,7 @@ body {
// ── Sign-off sequence (terminal output) ── // ── Sign-off sequence (terminal output) ──
const PROMPT = 'OPHI-118://> '; const PROMPT = 'OPHI-118://> ';
const MODE_KEYS = ['repeat', 'random', 'single'];
const lines = [ const lines = [
{ kind: 'cmd', text: 'unloadproject --flush --persist-state' }, { kind: 'cmd', text: 'unloadproject --flush --persist-state' },
{ kind: 'out', text: 'closing transmission envelope...' }, { kind: 'out', text: 'closing transmission envelope...' },
@@ -425,9 +426,34 @@ body {
await sleep(280); await sleep(280);
} }
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatMusicCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--keep-alive'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => {
if (p[key]) parts.push(`--${key}`);
});
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
// ── Music: passive listener; shared Music Daemon plays the audio ── // ── Music: passive listener; shared Music Daemon plays the audio ──
const playlistData = window.__PLAYLIST || { tracks: [] }; const playlistData = window.__PLAYLIST || { tracks: [] };
const allTracks = playlistData.tracks || []; const allTracks = playlistData.tracks || [];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
let modeLogChain = Promise.resolve();
let npLine = null; let npLine = null;
function ensureNowPlayingLine() { function ensureNowPlayingLine() {
@@ -456,6 +482,57 @@ body {
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`; return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
} }
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
}
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) {
lastPlaybackModes = current;
return;
}
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
function renderMusicState(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)}]`;
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatMusicCommand(s.playback));
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued', 'term-dim');
bootFinished = true;
return;
}
append('> ', `[audio] queue retained: ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
await sleep(180);
append('> ', '[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderMusicState(pendingState);
}
let lastTrackIndex = null; let lastTrackIndex = null;
async function connectMusic() { async function connectMusic() {
if (!window.__OBSWS) { if (!window.__OBSWS) {
@@ -470,14 +547,14 @@ body {
setTimeout(connectMusic, 2000); setTimeout(connectMusic, 2000);
}); });
obs.onCustom('mpd:state', (s) => { obs.onCustom('mpd:state', (s) => {
if (!npLine) ensureNowPlayingLine(); pendingState = s;
if (lastTrackIndex !== null && s.index !== lastTrackIndex) { if (!bootStarted) {
logBeforeNp(`[audio] next: ${s.title || '?'}`); bootFromState(s);
return;
} }
lastTrackIndex = s.index; if (!bootFinished) return;
npLine.querySelector('.np-title').textContent = s.title || '—'; renderMusicState(s);
npLine.querySelector('.np-time').textContent = trackModeChanges(s.playback);
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
}); });
} catch (e) { } catch (e) {
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim'); logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
@@ -487,19 +564,7 @@ body {
async function startMusic() { async function startMusic() {
await sleep(400); await sleep(400);
await typeCommand('mpd --keep-alive'); append('', '[audio] waiting for mpd state', 'term-dim');
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(); connectMusic();
} }

View File

@@ -376,6 +376,7 @@ body {
const cdMin = data.countdownMin ?? 5; const cdMin = data.countdownMin ?? 5;
const PROMPT = 'OPHI-118://> '; const PROMPT = 'OPHI-118://> ';
const MODE_KEYS = ['repeat', 'random', 'single'];
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase(); const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
const lines = [ const lines = [
{ kind: 'cmd', text: 'loadproject --manifest' }, { kind: 'cmd', text: 'loadproject --manifest' },
@@ -434,11 +435,36 @@ body {
await sleep(280); await sleep(280);
} }
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatMusicCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--boot'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => {
if (p[key]) parts.push(`--${key}`);
});
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
// ── Music: passive listener; daemon (music/index.html) plays the audio ── // ── Music: passive listener; daemon (music/index.html) plays the audio ──
// Track count comes from playlist.js; current track + progress arrive over // Track count comes from playlist.js; current track + progress arrive over
// OBS WebSocket as 'mpd:state' broadcasts from the daemon. // OBS WebSocket as 'mpd:state' broadcasts from the daemon.
const playlistData = window.__PLAYLIST || { tracks: [] }; const playlistData = window.__PLAYLIST || { tracks: [] };
const allTracks = playlistData.tracks || []; const allTracks = playlistData.tracks || [];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
let modeLogChain = Promise.resolve();
let npLine = null; let npLine = null;
function ensureNowPlayingLine() { function ensureNowPlayingLine() {
@@ -467,6 +493,59 @@ body {
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`; return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
} }
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
}
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) {
lastPlaybackModes = current;
return;
}
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
function renderMusicState(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)}]`;
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatMusicCommand(s.playback));
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued — run scripts/playlist.sh', 'term-dim');
bootFinished = true;
return;
}
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', '[audio] connecting to daemon on ws://localhost:4455', 'term-out');
await sleep(180);
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
await sleep(180);
append('> ', '[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderMusicState(pendingState);
}
// Connect to OBS WebSocket and listen for daemon broadcasts. // Connect to OBS WebSocket and listen for daemon broadcasts.
let lastTrackIndex = null; let lastTrackIndex = null;
async function connectMusic() { async function connectMusic() {
@@ -482,15 +561,14 @@ body {
setTimeout(connectMusic, 2000); setTimeout(connectMusic, 2000);
}); });
obs.onCustom('mpd:state', (s) => { obs.onCustom('mpd:state', (s) => {
if (!npLine) ensureNowPlayingLine(); pendingState = s;
// Detect track change (don't log on first state, just initialize). if (!bootStarted) {
if (lastTrackIndex !== null && s.index !== lastTrackIndex) { bootFromState(s);
logBeforeNp(`[audio] next: ${s.title || '?'}`); return;
} }
lastTrackIndex = s.index; if (!bootFinished) return;
npLine.querySelector('.np-title').textContent = s.title || '—'; renderMusicState(s);
npLine.querySelector('.np-time').textContent = trackModeChanges(s.playback);
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
}); });
} catch (e) { } catch (e) {
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim'); logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
@@ -500,21 +578,7 @@ body {
async function startMusic() { async function startMusic() {
await sleep(500); await sleep(500);
await typeCommand('mpd --queue ~/playlists/stream-mix'); append('', '[audio] waiting for mpd state', 'term-dim');
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued — run scripts/playlist.sh', 'term-dim');
return;
}
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', '[audio] connecting to daemon on ws://localhost:4455', 'term-out');
await sleep(180);
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
ensureNowPlayingLine();
connectMusic(); connectMusic();
} }

View File

@@ -189,11 +189,11 @@ body {
color: var(--term-fg); color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow); text-shadow: 0 0 6px var(--term-glow);
display: grid; display: grid;
/* art | meta | LIBRARY | <slack 1fr> | spectrum | progress /* art | meta | FILE | LIBRARY | <slack 1fr> | spectrum | progress
Slack sits between the upper info block and the bottom strip so the Slack sits between the upper info block and the bottom strip so the
spectrum analyzer reserve docks just above the progress bar — pairs spectrum analyzer reserve docks just above the progress bar — pairs
them visually as a single "playback" zone at the foot of the pane. */ them visually as a single "playback" zone at the foot of the pane. */
grid-template-rows: auto auto auto 1fr auto auto; grid-template-rows: auto auto auto auto 1fr auto auto;
gap: 18px; gap: 18px;
} }
@@ -283,6 +283,14 @@ body {
} }
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); } .np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
/* Lossless badge — bright green for FORMAT when the codec is FLAC/ALAC/WAV
so the "high quality" cue reads at a glance. Lossy formats (MP3/Opus/AAC)
stay in the regular term-fg color. */
.np-stats .stats-grid .val.lossless {
color: var(--term-fg-bright);
text-shadow: 0 0 8px var(--term-glow);
}
/* Spectrum slot — reserves the row that the waveform plugin source sits /* Spectrum slot — reserves the row that the waveform plugin source sits
on top of. Empty + transparent: the plugin draws into this region, the on top of. Empty + transparent: the plugin draws into this region, the
widget just holds the layout space so progress stays in its expected widget just holds the layout space so progress stays in its expected
@@ -438,6 +446,14 @@ body {
<span class="key">YEAR</span> <span class="val empty" id="metaYear"></span> <span class="key">YEAR</span> <span class="val empty" id="metaYear"></span>
<span class="key">GENRE</span> <span class="val empty" id="metaGenre"></span> <span class="key">GENRE</span> <span class="val empty" id="metaGenre"></span>
</div> </div>
<div class="np-stats">
<div class="stats-label">─ FILE ─</div>
<div class="stats-grid">
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat"></span>
<span class="key">QUALITY</span><span class="val empty" id="fileQuality"></span>
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate"></span>
</div>
</div>
<div class="np-stats"> <div class="np-stats">
<div class="stats-label">─ LIBRARY ─</div> <div class="stats-label">─ LIBRARY ─</div>
<div class="stats-grid"> <div class="stats-grid">
@@ -530,6 +546,7 @@ body {
const npTitle = pinnedBlock.querySelector('.np-title'); const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time'); const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`)); const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const nextRows = nextSlots.map(slot => slot.closest('.next-line'));
const PROMPT = 'OPHI-118://> '; const PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200; const MAX_TERM_LINES = 200;
const MODE_KEYS = ['repeat', 'random', 'single']; const MODE_KEYS = ['repeat', 'random', 'single'];
@@ -610,6 +627,20 @@ body {
return parts.join(' '); return parts.join(' ');
} }
function nextPreviewConfig(playback) {
const p = normalizePlayback(playback);
if (p.single && p.repeat) {
return { count: 1, empty: '— repeat single armed —' };
}
if (p.single) {
return { count: 0, empty: '— single mode —' };
}
if (p.random) {
return { count: 1, empty: '— shuffle active —' };
}
return { count: 3, empty: '— end of queue —' };
}
async function logModeChange(mode, enabled) { async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`); await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null); logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
@@ -640,14 +671,23 @@ body {
npTitle.textContent = s.title || '—'; npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`; npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : []; const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
const preview = nextPreviewConfig(s.playback);
nextSlots.forEach((slot, i) => { nextSlots.forEach((slot, i) => {
const v = titles[i]; const v = i < preview.count ? titles[i] : null;
if (v) { if (v) {
slot.textContent = v; slot.textContent = v;
slot.classList.remove('empty'); slot.classList.remove('empty');
nextRows[i].style.display = '';
} else { } else {
slot.textContent = i === 0 ? '— end of queue —' : '—'; if (i === 0) {
slot.textContent = preview.empty;
slot.classList.add('empty'); slot.classList.add('empty');
nextRows[i].style.display = '';
} else {
slot.textContent = '';
slot.classList.add('empty');
nextRows[i].style.display = 'none';
}
} }
}); });
@@ -669,6 +709,15 @@ body {
progElapsed.textContent = fmtClock(cur); progElapsed.textContent = fmtClock(cur);
progTotal.textContent = fmtClock(dur); progTotal.textContent = fmtClock(dur);
// File / audio info — codec from extension, sample rate + bit depth +
// bitrate from MPD `status.audio` and `status.bitrate`. FLAC and friends
// get the bright "lossless" treatment on FORMAT to advertise quality.
const audio = s.audio || {};
setMeta(fileFormat, audio.format);
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
setMeta(fileQuality, fmtQuality(audio));
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
// Library stats — bridge updates `stats` ~every 30s. Queue is built // Library stats — bridge updates `stats` ~every 30s. Queue is built
// from the per-tick index/total fields; render 1-indexed (mpc style). // from the per-tick index/total fields; render 1-indexed (mpc style).
const stats = s.stats; const stats = s.stats;
@@ -721,6 +770,33 @@ body {
const statSongs = document.getElementById('statSongs'); const statSongs = document.getElementById('statSongs');
const statPlaytime = document.getElementById('statPlaytime'); const statPlaytime = document.getElementById('statPlaytime');
const statQueue = document.getElementById('statQueue'); const statQueue = document.getElementById('statQueue');
const fileFormat = document.getElementById('fileFormat');
const fileQuality = document.getElementById('fileQuality');
const fileBitrate = document.getElementById('fileBitrate');
// Codecs whose bitstream is mathematically lossless — drives the bright
// FORMAT highlight in the FILE block.
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
// "44100" → "44.1 kHz", "96000" → "96 kHz". Trim the decimal when the rate
// is a clean multiple of 1000 so common rates render as "44.1/48/96 kHz".
function fmtSampleRate(hz) {
if (!hz || !isFinite(hz) || hz <= 0) return null;
const khz = hz / 1000;
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
return `${s} kHz`;
}
// Quality cell merges sample rate + bit depth: "96 kHz · 24-bit". Either
// half can be missing (DSD streams report bits='dsd64' which we drop, MP3s
// sometimes lack a clean rate at start of decode).
function fmtQuality(audio) {
const sr = fmtSampleRate(audio.samplerate);
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
? `${audio.bits}-bit` : null;
if (sr && bits) return `${sr} · ${bits}`;
return sr || bits || null;
}
const fmtCount = n => (typeof n === 'number' && isFinite(n)) const fmtCount = n => (typeof n === 'number' && isFinite(n))
? n.toLocaleString('en-US') : null; ? n.toLocaleString('en-US') : null;

View File

@@ -18,7 +18,7 @@
# 2. Python user packages (python-mpd2, websockets) # 2. Python user packages (python-mpd2, websockets)
# 3. Flatpak OBS Studio + filesystems=host override # 3. Flatpak OBS Studio + filesystems=host override
# 4. Git submodules + asset directories under $HOME # 4. Git submodules + asset directories under $HOME
# 5. PipeWire mpd_stream null sink + loopback # 5. PipeWire null sinks (mpd_stream + discord_stream) + loopbacks
# 6. MPD config (zeroconf_name = this rig) + system units # 6. MPD config (zeroconf_name = this rig) + system units
# 7. Custom systemd --user units (bridge, bot) # 7. Custom systemd --user units (bridge, bot)
# 8. OBS first-launch to seed obs-websocket plugin config # 8. OBS first-launch to seed obs-websocket plugin config
@@ -248,20 +248,32 @@ done
(( MISSING_ASSETS )) && warn " → bring these over manually from Ignia; OBS will surface 'missing source' errors otherwise" (( MISSING_ASSETS )) && warn " → bring these over manually from Ignia; OBS will surface 'missing source' errors otherwise"
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
# Phase 5 — PipeWire mpd_stream sink # Phase 5 — PipeWire null sinks (mpd_stream + discord_stream)
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
step "PipeWire null sink (mpd_stream)" # Two isolated streams into OBS so Discord voice and MPD music don't mix
# with each other — or with the rest of the desktop. Each gets its own
# null sink + loopback (so you still hear them locally) + monitor source
# that OBS captures via pulse_output_capture.
step "PipeWire null sinks (mpd_stream + discord_stream)"
PW_CONF_DIR="$HOME/.config/pipewire/pipewire-pulse.conf.d" PW_CONF_DIR="$HOME/.config/pipewire/pipewire-pulse.conf.d"
PW_CONF="$PW_CONF_DIR/mpd-stream.conf" mkdir -p "$PW_CONF_DIR"
if [[ -f "$PW_CONF" ]]; then PW_CONF_CHANGED=0
ok "$PW_CONF already present"
else write_pw_conf() {
if would "write $PW_CONF"; then # write_pw_conf <path> <body>
mkdir -p "$PW_CONF_DIR" local path="$1"; local body="$2"
cat > "$PW_CONF" <<'PWCONF' if [[ -f "$path" ]]; then
# Persistent virtual sink for streaming MPD into OBS without mixing in ok "$(basename "$path") already present"
elif would "write $path"; then
printf '%s' "$body" > "$path"
ok "wrote $path"
PW_CONF_CHANGED=1
fi
}
write_pw_conf "$PW_CONF_DIR/mpd-stream.conf" '# Persistent virtual sink for streaming MPD into OBS without mixing in
# desktop/browser/Discord audio. # desktop/browser/Discord audio.
# #
# MPD ──► null sink "mpd_stream" # MPD ──► null sink "mpd_stream"
@@ -284,23 +296,64 @@ pulse.cmd = [
args = "module-loopback source=mpd_stream.monitor latency_msec=50" args = "module-loopback source=mpd_stream.monitor latency_msec=50"
} }
] ]
PWCONF '
ok "wrote $PW_CONF"
if would "systemctl --user restart pipewire-pulse"; then write_pw_conf "$PW_CONF_DIR/discord-stream.conf" '# Persistent virtual sink for capturing Discord audio in OBS without mixing
# in the rest of the desktop (browser, MPD, system bleeps).
#
# Mirrors mpd-stream.conf — separate isolated channel for voice chat:
# Discord ──► null sink "discord_stream"
# │
# ├──► loopback ──► default sink (you still hear voice
# │ chat on speakers/headset)
# │
# └──► monitor ──► OBS "Audio Output Capture (PulseAudio)
# → Monitor of Discord-Stream"
#
# Discord side: set the app'"'"'s audio output to "Discord-Stream" once via
# pavucontrol (Playback tab). PipeWire remembers the routing across launches.
# Discord has no equivalent of MPD'"'"'s `target` setting, so this is a
# one-time per-rig manual step.
#
# Loaded automatically when pipewire-pulse starts.
pulse.cmd = [
{
cmd = "load-module"
args = "module-null-sink sink_name=discord_stream sink_properties=device.description=Discord-Stream"
}
{
cmd = "load-module"
args = "module-loopback source=discord_stream.monitor latency_msec=50"
}
]
'
# Restart pipewire-pulse only if we wrote a new conf AND OBS is not running
# (pipewire-pulse restart silently detaches OBS pulse_output_capture sources;
# fix needs OBS process restart, not just a settings refresh).
if (( PW_CONF_CHANGED )) && (( ! CHECK_ONLY )); then
if flatpak ps --columns=application 2>/dev/null | grep -qx com.obsproject.Studio \
|| pgrep -x obs >/dev/null 2>&1; then
warn "OBS is running — skipping pipewire-pulse restart (would detach OBS audio sources)"
warn " → close OBS, then: systemctl --user restart pipewire-pulse.service"
warn " → or just relog; conf loads on next pipewire-pulse start either way"
elif would "systemctl --user restart pipewire-pulse"; then
systemctl --user restart pipewire-pulse.service 2>/dev/null || \ systemctl --user restart pipewire-pulse.service 2>/dev/null || \
warn "could not restart pipewire-pulse (will load on next login anyway)" warn "could not restart pipewire-pulse (will load on next login anyway)"
fi fi
fi
fi fi
# Verify the sink is actually loaded (only meaningful in install mode). # Verify the sinks are actually loaded (only meaningful in install mode).
if (( ! CHECK_ONLY )); then if (( ! CHECK_ONLY )); then
sleep 1 sleep 1
if pactl list short sinks 2>/dev/null | grep -q '^[0-9]*[[:space:]]\+mpd_stream\b'; then for sink in mpd_stream discord_stream; do
ok "mpd_stream sink loaded" if pactl list short sinks 2>/dev/null | grep -q "^[0-9]*[[:space:]]\+${sink}\b"; then
ok "$sink sink loaded"
else else
warn "mpd_stream sink NOT visible to pactl — restart pipewire-pulse or relog and re-check" warn "$sink sink NOT visible to pactl — restart pipewire-pulse or relog and re-check"
fi fi
done
fi fi
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
@@ -572,8 +625,13 @@ cat <<DONE
6. Launch OBS: 6. Launch OBS:
flatpak run com.obsproject.Studio flatpak run com.obsproject.Studio
7. Route Discord audio to the Discord-Stream sink (one-time):
Open pavucontrol → Playback tab → while Discord plays sound,
click its dropdown and pick "Discord-Stream". PipeWire remembers
this across launches; the OBS "Discord" source picks up audio.
Sanity checks: Sanity checks:
pactl list short sinks | grep mpd_stream pactl list short sinks | grep -E 'mpd_stream|discord_stream'
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
mpc status mpc status
ss -tlnp | grep 4455 ss -tlnp | grep 4455

View File

@@ -160,8 +160,7 @@ echo " ✓ wrote $JS"
# the id is already resolved by the search step above. # the id is already resolved by the search step above.
SET_CHANNEL="$OBS_DIR/twitch-bot/set-channel.py" SET_CHANNEL="$OBS_DIR/twitch-bot/set-channel.py"
if (( have_twitch )) && [[ -x "$SET_CHANNEL" && -n "$game_id" ]]; then if (( have_twitch )) && [[ -x "$SET_CHANNEL" && -n "$game_id" ]]; then
twitch_title="$game" twitch_title="${subtitle:-$game}"
[[ -n "$subtitle" ]] && twitch_title="$game$subtitle"
echo echo
if ! "$SET_CHANNEL" --game-id "$game_id" "$twitch_title"; then if ! "$SET_CHANNEL" --game-id "$game_id" "$twitch_title"; then
echo " ! Twitch sync failed (manifest still saved) — fix and re-run if needed" >&2 echo " ! Twitch sync failed (manifest still saved) — fix and re-run if needed" >&2

Submodule station updated: 9749729371...5330c157fc

View File

@@ -1,38 +0,0 @@
# Copy to `.env` and fill in. `.env` is gitignored; this template is not.
# ─── Twitch chat auth ───────────────────────────────────────────────────────
# Generated together at https://twitchtokengenerator.com
# (scopes: chat:read + chat:edit). Access token is what the bot uses today;
# refresh + client id are stored so we can rotate the access token later
# without re-running the generator.
# Chat OAuth token. Paste the raw token — the bot adds the `oauth:` prefix
# automatically if you omit it.
TWITCH_ACCESS_TOKEN=
# Refresh token. Currently unused by bot.py; kept here so the value survives
# alongside the access token it's paired with.
TWITCH_REFRESH_TOKEN=
# App client ID shown by twitchtokengenerator. Needed together with the
# refresh token to mint a new access token via Twitch's OAuth endpoint.
# Currently unused by bot.py.
TWITCH_CLIENT_ID=
# ─── Identity / where to connect ────────────────────────────────────────────
# Bot login name (lowercase). Posting as your own streamer account is fine
# for a personal bot — set this to your channel name in that case.
TWITCH_NICK=ophi118
# Channel to join (no leading #). Defaults to TWITCH_NICK if omitted.
TWITCH_CHANNEL=ophi118
# ─── Mattermost: going-live notifications (optional) ────────────────────────
# When set, the bot polls https://ophi118.com/api/status.json (the public
# status service in station/server/) and POSTs a single message to this
# Mattermost incoming-webhook URL on every offline→live transition. Unset
# (or blank) disables notifications entirely; the bot still runs.
#
# Override the polled endpoint with STATUS_URL if running against a private
# status service.
MM_HOOK=

10
twitch-bot/.gitignore vendored
View File

@@ -1,10 +0,0 @@
# Defensive — root .gitignore already blocks `*.env` and `.env*`, but listing
# `.env` here makes the intent explicit at the bot dir.
.env
.env.ophi118
# Root's `.env*` glob also catches `.env.example`; un-ignore the template so
# it stays tracked.
!.env.example
# Python bytecode cache
__pycache__/

View File

@@ -1,112 +0,0 @@
"""
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

View File

@@ -1,462 +0,0 @@
#!/usr/bin/env python3
"""
Twitch chat → MPD control bot, plus optional "going live" notifications.
Connects to Twitch IRC over TLS, joins the configured channel, and responds to:
!skip advance MPD to the next track (per-user cooldown — see SKIP_COOLDOWN)
!queue print the next few queued tracks
!info print full metadata (artist · title · album · year · genre) for now-playing
The new track shows up on stream because bridges/mpd-state.py is already
broadcasting MPD state to the loading / game / music-box overlays.
If MM_HOOK is set, a second task polls the public ophi118.com status service
(which already polls Twitch Helix for live/title/game) and POSTs a single
Mattermost notification per offline→live transition. See live_watch().
Reads .env from the same directory:
TWITCH_ACCESS_TOKEN chat OAuth token (with or without `oauth:` prefix)
TWITCH_NICK bot login name (lowercase)
TWITCH_CHANNEL channel to join, no leading `#` — defaults to TWITCH_NICK
MM_HOOK optional Mattermost incoming-webhook URL; enables
going-live notifications when set
STATUS_URL optional override for the status API endpoint
(default: https://ophi118.com/api/status.json)
TWITCH_REFRESH_TOKEN and TWITCH_CLIENT_ID are also stored in .env (paired with
the access token at generation time) but the bot doesn't use them yet — token
refresh would land here later if access tokens start expiring mid-stream.
"""
import asyncio
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
from mpd.asyncio import MPDClient
HERE = Path(__file__).resolve().parent
ENV = HERE / ".env"
MPD_HOST = os.environ.get("MPD_HOST", "localhost")
MPD_PORT = int(os.environ.get("MPD_PORT", "6600"))
IRC_HOST = "irc.chat.twitch.tv"
IRC_PORT = 6697
PING_TIMEOUT = 360.0 # twitch PINGs ~every 5min; reconnect if silent longer
SKIP_COOLDOWN = 60.0 # per-user seconds between successful !skip invocations
QUEUE_PEEK = 3 # how many upcoming tracks !queue should print
# Going-live watcher: poll cadence matches the server's Twitch poll (60s) so we
# never lag the upstream signal by more than ~2 polls.
STATUS_URL = os.environ.get("STATUS_URL", "https://ophi118.com/api/status.json")
STATUS_POLL_S = 60.0
# Set TWITCH_BOT_DEBUG=1 (env or .env) to dump every received IRC line. Useful
# when a brand-new bot account looks "connected" but doesn't see chat — Twitch
# silently degrades unverified accounts in ways that don't surface as errors.
DEBUG_RAW = os.environ.get("TWITCH_BOT_DEBUG", "").strip() not in ("", "0", "false")
# ─── tiny .env loader ───────────────────────────────────────────────────────
def load_env(path: Path) -> dict:
"""KEY=VALUE lines, # comments, optional surrounding quotes. Avoids a
python-dotenv dependency for three vars."""
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
# ─── MPD ────────────────────────────────────────────────────────────────────
class Mpd:
"""One shared MPDClient with reconnect-on-failure.
python-mpd2's asyncio client doesn't auto-reconnect, so each command is
wrapped to retry once after dropping the connection — covers MPD
restarts and idle-socket timeouts without surfacing transient errors.
"""
def __init__(self):
self._cli = None
self._lock = asyncio.Lock()
async def _ensure(self):
if self._cli is None:
cli = MPDClient()
await cli.connect(MPD_HOST, MPD_PORT)
self._cli = cli
async def _retry(self, op):
"""Run an MPD coroutine with one reconnect-on-failure retry."""
async with self._lock:
last = None
for attempt in (1, 2):
try:
await self._ensure()
return await op(self._cli)
except Exception as e:
last = e
self._cli = None # force reconnect on retry
raise last # type: ignore[misc]
async def skip(self) -> str:
async def op(cli):
await cli.next()
return _fmt_song(await cli.currentsong())
return await self._retry(op)
async def currentsong(self) -> dict:
return await self._retry(lambda cli: cli.currentsong())
async def status(self) -> dict:
return await self._retry(lambda cli: cli.status())
async def playlistinfo(self, pos: int) -> list:
return await self._retry(lambda cli: cli.playlistinfo(pos))
def _flat(v):
"""MPD tags can be lists (multi-value); flatten to a comma-joined string
with empties removed. Returns None for missing/empty so callers can skip
fields without empty parens."""
if v is None:
return None
if isinstance(v, list):
v = ", ".join(x for x in v if x)
v = (v or "").strip()
return v or None
def _fmt_song(song: dict) -> str:
artist = _flat(song.get("artist") or song.get("albumartist")) or ""
title = _flat(song.get("title")) or (song.get("file") or "").rsplit("/", 1)[-1]
out = f"{artist}{title}".strip("")
return out or "(unknown track)"
def _fmt_info(song: dict) -> str:
"""Compose the !info reply: 'Artist — Title · Album (Year) · Genre'.
Skip any segment whose underlying tag is missing."""
head = _fmt_song(song)
parts = [head] if head != "(unknown track)" else []
album = _flat(song.get("album"))
year = _flat(song.get("date"))
if year:
# MPD often stores ISO dates ('2018-04-12') — show year only.
year = year.split("-", 1)[0]
if album and year:
parts.append(f"{album} ({year})")
elif album:
parts.append(album)
elif year:
parts.append(f"({year})")
genre = _flat(song.get("genre"))
if genre:
parts.append(genre)
return " · ".join(parts) if parts else "(unknown track)"
# ─── Twitch IRC ─────────────────────────────────────────────────────────────
class TwitchBot:
def __init__(self, nick: str, token: str, channel: str, mpd: Mpd):
self.nick = nick.lower()
self.token = token if token.startswith("oauth:") else f"oauth:{token}"
self.channel = "#" + channel.lower().lstrip("#")
self.mpd = mpd
# Per-user !skip cooldown — keyed by lowercased nick. The dict grows
# unboundedly with viewer count over a stream session; not worth
# pruning at typical chat sizes (one float per chatter).
self._skip_seen: dict[str, float] = {}
async def _send(self, writer: asyncio.StreamWriter, raw: str):
writer.write((raw + "\r\n").encode("utf-8"))
await writer.drain()
async def _say(self, writer: asyncio.StreamWriter, msg: str):
# twitch chat lines max ~500 chars; truncate defensively, strip newlines
msg = msg.replace("\r", " ").replace("\n", " ")[:480]
await self._send(writer, f"PRIVMSG {self.channel} :{msg}")
async def run(self):
"""Connect/auth/listen loop with quiet exponential backoff.
Mirrors the connect-quietly pattern in bridges/mpd-state.py:
first failure prints once, then silent retry until the connection
comes back. Auth failure is fatal — no point retrying with a bad token.
"""
backoff = 2
BACKOFF_MAX = 30
prev_status = None # None | "up" | "down"
ctx = ssl.create_default_context()
while True:
try:
reader, writer = await asyncio.open_connection(
IRC_HOST, IRC_PORT, ssl=ctx,
)
try:
await self._send(writer, f"PASS {self.token}")
await self._send(writer, f"NICK {self.nick}")
await self._send(writer, f"JOIN {self.channel}")
if prev_status != "up":
print(f"[twitch] connected → {self.channel} as {self.nick}",
flush=True)
prev_status = "up"
backoff = 2
while True:
line_bytes = await asyncio.wait_for(
reader.readline(), timeout=PING_TIMEOUT,
)
if not line_bytes:
raise ConnectionError("server closed connection")
line = line_bytes.decode("utf-8", errors="replace").rstrip("\r\n")
await self._handle(writer, line)
finally:
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
except RuntimeError as e:
# auth-failed and similar fatals — let systemd flag it
print(f"[twitch] fatal: {e}", file=sys.stderr, flush=True)
raise
except (asyncio.TimeoutError, ConnectionError, OSError) as e:
if prev_status == "up":
print(f"[twitch] disconnected ({type(e).__name__}: {e}) — retrying",
flush=True)
elif prev_status is None:
print(f"[twitch] unreachable ({type(e).__name__}) — will retry silently",
flush=True)
prev_status = "down"
await asyncio.sleep(backoff)
backoff = min(backoff * 2, BACKOFF_MAX)
async def _handle(self, writer: asyncio.StreamWriter, line: str):
if DEBUG_RAW:
print(f"[rx] {line}", flush=True)
# PING/PONG keepalive — twitch sends `PING :tmi.twitch.tv` periodically
if line.startswith("PING "):
await self._send(writer, "PONG " + line[5:])
return
# NOTICE on bad creds: ":tmi.twitch.tv NOTICE * :Login authentication failed"
if " NOTICE " in line and "authentication failed" in line.lower():
raise RuntimeError("twitch auth failed — check TWITCH_KEY / TWITCH_NICK")
# PRIVMSG format: ":user!user@user.tmi.twitch.tv PRIVMSG #chan :body"
if " PRIVMSG " not in line:
return
try:
prefix, rest = line.split(" PRIVMSG ", 1)
user = prefix[1:].split("!", 1)[0]
_, _, body = rest.partition(":")
except ValueError:
return
# Twitch's anti-duplicate logic appends an invisible Tags-block
# codepoint (U+E0000U+E007F) to back-to-back identical messages
# from the same sender within ~30s. Without stripping it, every
# other `!skip` in a series silently fails to match.
body = "".join(c for c in body if not (0xE0000 <= ord(c) <= 0xE007F))
head = body.strip().split(" ", 1)[0].lower()
if head == "!skip":
await self._cmd_skip(writer, user)
elif head == "!queue":
await self._cmd_queue(writer, user)
elif head == "!info":
await self._cmd_info(writer, user)
async def _cmd_skip(self, writer: asyncio.StreamWriter, user: str):
# Per-user cooldown: each chatter gets one skip per SKIP_COOLDOWN
# seconds. Reject with a wait-time hint so the user knows it's their
# own gate, not the bot being broken.
now = time.monotonic()
ukey = user.lower()
last = self._skip_seen.get(ukey, 0.0)
wait = SKIP_COOLDOWN - (now - last)
if wait > 0:
await self._say(
writer,
f"@{user} you can only !skip once per minute (wait {int(wait)+1}s)",
)
return
try:
now_playing = await self.mpd.skip()
except Exception as e:
print(f"[skip] failed for @{user}: {type(e).__name__}: {e}", flush=True)
await self._say(writer, f"@{user} couldn't skip — MPD didn't respond")
return
self._skip_seen[ukey] = now
print(f"[skip] @{user}{now_playing}", flush=True)
await self._say(writer, f"⏭ skipped by @{user} → now: {now_playing}")
async def _cmd_queue(self, writer: asyncio.StreamWriter, user: str):
# Read-only; no cooldown. Pulls current pos from status, then peeks
# the next QUEUE_PEEK tracks one-by-one (small N — full playlistinfo
# would dump the whole queue).
try:
status = await self.mpd.status()
pos = int(status.get("song", -1))
total = int(status.get("playlistlength") or 0)
except Exception as e:
print(f"[queue] failed for @{user}: {type(e).__name__}: {e}", flush=True)
await self._say(writer, f"@{user} couldn't read queue — MPD didn't respond")
return
if pos < 0 or pos + 1 >= total:
await self._say(writer, "🎵 nothing else queued")
return
upcoming = []
for i, p in enumerate(range(pos + 1, min(pos + 1 + QUEUE_PEEK, total)), start=1):
try:
rows = await self.mpd.playlistinfo(p)
if rows:
upcoming.append(f"{i}) {_fmt_song(rows[0])}")
except Exception:
continue
if not upcoming:
await self._say(writer, "🎵 nothing else queued")
return
await self._say(writer, "🎵 next: " + " · ".join(upcoming))
async def _cmd_info(self, writer: asyncio.StreamWriter, user: str):
try:
song = await self.mpd.currentsong()
except Exception as e:
print(f"[info] failed for @{user}: {type(e).__name__}: {e}", flush=True)
await self._say(writer, f"@{user} couldn't read track info — MPD didn't respond")
return
if not song:
await self._say(writer, " nothing playing")
return
await self._say(writer, " " + _fmt_info(song))
# ─── Mattermost "going live" notifications ──────────────────────────────────
def _fetch_status(url: str) -> dict:
"""GET the status JSON. Raises on transport/parse errors so live_watch()
can log + back off uniformly."""
req = urllib.request.Request(url, method="GET",
headers={"User-Agent": "obs-twitch-bot/1.0"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read().decode("utf-8"))
def _post_mattermost(hook_url: str, channel: str, title: str, game: str) -> None:
"""POST a single going-live notification. Markdown renders in Mattermost."""
parts = [f"🔴 **{channel}** is live on Twitch"]
if title:
parts.append(f"> {title}")
if game:
parts.append(f"_{game}_")
parts.append(f"https://twitch.tv/{channel}")
body = json.dumps({"text": "\n".join(parts)}).encode("utf-8")
req = urllib.request.Request(
hook_url, method="POST", data=body,
headers={"Content-Type": "application/json",
"User-Agent": "obs-twitch-bot/1.0"},
)
with urllib.request.urlopen(req, timeout=10) as r:
if r.status >= 300:
raise RuntimeError(f"mattermost hook returned HTTP {r.status}")
async def live_watch(mm_hook: str, channel: str, status_url: str = STATUS_URL):
"""Poll the status service; POST to Mattermost on offline→live transitions.
Transition detection is keyed on `started_at`, not just the `live` flag, so
a brief Helix hiccup (live=true → false → true within one stream session,
same `started_at`) doesn't double-fire.
Cold-start during an ongoing stream is intentionally silent: we record the
current `started_at` so we won't fire for it later, but we don't announce
it (we can't tell "they just went live" from "the bot restarted mid-stream"
without prior offline observation). Once we see live=False at least once,
the next started_at change becomes a real transition we'll announce.
"""
last_announced = None # started_at we've already handled (announced or recorded)
seen_offline = False # have we observed live=False since last_announced?
while True:
try:
data = await asyncio.to_thread(_fetch_status, status_url)
twitch = (data or {}).get("twitch") or {}
live = bool(twitch.get("live"))
started = (twitch.get("started_at") or None) if live else None
except Exception as e:
print(f"[live] poll failed: {type(e).__name__}: {e}", flush=True)
await asyncio.sleep(STATUS_POLL_S)
continue
if not live:
seen_offline = True
elif started:
if last_announced is None and not seen_offline:
# cold start mid-stream: lock in this started_at so we won't
# falsely announce it later
last_announced = started
elif started != last_announced and seen_offline:
title = twitch.get("title") or ""
game = twitch.get("game") or ""
try:
await asyncio.to_thread(_post_mattermost, mm_hook, channel, title, game)
print(f"[live] {channel} went live → posted to mattermost "
f"(title={title!r}, game={game!r})", flush=True)
except Exception as e:
# log but don't retry — a missed notification is better
# than a duplicate; the next genuine transition will fire
print(f"[live] mattermost post failed: {type(e).__name__}: {e}",
flush=True)
last_announced = started
seen_offline = False
await asyncio.sleep(STATUS_POLL_S)
# ─── entrypoint ─────────────────────────────────────────────────────────────
async def main():
env = {**load_env(ENV), **os.environ} # process env wins over .env
token = env.get("TWITCH_ACCESS_TOKEN", "").strip()
nick = env.get("TWITCH_NICK", "").strip()
channel = env.get("TWITCH_CHANNEL", nick).strip()
mm_hook = env.get("MM_HOOK", "").strip()
missing = [k for k, v in (("TWITCH_ACCESS_TOKEN", token), ("TWITCH_NICK", nick)) if not v]
if missing:
print(f"[err] missing in {ENV}: {', '.join(missing)}", file=sys.stderr)
sys.exit(1)
bot = TwitchBot(nick=nick, token=token, channel=channel, mpd=Mpd())
tasks = [bot.run()]
if mm_hook:
print(f"[live] watching {STATUS_URL} → mattermost on offline→live", flush=True)
tasks.append(live_watch(mm_hook, channel))
else:
print("[live] MM_HOOK unset — going-live notifications disabled", flush=True)
await asyncio.gather(*tasks)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)

View File

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

View File

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