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
@@ -32,7 +32,15 @@ Firestore layout:
|
||||
in_use / assigned_node_id / assigned_at — legacy mirrors of ``lease``,
|
||||
still written so the old /tokens page and any reader of them stay right.
|
||||
|
||||
discord_streams/{guild_id}:{channel_id} — see discord_streams.py.
|
||||
discord_streams/{stream_id} — see discord_streams.py. ``stream_id`` is
|
||||
``{guild_id}:{channel_id}:{suffix}`` (server-26#213 follow-up: several
|
||||
streams may share one voice channel, each with its own bot); docs created
|
||||
before that are plain ``{guild_id}:{channel_id}`` and keep working.
|
||||
|
||||
Several streams in ONE guild therefore always hold DIFFERENT tokens: a token's
|
||||
lease lists its streams and ``pick_token`` skips any token that already has a
|
||||
stream in the guild. Both the read and the write happen inside the lease
|
||||
transaction, so two concurrent starts in one guild can never share a token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -63,10 +71,23 @@ def invite_url(application_id: Optional[str], guild_id: Optional[str] = None) ->
|
||||
return url
|
||||
|
||||
|
||||
# Old ids are ``guild:channel``; new ones add ``:<hex>``. Safe as a Firestore
|
||||
# doc id (no "/") and as a URL path segment. Every route validates against it.
|
||||
STREAM_ID_PATTERN = r"^[0-9]{1,25}:[0-9]{1,25}(?::[0-9a-f]{8,32})?$"
|
||||
|
||||
|
||||
def stream_id_for(guild_id: str, channel_id: str) -> str:
|
||||
"""The LEGACY one-stream-per-channel id. Only for reading docs created
|
||||
before several streams could share a channel — never for a new stream."""
|
||||
return f"{guild_id}:{channel_id}"
|
||||
|
||||
|
||||
def new_stream_id(guild_id: str, channel_id: str) -> str:
|
||||
"""A fresh id for a new stream. Unique per stream, so adding a radio
|
||||
channel can never land on (and overwrite) an existing one."""
|
||||
return f"{guild_id}:{channel_id}:{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
def guild_of(stream_id: str) -> str:
|
||||
return stream_id.split(":", 1)[0]
|
||||
|
||||
@@ -81,10 +102,15 @@ class LeaseResult:
|
||||
token_id: Optional[str] = None
|
||||
lease_id: Optional[str] = None
|
||||
reused: bool = False
|
||||
# error codes: pool_empty | no_bot_in_guild | pool_exhausted
|
||||
# error codes: pool_empty | no_bot_in_guild | pool_exhausted |
|
||||
# preferred_busy (the chosen bot already has a stream in this guild) |
|
||||
# preferred_other_node (the chosen bot is logged in on another node) |
|
||||
# preferred_unavailable (unknown, invalid, or another org's bot)
|
||||
error: Optional[str] = None
|
||||
# bots that would work if invited to this guild: [{name, invite_url}]
|
||||
invite: list = field(default_factory=list)
|
||||
# preferred_* errors: {token_name, busy_stream_id, node_id}
|
||||
detail: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _lease_streams(tok: dict) -> list:
|
||||
@@ -124,9 +150,15 @@ def pick_token(
|
||||
preferred_token_id: Optional[str] = None,
|
||||
exclude_stream_id: Optional[str] = None,
|
||||
) -> LeaseResult:
|
||||
"""Pure choice over a snapshot of bot_tokens. No I/O — unit tested directly."""
|
||||
"""Pure choice over a snapshot of bot_tokens. No I/O — unit tested directly.
|
||||
|
||||
A ``preferred_token_id`` is a demand, not a hint: if that bot cannot take
|
||||
the stream the result says why instead of quietly using another bot."""
|
||||
if not tokens:
|
||||
return LeaseResult(ok=False, error="pool_empty")
|
||||
if preferred_token_id:
|
||||
return _pick_preferred(tokens, preferred_token_id, node_id=node_id, guild_id=guild_id,
|
||||
org_id=org_id, exclude_stream_id=exclude_stream_id)
|
||||
usable: list[tuple] = []
|
||||
not_member: list[dict] = []
|
||||
for tok_id, tok in tokens.items():
|
||||
@@ -155,6 +187,31 @@ def pick_token(
|
||||
return LeaseResult(ok=False, error="pool_exhausted")
|
||||
|
||||
|
||||
def _pick_preferred(tokens: dict[str, dict], tok_id: str, *, node_id: str, guild_id: str,
|
||||
org_id: Optional[str], exclude_stream_id: Optional[str]) -> LeaseResult:
|
||||
tok = tokens.get(tok_id)
|
||||
owner = (tok or {}).get("org_id")
|
||||
if not tok or tok.get("valid") is False or (owner is not None and owner != org_id):
|
||||
return LeaseResult(ok=False, error="preferred_unavailable",
|
||||
detail={"token_name": (tok or {}).get("name") or tok_id})
|
||||
name = tok.get("name") or tok_id
|
||||
busy = [s for s in _lease_streams(tok) if s != exclude_stream_id and guild_of(s) == guild_id]
|
||||
if busy:
|
||||
# One voice connection per bot per guild (Discord): never move the
|
||||
# bot off another stream to serve this one.
|
||||
return LeaseResult(ok=False, error="preferred_busy",
|
||||
detail={"token_name": name, "busy_stream_id": busy[0]})
|
||||
lease = tok.get("lease") or {}
|
||||
if lease.get("node_id") and lease["node_id"] != node_id:
|
||||
return LeaseResult(ok=False, error="preferred_other_node",
|
||||
detail={"token_name": name, "node_id": lease["node_id"]})
|
||||
members = tok.get("guild_ids")
|
||||
if members is not None and guild_id not in members:
|
||||
return LeaseResult(ok=False, error="no_bot_in_guild",
|
||||
invite=[{"name": name, "invite_url": invite_url(tok.get("application_id"), guild_id)}])
|
||||
return LeaseResult(ok=True, token_id=tok_id)
|
||||
|
||||
|
||||
async def lease_for_stream(
|
||||
stream_id: str,
|
||||
*,
|
||||
@@ -186,6 +243,8 @@ async def lease_for_stream(
|
||||
and (prev_tok.get("lease") or {}).get("node_id") == node_id
|
||||
and stream_id in _lease_streams(prev_tok)
|
||||
and prev_tok.get("valid") is not False
|
||||
# An explicit edit to a different bot moves the stream to it.
|
||||
and (not preferred_token_id or preferred_token_id == prev_tok_id)
|
||||
):
|
||||
lease = prev_tok["lease"]
|
||||
if lease.get("releasing"):
|
||||
|
||||
Reference in New Issue
Block a user