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:
co-authored by
Claude Opus 5.5
parent
16a609e35b
commit
8af410aede
@@ -23,6 +23,13 @@ STATE_TEXT = {
|
||||
}
|
||||
|
||||
|
||||
def _stream_name(s: dict) -> str:
|
||||
"""How one radio stream is named to a Discord user. Several streams can
|
||||
share a voice channel, so the system and the bot are what tell them apart."""
|
||||
bot = f" ({s['token_name']})" if s.get("token_name") else ""
|
||||
return f"{s.get('system_name') or s.get('system_id') or 'radio'}{bot}"
|
||||
|
||||
|
||||
def _refusal_text(body: dict) -> str:
|
||||
text = body.get("detail") or "DRB could not start radio in that channel."
|
||||
if isinstance(text, dict): # FastAPI HTTPException detail
|
||||
@@ -40,6 +47,15 @@ class RadioCommands(commands.Cog):
|
||||
server is linked to and never returns or touches anything outside it.
|
||||
/join and /leave default to members with Manage Channels — a server admin
|
||||
can hand them to a DJ role under Server Settings > Integrations.
|
||||
|
||||
A voice channel can carry SEVERAL radio streams, each its own bot playing
|
||||
its own system (server-26#213 follow-up). So:
|
||||
* /join adds a stream for the chosen system; it never replaces another
|
||||
system's stream in the channel. The same system again is a no-op.
|
||||
* /leave ``stream:`` stops exactly one (autocomplete lists this server's).
|
||||
* /leave ``channel:`` stops that channel's stream; if it has several it
|
||||
lists them and asks which (``stream:``) — or ``all:True`` for every one.
|
||||
* /leave with nothing stops every stream in this server (unchanged).
|
||||
"""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
@@ -91,6 +107,21 @@ class RadioCommands(commands.Cog):
|
||||
out.append(app_commands.Choice(name=f"{name} ({label})", value=b["token_id"]))
|
||||
return out[:25]
|
||||
|
||||
async def stream_autocomplete(
|
||||
self, interaction: discord.Interaction, current: str
|
||||
) -> list[app_commands.Choice[str]]:
|
||||
if not interaction.guild_id:
|
||||
return []
|
||||
data = await self._guild(interaction.guild_id)
|
||||
out = []
|
||||
for s in data.get("streams", []):
|
||||
ch = interaction.guild.get_channel(int(s["channel_id"])) if interaction.guild else None
|
||||
where = f"#{ch.name}" if ch else (s.get("channel_name") or f"channel {s['channel_id']}")
|
||||
name = f"{where} — {_stream_name(s)}"
|
||||
if current.lower() in name.lower():
|
||||
out.append(app_commands.Choice(name=name[:100], value=s["stream_id"]))
|
||||
return out[:25]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /join
|
||||
# ------------------------------------------------------------------
|
||||
@@ -131,6 +162,8 @@ class RadioCommands(commands.Cog):
|
||||
|
||||
system_name = body.get("system_name") or system
|
||||
head = f"**{system_name}** → {channel.mention}"
|
||||
if body.get("already_on"):
|
||||
head += "\nThat system is already playing in this channel — nothing was replaced."
|
||||
if body.get("delivered") is False:
|
||||
head += "\nDRB's message broker is down; the node will get this as soon as it is back."
|
||||
msg = await interaction.followup.send(f"{head}\n{STATE_TEXT['pending']}", ephemeral=True, wait=True)
|
||||
@@ -169,22 +202,42 @@ class RadioCommands(commands.Cog):
|
||||
# /leave — this server only
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app_commands.command(name="leave", description="Stop radio in this server (or in one channel).")
|
||||
@app_commands.command(name="leave", description="Stop radio in this server, one channel, or one stream.")
|
||||
@app_commands.guild_only()
|
||||
@app_commands.default_permissions(manage_channels=True)
|
||||
@app_commands.describe(channel="Only stop radio in this voice channel.")
|
||||
@app_commands.describe(
|
||||
channel="Only stop radio in this voice channel.",
|
||||
stream="Stop just this radio stream (a channel can carry several).",
|
||||
every="With channel: stop every stream in it without asking.",
|
||||
)
|
||||
@app_commands.rename(every="all")
|
||||
@app_commands.autocomplete(stream=stream_autocomplete)
|
||||
async def leave(self, interaction: discord.Interaction,
|
||||
channel: Optional[discord.VoiceChannel] = None):
|
||||
channel: Optional[discord.VoiceChannel] = None,
|
||||
stream: Optional[str] = None,
|
||||
every: bool = False):
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
stopped = await c2.stop_guild(interaction.guild_id, str(channel.id) if channel else None)
|
||||
if channel and not stream and not every:
|
||||
# Several streams in one channel: say which, never guess.
|
||||
data = await self._guild(interaction.guild_id, fresh=True)
|
||||
here = [s for s in data.get("streams", []) if str(s.get("channel_id")) == str(channel.id)]
|
||||
if len(here) > 1:
|
||||
names = "\n".join(f"- {_stream_name(s)}" for s in here)
|
||||
await interaction.followup.send(
|
||||
f"{channel.mention} has {len(here)} radio streams:\n{names}\n"
|
||||
"Run `/leave stream:` and pick one, or `/leave channel:` with `all:True` to stop them all.",
|
||||
ephemeral=True)
|
||||
return
|
||||
stopped = await c2.stop_guild(interaction.guild_id, str(channel.id) if channel and not stream else None,
|
||||
stream_id=stream)
|
||||
self._overview.pop(interaction.guild_id, None)
|
||||
if stopped is None:
|
||||
await interaction.followup.send("Could not reach DRB. Try again in a minute.", ephemeral=True)
|
||||
elif not stopped:
|
||||
where = channel.mention if channel else "this server"
|
||||
where = "that stream" if stream else (channel.mention if channel else "this server")
|
||||
await interaction.followup.send(f"No radio is playing in {where}.", ephemeral=True)
|
||||
else:
|
||||
await interaction.followup.send(f"Stopped radio in {len(stopped)} channel(s).", ephemeral=True)
|
||||
await interaction.followup.send(f"Stopped {len(stopped)} radio stream(s).", ephemeral=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# /status — this server's org only
|
||||
@@ -206,7 +259,7 @@ class RadioCommands(commands.Cog):
|
||||
for s in data.get("streams", []):
|
||||
actual = (s.get("actual") or {})
|
||||
state = actual.get("state") or "pending"
|
||||
lines.append(f"<#{s['channel_id']}> — {s.get('system_name') or s.get('system_id')} · `{state}`"
|
||||
lines.append(f"<#{s['channel_id']}> — {_stream_name(s)} · `{state}`"
|
||||
+ (" · persistent" if s.get("mode") == "persistent" else ""))
|
||||
embed.add_field(name="Radio channels", value="\n".join(lines) or "None playing.", inline=False)
|
||||
dot = {"online": "🟢", "recording": "🔴", "offline": "⚫", "unconfigured": "🟡"}
|
||||
@@ -239,14 +292,17 @@ class RadioCommands(commands.Cog):
|
||||
name="/join `system` [`bot`] [`persistent`]",
|
||||
value=(
|
||||
"Put a radio system on your current voice channel. A radio bot joins and plays it.\n"
|
||||
"Run it again with another system to add a second stream (its own bot) to the same channel — "
|
||||
"it never replaces one that is playing. A bot can be in one voice channel per server.\n"
|
||||
"`persistent` keeps the channel on radio until `/leave`, reconnecting by itself.\n"
|
||||
"Needs Manage Channels unless a server admin grants it to another role."
|
||||
),
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(
|
||||
name="/leave [`channel`]",
|
||||
value="Stop radio in this server, or only in one voice channel. Never affects other servers.",
|
||||
name="/leave [`channel`] [`stream`] [`all`]",
|
||||
value=("Stop radio in this server, one voice channel, or one `stream`. If a channel has several "
|
||||
"streams, `/leave channel:` asks which (or add `all:True`). Never affects other servers."),
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(
|
||||
|
||||
Reference in New Issue
Block a user