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,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()}
|
||||
|
||||
Reference in New Issue
Block a user