Discord: several radio streams per voice channel; adding never overwrites (#213) #227

Merged
logan merged 2 commits from fix/213-multi-stream-per-channel into main 2026-10-05 01:44:36 -04:00
Owner

Owner-reported on live: on /settings/discord (and node page, bot /join), adding a radio channel for a server + voice channel that already had one — different label, system, even preferred bot — overwrote the existing stream and made its bot leave. Root cause: stream_id = guild:channel, one doc per voice channel, start_stream upserted.

Owner decision: several radio streams may share one voice channel, each with its own bot, each playing its own system.

What changed

  • Ids: new streams are guild:channel:<12 hex> (leases.new_stream_id). Old guild:channel docs keep working (no migration) for read, stop, remove, node reports. Every route validator uses leases.STREAM_ID_PATTERN = ^[0-9]{1,25}:[0-9]{1,25}(?::[0-9a-f]{8,32})?$.
  • Add vs edit: POST /discord/streams without stream_id adds. Same system already on in that channel → returns it (already_on, label/mode untouched). Stopped stream of that system → restarted. Anything else → new doc. With stream_id → explicit restart/edit (admin Start button, reconciler), including moving to another bot.
  • One bot per server (Discord rule): already guaranteed by #216's lease transaction (pick_token skips a token with a stream in the guild, read + write in one txn). Now covered by race tests. preferred_token_id is strict: bot in use in this server → 409 preferred_busy naming the stream (busy_stream_id); bot on another node → preferred_other_node; unusable → preferred_unavailable. pool_exhausted text now says one-voice-channel-per-server and to add + invite another bot.
  • Per-guild asyncio lock around "already on?" + server-cap check + create, so concurrent adds of one system make one stream and can't overshoot discord_guild_max_streams (the guild half of #223; per-node cap still open there).
  • Keyed by stream_id: POST /discord/guilds/{id}/stop takes stream_id; admin table Stop/Start/Remove act on the row (one row per stream, "+N other streams in this voice channel", bot column, busy bots disabled in Add form); node picker shows every stream per channel and adds beside other systems; guild overview + join-options carry token_name.
  • Bot /leave choice: stream: (autocomplete of this server's streams) stops one; channel: with several streams lists them and asks unless all:True; bare /leave still stops the whole server (unchanged). /status and /join name each stream's bot; /join with an already-playing system says nothing was replaced.

Edge node — no Client change

discord_radio.py keys by opaque stream_id, one discord.Client per token, so two tokens in one guild+channel each have their own guild.voice_client. Verified with a scratch test against Client main (5770eca) using per-client guild caches: old id 1:11 + new id 1:11:0123456789ab, tokens t1/t2 → both connected and playing, presence per bot, dropping one leaves the other playing. No stream_id format validation exists on the edge.

Tests

  • c2-core: 684 passed (~/venvs/drb-5c/bin/python -m pytest -q), incl. new tests/test_discord_multi_stream.py (21): two streams same channel different bots; same preferred bot refused by name; preferred on another node; pool exhausted hint; same-system idempotent + duplicate-bot 409; stop by id leaves sibling; restart/edit by id; reconciler re-places each by id; lease races (two new streams one guild → two bots, last free bot, same preferred bot); concurrent adds dedup + cap (mutation-checked: removing the lock fails both); old-id stop/remove/node-page/report; node page and admin page flows.
  • Frontend: npx tsc --noEmit exit 0 (portable Node 20 on a copy of this branch).
  • flake8 --max-line-length=120 on changed Python: clean. Discord bot: import/command-tree check only (no test suite).

Merge order: standalone on main (16a609e). No Client PR.

🤖 Generated with Claude Code

Owner-reported on live: on /settings/discord (and node page, bot /join), adding a radio channel for a server + voice channel that already had one — different label, system, even preferred bot — **overwrote** the existing stream and made its bot leave. Root cause: `stream_id = guild:channel`, one doc per voice channel, `start_stream` upserted. **Owner decision:** several radio streams may share one voice channel, each with its own bot, each playing its own system. ## What changed - **Ids:** new streams are `guild:channel:<12 hex>` (`leases.new_stream_id`). Old `guild:channel` docs keep working (no migration) for read, stop, remove, node reports. Every route validator uses `leases.STREAM_ID_PATTERN` = `^[0-9]{1,25}:[0-9]{1,25}(?::[0-9a-f]{8,32})?$`. - **Add vs edit:** `POST /discord/streams` without `stream_id` adds. Same system already on in that channel → returns it (`already_on`, label/mode untouched). Stopped stream of that system → restarted. Anything else → new doc. With `stream_id` → explicit restart/edit (admin Start button, reconciler), including moving to another bot. - **One bot per server (Discord rule):** already guaranteed by #216's lease transaction (`pick_token` skips a token with a stream in the guild, read + write in one txn). Now covered by race tests. `preferred_token_id` is strict: bot in use in this server → **409 `preferred_busy`** naming the stream (`busy_stream_id`); bot on another node → `preferred_other_node`; unusable → `preferred_unavailable`. `pool_exhausted` text now says one-voice-channel-per-server and to add + invite another bot. - **Per-guild asyncio lock** around "already on?" + server-cap check + create, so concurrent adds of one system make one stream and can't overshoot `discord_guild_max_streams` (the guild half of #223; per-node cap still open there). - **Keyed by stream_id:** `POST /discord/guilds/{id}/stop` takes `stream_id`; admin table Stop/Start/Remove act on the row (one row per stream, \"+N other streams in this voice channel\", bot column, busy bots disabled in Add form); node picker shows every stream per channel and adds beside other systems; guild overview + join-options carry `token_name`. - **Bot `/leave` choice:** `stream:` (autocomplete of this server's streams) stops one; `channel:` with several streams lists them and asks unless `all:True`; bare `/leave` still stops the whole server (unchanged). `/status` and `/join` name each stream's bot; `/join` with an already-playing system says nothing was replaced. ## Edge node — no Client change `discord_radio.py` keys by opaque `stream_id`, one `discord.Client` per token, so two tokens in one guild+channel each have their own `guild.voice_client`. Verified with a scratch test against Client `main` (5770eca) using per-client guild caches: old id `1:11` + new id `1:11:0123456789ab`, tokens t1/t2 → both connected and playing, presence per bot, dropping one leaves the other playing. No stream_id format validation exists on the edge. ## Tests - c2-core: `684 passed` (`~/venvs/drb-5c/bin/python -m pytest -q`), incl. new `tests/test_discord_multi_stream.py` (21): two streams same channel different bots; same preferred bot refused by name; preferred on another node; pool exhausted hint; same-system idempotent + duplicate-bot 409; stop by id leaves sibling; restart/edit by id; reconciler re-places each by id; lease races (two new streams one guild → two bots, last free bot, same preferred bot); concurrent adds dedup + cap (mutation-checked: removing the lock fails both); old-id stop/remove/node-page/report; node page and admin page flows. - Frontend: `npx tsc --noEmit` exit 0 (portable Node 20 on a copy of this branch). - flake8 --max-line-length=120 on changed Python: clean. Discord bot: import/command-tree check only (no test suite). Merge order: standalone on main (16a609e). No Client PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
logan added 1 commit 2026-10-05 00:31:34 -04:00
Owner-reported on live: adding a radio channel for a server + voice channel
that already had one (different label, system, even bot) overwrote the
existing stream and made its bot leave, because stream_id was guild:channel.

- New streams get guild:channel:<12 hex> ids (leases.new_stream_id); old
  guild:channel docs keep working for read/stop/remove/node reports. Route
  validators accept both (leases.STREAM_ID_PATTERN).
- start_stream: no stream_id = ADD. Same system already on in that channel
  is idempotent (already_on, label/mode untouched); a stopped one of that
  system is restarted; anything else is a new doc. stream_id = explicit
  restart/edit (admin Start button, reconciler), incl. moving to another bot.
- preferred_token_id is strict: bot already in this server -> 409
  preferred_busy naming the stream; on another node -> preferred_other_node;
  unusable -> preferred_unavailable. No silent swap. pool_exhausted now says
  one-bot-per-server and to add + invite another bot.
- One-token-per-guild was already enforced inside the lease transaction;
  now covered by race tests. Per-guild asyncio lock serialises the
  already-on / server-cap check with the create (guild half of #223).
- Stop by stream_id (POST /discord/guilds/{id}/stop {stream_id}); admin UI
  Stop/Start/Remove act on the row's stream; node picker lists every stream
  in a channel and adds beside other systems.
- Bot: /leave stream: (autocomplete) stops one; /leave channel: with several
  streams asks which unless all:True; bare /leave still stops the server.
  /status and /join name each stream's bot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan added 1 commit 2026-10-05 00:39:31 -04:00
Re-adding the same system to a voice channel returns the running stream
untouched (already_on). The form closed as if a stream had been added and
the new label/bot was silently dropped; it now stays open and explains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
logan merged commit c4e1f9aa61 into main 2026-10-05 01:44:36 -04:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: logan/server-26#227