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
+26 -11
View File
@@ -23,7 +23,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Security
from fastapi.responses import JSONResponse
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.config import settings
from app.internal import discord_leases as leases
@@ -35,14 +35,14 @@ from app.internal.logger import logger
router = APIRouter(prefix="/discord", tags=["discord"])
_bearer = HTTPBearer(auto_error=False)
# guild:channel snowflakes (leases.stream_id_for) — anything else is a 422,
# never a Firestore path.
STREAM_ID = r"^[0-9]{1,25}:[0-9]{1,25}$"
# guild:channel[:suffix] (leases.STREAM_ID_PATTERN) — old one-per-channel ids
# and new per-stream ids. Anything else is a 422, never a Firestore path.
STREAM_ID = leases.STREAM_ID_PATTERN
def _error(e: streams.StreamError) -> JSONResponse:
return JSONResponse(status_code=e.status,
content={"detail": e.message, "code": e.code, "invite": e.invite})
content={**e.extra, "detail": e.message, "code": e.code, "invite": e.invite})
async def _org_or_403(guild_id: str) -> str:
@@ -54,7 +54,8 @@ async def _org_or_403(guild_id: str) -> str:
def _public_stream(s: dict) -> dict:
keep = ("stream_id", "guild_id", "channel_id", "channel_name", "system_id", "system_name", "mode",
"desired", "node_id", "token_id", "actual", "stop_reason", "updated_at", "delivered", "reused")
"desired", "node_id", "token_id", "token_name", "actual", "stop_reason", "updated_at", "delivered",
"reused", "already_on")
return {k: s.get(k) for k in keep if k in s}
@@ -75,8 +76,13 @@ async def guild_overview(guild_id: str, _: dict = Depends(require_service_key_or
nodes = await fstore.collection_list("nodes", org_id=org_id)
gstreams = [s for s in await fstore.collection_list(streams.STREAMS, guild_id=guild_id)
if s.get("desired") == "on"]
all_tokens = await fstore.collection_list(leases.TOKENS)
names = {t.get("token_id"): t.get("name") for t in all_tokens}
# Several streams can share a voice channel; the bot names each by its bot.
gstreams = [{**s, "token_name": names.get(s.get("token_id"))} for s in gstreams]
gstreams.sort(key=lambda s: (s.get("channel_name") or "", s.get("channel_id") or "", s.get("created_at") or ""))
bots = []
for t in await fstore.collection_list(leases.TOKENS):
for t in all_tokens:
if t.get("org_id") not in (None, org_id) or t.get("valid") is False:
continue
lease = t.get("lease") or {}
@@ -104,6 +110,9 @@ class StreamStart(BaseModel):
guild_id: str
channel_id: str
system_id: str
# Set = restart / edit THAT stream. Unset = add a stream to the channel
# (never overwrites another system's stream there — several may share it).
stream_id: Optional[str] = Field(None, pattern=STREAM_ID)
mode: str = "adhoc"
preferred_token_id: Optional[str] = None
channel_name: Optional[str] = None
@@ -125,7 +134,7 @@ async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_
org_id=org_id, guild_id=body.guild_id, channel_id=body.channel_id, system_id=body.system_id,
mode=body.mode, channel_name=body.channel_name, guild_name=body.guild_name,
created_by=body.created_by, preferred_token_id=body.preferred_token_id,
max_streams=settings.discord_guild_max_streams,
max_streams=settings.discord_guild_max_streams, stream_id=body.stream_id,
)
except streams.StreamError as e:
return _error(e)
@@ -133,7 +142,8 @@ async def start_stream(body: StreamStart, _: dict = Depends(require_service_key_
@router.get("/streams/{stream_id}")
async def get_stream(stream_id: str, _: dict = Depends(require_service_key_or_admin)):
async def get_stream(stream_id: str = Path(..., pattern=STREAM_ID),
_: dict = Depends(require_service_key_or_admin)):
doc = await fstore.doc_get(streams.STREAMS, stream_id)
if not doc:
raise HTTPException(404, "Stream not found.")
@@ -141,13 +151,17 @@ async def get_stream(stream_id: str, _: dict = Depends(require_service_key_or_ad
class GuildStop(BaseModel):
# Neither: every live stream in the server. channel_id: every stream in
# that voice channel. stream_id: just that one (it must be this server's).
channel_id: Optional[str] = None
stream_id: Optional[str] = Field(None, pattern=STREAM_ID)
reason: str = "stopped"
@router.post("/guilds/{guild_id}/stop")
async def stop_guild(guild_id: str, body: GuildStop, _: dict = Depends(require_service_key_or_admin)):
stopped = await streams.stop_guild(guild_id, body.reason[:60], channel_id=body.channel_id)
stopped = await streams.stop_guild(guild_id, body.reason[:60], channel_id=body.channel_id,
stream_id=body.stream_id)
return {"stopped": stopped}
@@ -293,8 +307,9 @@ async def admin_overview(_: dict = Depends(require_admin_token)):
g["acting_org_name"] = (orgs.get(acting) or {}).get("name") if acting else None
g["live_streams"] = sum(1 for r in rows if r.get("guild_id") == g["guild_id"] and r.get("desired") == "on")
# Streams sharing a voice channel sit together, oldest first.
rows.sort(key=lambda r: (0 if r.get("desired") == "on" else 1, r.get("guild_name") or r.get("guild_id") or "",
r.get("channel_name") or r.get("channel_id") or ""))
r.get("channel_id") or "", r.get("created_at") or "", r.get("stream_id") or ""))
systems = await fstore.collection_list("systems")
# Names for every node a stream or a radio-bot lease points at.
referenced = {r.get("node_id") for r in rows} | {(t.get("lease") or {}).get("node_id") for t in tokens.values()}
+24 -13
View File
@@ -156,14 +156,16 @@ def _bot_status(tokens: dict, *, node_id: str, guild_id: str, org_id: str) -> di
"invite": [i for i in res.invite if i.get("invite_url")][:5]}
def _stream_view(s: dict, node_names: dict) -> dict:
def _stream_view(s: dict, node_names: dict, token_names: Optional[dict] = None) -> dict:
return {
"stream_id": s.get("stream_id"),
"guild_id": s.get("guild_id"),
"channel_id": s.get("channel_id"),
"guild_name": s.get("guild_name"),
"channel_name": s.get("channel_name"),
"system_id": s.get("system_id"),
"system_name": s.get("system_name"),
"token_name": (token_names or {}).get(s.get("token_id") or ""),
"mode": s.get("mode"),
"node_id": s.get("node_id"),
"node_name": node_names.get(s.get("node_id") or "") or s.get("node_id"),
@@ -184,8 +186,12 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
if s.get("org_id") == org_id]
nodes = {n.get("node_id"): n.get("name") for n in await fstore.collection_list("nodes", org_id=org_id)
if n.get("node_id")} if org_id else {}
by_channel = {s.get("stream_id"): s for s in live}
# Several streams can share one voice channel (each its own bot + system).
by_channel: dict[tuple, list] = {}
for s in sorted(live, key=lambda s: (s.get("created_at") or "", s.get("stream_id") or "")):
by_channel.setdefault((str(s.get("guild_id")), str(s.get("channel_id"))), []).append(s)
tokens = {t["token_id"]: t for t in await fstore.collection_list(leases.TOKENS) if t.get("token_id")}
token_names = {tid: t.get("name") or tid for tid, t in tokens.items()}
out_guilds = []
for g in guilds:
@@ -193,12 +199,16 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
channels = []
for c in sorted(g.get("voice_channels") or [],
key=lambda c: (c.get("position") or 0, (c.get("name") or "").lower())):
s = by_channel.get(leases.stream_id_for(gid, str(c.get("channel_id"))))
on = by_channel.get((gid, str(c.get("channel_id"))), [])
# ``stream``: the one a pick here would touch — this system's (a
# pick restarts / moves it). Every other stream is left alone.
mine = next((s for s in on if s.get("system_id") == system_id), None)
channels.append({
"channel_id": str(c.get("channel_id")),
"name": c.get("name"),
"category": c.get("category"),
"stream": _stream_view(s, nodes) if s else None,
"stream": _stream_view(mine, nodes, token_names) if mine else None,
"streams": [_stream_view(s, nodes, token_names) for s in on],
})
out_guilds.append({
"guild_id": gid,
@@ -227,7 +237,7 @@ async def join_options(node_id: str, decoded: dict = Depends(require_firebase_to
"guild_max": settings.discord_guild_max_streams,
},
"guilds": out_guilds,
"streams": [_stream_view(s, nodes) for s in here],
"streams": [_stream_view(s, nodes, token_names) for s in here],
}
@@ -259,8 +269,9 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
raise HTTPException(400, "That is not a voice channel DRB can see in this server. "
"Refresh the list and pick again.")
stream_id = leases.stream_id_for(guild_id, channel_id)
existing = await fstore.doc_get(streams.STREAMS, stream_id) or {}
# Same system already on in this channel = that stream (moved here if it
# is on another node); anything else is a NEW stream beside the others.
existing = await streams.find_channel_stream(guild_id, channel_id, system_id) or {}
already_here = existing.get("desired") == "on" and existing.get("node_id") == node_id
if not already_here:
used = sum(1 for s in await fstore.collection_list(streams.STREAMS, node_id=node_id)
@@ -279,16 +290,16 @@ async def start_node_stream(node_id: str, body: NodeStreamStart, decoded: dict =
)
except streams.StreamError as e:
return JSONResponse(status_code=e.status,
content={"detail": e.message, "code": e.code, "invite": e.invite})
logger.info(f"Web: radio channel {stream_id} pinned to node {node_id} ({body.mode})")
content={**e.extra, "detail": e.message, "code": e.code, "invite": e.invite})
logger.info(f"Web: radio channel {doc['stream_id']} pinned to node {node_id} ({body.mode})")
return {"ok": True, "stream_id": doc["stream_id"], "token_id": doc.get("token_id"),
"delivered": doc.get("delivered"), "reused": doc.get("reused"), "actual": doc.get("actual")}
class NodeStreamStop(BaseModel):
# guild:channel snowflakes (leases.stream_id_for). Validated so a malformed
# id (e.g. one with "/") is a 422, not a Firestore path error / 500.
stream_id: Optional[str] = Field(None, pattern=r"^[0-9]{1,25}:[0-9]{1,25}$")
# guild:channel[:suffix] (leases.STREAM_ID_PATTERN). Validated so a
# malformed id (e.g. one with "/") is a 422, not a Firestore path error / 500.
stream_id: Optional[str] = Field(None, pattern=leases.STREAM_ID_PATTERN)
@router.post("/nodes/{node_id}/streams/stop")
@@ -307,7 +318,7 @@ async def stop_node_streams(node_id: str, body: NodeStreamStop, decoded: dict =
@router.delete("/nodes/{node_id}/streams/{stream_id}")
async def remove_node_stream(node_id: str,
stream_id: str = Path(..., pattern=r"^[0-9]{1,25}:[0-9]{1,25}$"),
stream_id: str = Path(..., pattern=leases.STREAM_ID_PATTERN),
decoded: dict = Depends(require_firebase_token)):
"""Permanently remove a radio channel this node carries (or last carried):
stopped through desired state first so the node logs the bot out and the