MPD and Scene fixes + twitch-bot cleanup
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -98,3 +98,4 @@ Thumbs.db
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
|
temp
|
||||||
|
|||||||
67
CLAUDE.md
67
CLAUDE.md
@@ -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.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌────────────┐
|
┌────────────┐ ┌─────────────┐
|
||||||
│ MPD │ user 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
2
station
2
station
Submodule station updated: 9749729371...5330c157fc
@@ -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
10
twitch-bot/.gitignore
vendored
@@ -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__/
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
Reference in New Issue
Block a user