diff --git a/.gitignore b/.gitignore index b35db50..5d3e303 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,4 @@ Thumbs.db __pycache__/ *.pyc *.pyo +temp diff --git a/CLAUDE.md b/CLAUDE.md index 0bd2ef3..518a9eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,30 +76,43 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p ## 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. + ``` - ┌────────────┐ - │ 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 + ┌────────────┐ ┌─────────────┐ + │ MPD │ systemd user │ Discord │ Flatpak / native + └──────┬─────┘ unit └──────┬──────┘ app + │ PipeWire target= │ pavucontrol → "Discord-Stream" + │ "mpd_stream" │ (one-time per rig) + ▼ ▼ + ┌────────────────────────┐ ┌────────────────────────────┐ + │ 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 ``` -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 @@ -188,6 +201,13 @@ These hardcoded paths exist in `basic/scenes/Default_Stream_HUD.json`. On a fres - All scripts in `scripts/` - `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) - **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 (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 // []` 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. ## 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. +## 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 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 --=`. @@ -285,8 +316,8 @@ git clone ~/.config/obs-studio && cd ~/.config/obs-studio && bash scripts 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 +# Verify both audio sinks exist +pactl list short sinks | grep -E 'mpd_stream|discord_stream' # Check OBS WS port is listening ss -tlnp | grep 4455 diff --git a/basic/scenes/Default_Stream_HUD.json b/basic/scenes/Default_Stream_HUD.json index 5d93143..5d9a710 100644 --- a/basic/scenes/Default_Stream_HUD.json +++ b/basic/scenes/Default_Stream_HUD.json @@ -217,7 +217,7 @@ "id": "pipewire-screen-capture-source", "versioned_id": "pipewire-screen-capture-source", "settings": { - "RestoreToken": "f49aa90d-8c6a-4ed9-9002-b59ddeaade18" + "RestoreToken": "3951c703-09cb-4e8a-9dfd-14b4090da608" }, "mixers": 0, "sync": 0, @@ -536,7 +536,7 @@ "mixers": 255, "sync": 0, "flags": 0, - "volume": 0.38266569375991821, + "volume": 0.64026409387588501, "balance": 0.5, "enabled": true, "muted": false, @@ -776,7 +776,7 @@ "url": "file:///home/jin/.config/obs-studio/music-box/nc-music-box.html", "width": 2560, "height": 1331, - "fps": 9, + "fps": 15, "fps_custom": true }, "mixers": 255, @@ -809,12 +809,12 @@ "id": "phandasm_waveform_source", "versioned_id": "phandasm_waveform_source", "settings": { + "pulse_mode": "peak_magnitude", "audio_source": "MPD", "display_mode": "stepped_bars", - "color_base": 4278216704, - "render_mode": "solid", - "pulse_mode": "peak_magnitude", "channel_mode": "mono", + "render_mode": "solid", + "color_base": 4278216704, "step_gap": 3 }, "mixers": 0, @@ -834,6 +834,68 @@ "monitoring_type": 0, "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, "name": "Desktop", @@ -842,7 +904,7 @@ "versioned_id": "scene", "settings": { "custom_size": false, - "id_counter": 17, + "id_counter": 18, "items": [ { "name": "Screen Capture (PipeWire)", @@ -865,11 +927,11 @@ "id": 13, "group_item_backup": false, "pos": { - "x": 566.0, + "x": 534.0, "y": 7.0 }, "pos_rel": { - "x": -1.0688623189926147, + "x": -1.1167664527893066, "y": -0.98952096700668335 }, "scale": { @@ -881,11 +943,11 @@ "y": 1.0 }, "bounds": { - "x": 1985.0, + "x": 2017.0, "y": 1213.0 }, "bounds_rel": { - "x": 2.9715569019317627, + "x": 3.0194611549377441, "y": 1.8158682584762573 }, "scale_filter": "disable", @@ -1038,12 +1100,12 @@ "y": 0.35029935836791992 }, "scale": { - "x": 0.4270491898059845, - "y": 0.42727276682853699 + "x": 0.39754101634025574, + "y": 0.39722222089767456 }, "scale_rel": { - "x": 0.34521940350532532, - "y": 0.3454001247882843 + "x": 0.32136547565460205, + "y": 0.32110777497291565 }, "bounds": { "x": 0.0, @@ -1093,12 +1155,12 @@ "y": -0.99101793766021729 }, "scale": { - "x": 0.99089258909225464, - "y": 0.99090909957885742 + "x": 0.91985428333282471, + "y": 0.91931825876235962 }, "scale_rel": { - "x": 0.65268373489379883, - "y": 0.65269464254379272 + "x": 0.60589206218719482, + "y": 0.60553896427154541 }, "bounds": { "x": 0.0, @@ -1118,6 +1180,61 @@ "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": 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.hide_scene_item.16": [], "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_field_order": 0, @@ -1164,7 +1283,7 @@ "versioned_id": "scene", "settings": { "custom_size": false, - "id_counter": 24, + "id_counter": 25, "items": [ { "name": "Screen Capture (PipeWire) 2", @@ -1678,6 +1797,61 @@ "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": 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.hide_scene_item.16": [], "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_field_order": 0, @@ -2401,7 +2577,7 @@ "id": "scene", "versioned_id": "scene", "settings": { - "id_counter": 6, + "id_counter": 7, "custom_size": false, "items": [ { @@ -2690,6 +2866,61 @@ "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": 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.hide_scene_item.5": [], "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_field_order": 0, @@ -2735,7 +2968,7 @@ "id": "scene", "versioned_id": "scene", "settings": { - "id_counter": 6, + "id_counter": 10, "custom_size": false, "items": [ { @@ -2775,12 +3008,12 @@ "y": 1.0 }, "bounds": { - "x": 2010.0, - "y": 1213.0 + "x": 2127.0, + "y": 1289.0 }, "bounds_rel": { - "x": 3.0089819431304932, - "y": 1.8158682584762573 + "x": 3.1841316223144531, + "y": 1.9296407699584961 }, "scale_filter": "disable", "blend_method": "default", @@ -2979,20 +3212,20 @@ "id": 5, "group_item_backup": false, "pos": { - "x": 2043.0, - "y": 402.0 + "x": 2156.0, + "y": 597.0 }, "pos_rel": { - "x": 1.1422156095504761, - "y": -0.39820361137390137 + "x": 1.3113772869110107, + "y": -0.10628741979598999 }, "scale": { - "x": 0.90892529487609863, - "y": 0.90909093618392944 + "x": 0.7012750506401062, + "y": 0.70113635063171387 }, "scale_rel": { - "x": 0.90892529487609863, - "y": 0.90909093618392944 + "x": 0.7012750506401062, + "y": 0.70113635063171387 }, "bounds": { "x": 0.0, @@ -3034,20 +3267,20 @@ "id": 6, "group_item_backup": false, "pos": { - "x": 2044.0, + "x": 2158.0, "y": 13.0 }, "pos_rel": { - "x": 1.1437125205993652, + "x": 1.3143712282180786, "y": -0.98053890466690063 }, "scale": { - "x": 0.62374997138977051, - "y": 0.62333333492279053 + "x": 0.48124998807907104, + "y": 0.48166665434837341 }, "scale_rel": { - "x": 0.62374997138977051, - "y": 0.62333333492279053 + "x": 0.48124998807907104, + "y": 0.48166665434837341 }, "bounds": { "x": 0.0, @@ -3067,6 +3300,116 @@ "duration": 300 }, "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.hide_scene_item.5": [], "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_field_order": 0, @@ -3336,8 +3683,8 @@ "name": "Good Bye" } ], - "current_scene": "Music Box", - "current_program_scene": "Music Box", + "current_scene": "Good Bye", + "current_program_scene": "Good Bye", "canvases": [], "current_transition": "Luma Wipe", "transition_duration": 1000, diff --git a/bridges/mpd-state.py b/bridges/mpd-state.py index d470e63..9c04ab0 100755 --- a/bridges/mpd-state.py +++ b/bridges/mpd-state.py @@ -12,7 +12,12 @@ needs zero changes: { _type, index, total, title, artist, trackTitle, album, year, genre, file, currentTime, duration, paused, 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. `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 @@ -158,6 +163,72 @@ def _status_volume(status: dict) -> int | 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` 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 # add/remove), so we refresh every STATS_REFRESH_SEC and reuse otherwise. _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, "playback": playback, "stats": stats, + "audio": _audio_info(status, song), } @@ -261,6 +333,7 @@ class SharedState: "coverPath": None, "coverHash": None, "playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False}, "stats": None, + "audio": {"format": None, "samplerate": None, "bits": None, "channels": None, "bitrate": None}, } self.changed = asyncio.Event() diff --git a/goodbye/index.html b/goodbye/index.html index f1ee110..9b29fc4 100644 --- a/goodbye/index.html +++ b/goodbye/index.html @@ -359,6 +359,7 @@ body { // ── Sign-off sequence (terminal output) ── const PROMPT = 'OPHI-118://> '; + const MODE_KEYS = ['repeat', 'random', 'single']; const lines = [ { kind: 'cmd', text: 'unloadproject --flush --persist-state' }, { kind: 'out', text: 'closing transmission envelope...' }, @@ -425,9 +426,34 @@ body { 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 ── const playlistData = window.__PLAYLIST || { tracks: [] }; const allTracks = playlistData.tracks || []; + let bootStarted = false; + let bootFinished = false; + let lastPlaybackModes = null; + let pendingState = null; + let modeLogChain = Promise.resolve(); let npLine = null; function ensureNowPlayingLine() { @@ -456,6 +482,57 @@ body { 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; async function connectMusic() { if (!window.__OBSWS) { @@ -470,14 +547,14 @@ body { setTimeout(connectMusic, 2000); }); obs.onCustom('mpd:state', (s) => { - if (!npLine) ensureNowPlayingLine(); - if (lastTrackIndex !== null && s.index !== lastTrackIndex) { - logBeforeNp(`[audio] next: ${s.title || '?'}`); + pendingState = s; + if (!bootStarted) { + bootFromState(s); + return; } - lastTrackIndex = s.index; - npLine.querySelector('.np-title').textContent = s.title || '—'; - npLine.querySelector('.np-time').textContent = - `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`; + if (!bootFinished) return; + renderMusicState(s); + trackModeChanges(s.playback); }); } catch (e) { logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim'); @@ -487,19 +564,7 @@ body { 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(); + append('', '[audio] waiting for mpd state', 'term-dim'); connectMusic(); } diff --git a/loading/index.html b/loading/index.html index 9d59822..1432f4c 100644 --- a/loading/index.html +++ b/loading/index.html @@ -376,6 +376,7 @@ body { const cdMin = data.countdownMin ?? 5; const PROMPT = 'OPHI-118://> '; + const MODE_KEYS = ['repeat', 'random', 'single']; const rigName = (window.__TEL?.rig || 'unknown').toUpperCase(); const lines = [ { kind: 'cmd', text: 'loadproject --manifest' }, @@ -434,11 +435,36 @@ body { 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 ── // Track count comes from playlist.js; current track + progress arrive over // OBS WebSocket as 'mpd:state' broadcasts from the daemon. const playlistData = window.__PLAYLIST || { tracks: [] }; const allTracks = playlistData.tracks || []; + let bootStarted = false; + let bootFinished = false; + let lastPlaybackModes = null; + let pendingState = null; + let modeLogChain = Promise.resolve(); let npLine = null; function ensureNowPlayingLine() { @@ -467,6 +493,59 @@ body { 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. let lastTrackIndex = null; async function connectMusic() { @@ -482,15 +561,14 @@ body { setTimeout(connectMusic, 2000); }); obs.onCustom('mpd:state', (s) => { - if (!npLine) ensureNowPlayingLine(); - // Detect track change (don't log on first state, just initialize). - if (lastTrackIndex !== null && s.index !== lastTrackIndex) { - logBeforeNp(`[audio] next: ${s.title || '?'}`); + pendingState = s; + if (!bootStarted) { + bootFromState(s); + return; } - lastTrackIndex = s.index; - npLine.querySelector('.np-title').textContent = s.title || '—'; - npLine.querySelector('.np-time').textContent = - `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`; + if (!bootFinished) return; + renderMusicState(s); + trackModeChanges(s.playback); }); } catch (e) { logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim'); @@ -500,21 +578,7 @@ body { async function startMusic() { await sleep(500); - await typeCommand('mpd --queue ~/playlists/stream-mix'); - - 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(); + append('', '[audio] waiting for mpd state', 'term-dim'); connectMusic(); } diff --git a/music-box/nc-music-box.html b/music-box/nc-music-box.html index 8b2b2ae..142e530 100644 --- a/music-box/nc-music-box.html +++ b/music-box/nc-music-box.html @@ -189,11 +189,11 @@ body { color: var(--term-fg); text-shadow: 0 0 6px var(--term-glow); display: grid; - /* art | meta | LIBRARY | | spectrum | progress + /* art | meta | FILE | LIBRARY | | spectrum | progress Slack sits between the upper info block and the bottom strip so the spectrum analyzer reserve docks just above the progress bar — pairs 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; } @@ -283,6 +283,14 @@ body { } .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 on top of. Empty + transparent: the plugin draws into this region, the widget just holds the layout space so progress stays in its expected @@ -438,6 +446,14 @@ body { YEAR GENRE +
+
─ FILE ─
+
+ FORMAT + QUALITY + BITRATE +
+
─ LIBRARY ─
@@ -530,6 +546,7 @@ body { const npTitle = pinnedBlock.querySelector('.np-title'); const npTime = pinnedBlock.querySelector('.np-time'); 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 MAX_TERM_LINES = 200; const MODE_KEYS = ['repeat', 'random', 'single']; @@ -610,6 +627,20 @@ body { 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) { await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`); logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null); @@ -640,14 +671,23 @@ body { npTitle.textContent = s.title || '—'; npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`; const titles = Array.isArray(s.nextTitles) ? s.nextTitles : []; + const preview = nextPreviewConfig(s.playback); nextSlots.forEach((slot, i) => { - const v = titles[i]; + const v = i < preview.count ? titles[i] : null; if (v) { slot.textContent = v; slot.classList.remove('empty'); + nextRows[i].style.display = ''; } else { - slot.textContent = i === 0 ? '— end of queue —' : '—'; - slot.classList.add('empty'); + if (i === 0) { + slot.textContent = preview.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); 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 // from the per-tick index/total fields; render 1-indexed (mpc style). const stats = s.stats; @@ -721,6 +770,33 @@ body { const statSongs = document.getElementById('statSongs'); const statPlaytime = document.getElementById('statPlaytime'); 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)) ? n.toLocaleString('en-US') : null; diff --git a/scripts/deploy-rig.sh b/scripts/deploy-rig.sh index df4da70..5747fd8 100755 --- a/scripts/deploy-rig.sh +++ b/scripts/deploy-rig.sh @@ -18,7 +18,7 @@ # 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 +# 5. PipeWire null sinks (mpd_stream + discord_stream) + loopbacks # 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 @@ -248,20 +248,32 @@ done (( 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="$PW_CONF_DIR/mpd-stream.conf" +mkdir -p "$PW_CONF_DIR" -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 +PW_CONF_CHANGED=0 + +write_pw_conf() { + # write_pw_conf + local path="$1"; local body="$2" + if [[ -f "$path" ]]; then + 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. # # MPD ──► null sink "mpd_stream" @@ -284,23 +296,64 @@ pulse.cmd = [ 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 +' + +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 || \ + warn "could not restart pipewire-pulse (will load on next login anyway)" 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 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 + for sink in mpd_stream discord_stream; do + if pactl list short sinks 2>/dev/null | grep -q "^[0-9]*[[:space:]]\+${sink}\b"; then + ok "$sink sink loaded" + else + warn "$sink sink NOT visible to pactl — restart pipewire-pulse or relog and re-check" + fi + done fi # ──────────────────────────────────────────────────────────────────────────── @@ -572,8 +625,13 @@ cat <&2 diff --git a/station b/station index 9749729..5330c15 160000 --- a/station +++ b/station @@ -1 +1 @@ -Subproject commit 97497293715f5ac20e77679908910d856d75bdb9 +Subproject commit 5330c157fc9c62aa6bc6011ab58a3aa24d36bf65 diff --git a/twitch-bot/.env.example b/twitch-bot/.env.example deleted file mode 100644 index a59a709..0000000 --- a/twitch-bot/.env.example +++ /dev/null @@ -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= diff --git a/twitch-bot/.gitignore b/twitch-bot/.gitignore deleted file mode 100644 index ce67129..0000000 --- a/twitch-bot/.gitignore +++ /dev/null @@ -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__/ diff --git a/twitch-bot/_twitch.py b/twitch-bot/_twitch.py deleted file mode 100644 index 4900ae7..0000000 --- a/twitch-bot/_twitch.py +++ /dev/null @@ -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 diff --git a/twitch-bot/bot.py b/twitch-bot/bot.py deleted file mode 100644 index 317d167..0000000 --- a/twitch-bot/bot.py +++ /dev/null @@ -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+E0000–U+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) diff --git a/twitch-bot/search-game.py b/twitch-bot/search-game.py deleted file mode 100755 index bfb3074..0000000 --- a/twitch-bot/search-game.py +++ /dev/null @@ -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 "\\t" (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 \t 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 ", 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) diff --git a/twitch-bot/set-channel.py b/twitch-bot/set-channel.py deleted file mode 100755 index 6a892d9..0000000 --- a/twitch-bot/set-channel.py +++ /dev/null @@ -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 | ) ", - 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)