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

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>
This commit is contained in:
Logan Cusano
2026-10-05 00:30:40 -04:00
co-authored by Claude Opus 5.5
parent 16a609e35b
commit 8af410aede
15 changed files with 913 additions and 113 deletions
+22 -4
View File
@@ -5,6 +5,10 @@
// the Discord servers linked to this node's org and the voice channels the
// DRB bot reported in them; a pick becomes a radio channel pinned to this node
// (desired state — the node reconciles and reports back, shown per row).
// A voice channel can carry several radio streams, each its own bot playing
// its own system (server-26#213 follow-up): picking a channel that already
// plays another system ADDS this node's system beside it; only a stream of
// this node's own system there is restarted / moved here.
// Layout follows design-canvas/discord-system-mockup.html "Radio channels".
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -64,6 +68,11 @@ function modeLabel(mode: DiscordStreamView["mode"]) {
return mode === "persistent" ? "persistent" : "ad-hoc";
}
function streamLabel(s: DiscordStreamView) {
const sys = s.system_name ?? "radio";
return s.token_name ? `${sys} (${s.token_name})` : sys;
}
// ---------------------------------------------------------------------------
// Picker
// ---------------------------------------------------------------------------
@@ -199,6 +208,7 @@ function DiscordPicker({
<div className="space-y-1">
{grp.channels.map((c) => {
const here = c.stream?.node_id === nodeId;
const others = (c.streams ?? []).filter((x) => x.stream_id !== c.stream?.stream_id);
return (
<label
key={c.channel_id}
@@ -221,8 +231,14 @@ function DiscordPicker({
{c.stream && (
<span className="text-xs text-ink-muted block">
{here
? "Already on this node"
: `Carried by ${c.stream.node_name ?? "another node"} — picking it moves it to this node`}
? `${options.system_name ?? "This system"} is already on this node here`
: `${options.system_name ?? "This system"} is carried by ${c.stream.node_name ?? "another node"} — picking it moves it to this node`}
</span>
)}
{others.length > 0 && (
<span className="text-xs text-ink-muted block">
Also playing: {others.map(streamLabel).join(", ")}
{!c.stream && ` — this adds ${options.system_name ?? "this node's system"} beside it with another bot`}
</span>
)}
</span>
@@ -356,7 +372,7 @@ export function NodeDiscordRadio({ nodeId }: { nodeId: string }) {
async function remove(s: DiscordStreamView) {
const name = s.channel_name ?? "this channel";
if (!confirm(`Remove radio from ${name} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB.`)) return;
if (!confirm(`Remove ${streamLabel(s)} from ${name} permanently? It is stopped first (the node logs its radio bot out), then deleted from DRB. Other streams in that voice channel keep playing.`)) return;
setBusy(`rm:${s.stream_id}`);
setActionError(null);
try {
@@ -429,7 +445,9 @@ export function NodeDiscordRadio({ nodeId }: { nodeId: string }) {
<tr key={s.stream_id} className="border-b border-line last:border-0 align-top">
<td className="px-4 py-2.5">
<div className="text-ink font-medium">🔊 {s.channel_name ?? s.channel_id}</div>
<div className="text-xs text-ink-muted">{s.guild_name ?? s.guild_id}</div>
<div className="text-xs text-ink-muted">
{s.guild_name ?? s.guild_id} · {streamLabel(s)}
</div>
</td>
<td className="px-4 py-2.5 text-ink-2">{modeLabel(s.mode)}</td>
<td className="px-4 py-2.5">