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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user