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
+37 -25
View File
@@ -34,6 +34,7 @@ def fs(monkeypatch):
from app.internal.mqtt_handler import mqtt_handler
monkeypatch.setattr(mqtt_handler, "publish_discord_desired", _publish)
streams._publish_locks.clear()
streams._guild_locks.clear()
async def _no_discord(token_ids):
return 0
@@ -70,6 +71,15 @@ def _lease(fake, token_id):
return (fake.get("bot_tokens", token_id) or {}).get("lease")
def _sid(fake, guild="g1", channel="c1"):
"""The id of the one stream start_stream created in this channel (new
streams get a unique ``guild:channel:suffix`` id — server-26#213)."""
ids = [k for k, d in (fake.data.get("discord_streams") or {}).items()
if d.get("guild_id") == guild and d.get("channel_id") == channel]
assert len(ids) == 1, ids
return ids[0]
# ---------------------------------------------------------------------------
# pick_token — pure choice
# ---------------------------------------------------------------------------
@@ -118,7 +128,8 @@ async def test_rejoin_reuses_the_lease_instead_of_taking_a_second_token(fs):
assert again["token_id"] == first["token_id"] and again["reused"] is True
leased = [t for t in ("t1", "t2") if _lease(fs, t)]
assert leased == [first["token_id"]] # server-26#204 item 2
assert _lease(fs, first["token_id"])["streams"] == ["g1:c1"]
assert _lease(fs, first["token_id"])["streams"] == [first["stream_id"]]
assert again["stream_id"] == first["stream_id"] and again["already_on"] is True
async def test_preferred_token_is_honoured(fs):
@@ -144,9 +155,9 @@ async def test_join_never_moves_another_guilds_stream(fs):
_system(fs); _node(fs, "n1"); _token(fs, "t1")
await _start("gA", "c1")
await _start("gB", "c1")
a = fs.get("discord_streams", "gA:c1")
a = fs.get("discord_streams", _sid(fs, "gA", "c1"))
assert a["desired"] == "on" and a["token_id"] == "t1"
assert sorted(_lease(fs, "t1")["streams"]) == ["gA:c1", "gB:c1"]
assert sorted(_lease(fs, "t1")["streams"]) == sorted([_sid(fs, "gA", "c1"), _sid(fs, "gB", "c1")])
async def test_desired_state_never_carries_the_token_string(fs):
@@ -236,7 +247,7 @@ async def test_two_guilds_same_node_racing_share_one_session(fs):
async def test_stop_marks_releasing_and_holds_until_the_node_acks(fs):
_system(fs); _node(fs, "n1"); _node(fs, "n2"); _token(fs, "t1")
await _start()
await streams.stop_stream("g1:c1")
await streams.stop_stream(_sid(fs, "g1", "c1"))
lease = _lease(fs, "t1")
assert lease["releasing"] is True and lease["streams"] == []
drop_seq = lease["release_seq"]
@@ -260,25 +271,26 @@ async def test_stop_marks_releasing_and_holds_until_the_node_acks(fs):
async def test_restart_before_release_reuses_the_logged_in_session(fs):
_system(fs); _node(fs, "n1"); _token(fs, "t1"); _token(fs, "t2")
first = await _start()
await streams.stop_stream("g1:c1")
await streams.stop_stream(_sid(fs, "g1", "c1"))
again = await _start()
assert again["token_id"] == first["token_id"]
lease = _lease(fs, first["token_id"])
assert lease["releasing"] is False and lease["streams"] == ["g1:c1"]
assert lease["releasing"] is False and lease["streams"] == [first["stream_id"]]
assert again["stream_id"] == first["stream_id"] # restarted, not a second doc
async def test_handle_actual_settles_and_ignores_other_nodes_streams(fs):
_system(fs); _node(fs, "n1"); _node(fs, "n2"); _token(fs, "t1"); _token(fs, "t2")
await _start("g1", "c1", node_id="n1")
await _start("g2", "c1", node_id="n2")
a = await _start("g1", "c1", node_id="n1")
b = await _start("g2", "c1", node_id="n2")
await streams.handle_actual("n1", {
"applied_seq": 1, "sessions": ["t1"],
"streams": {"g1:c1": {"state": "connected"}, "g2:c1": {"state": "error", "error": "spoof"}},
"streams": {a["stream_id"]: {"state": "connected"}, b["stream_id"]: {"state": "error", "error": "spoof"}},
})
assert fs.get("discord_streams", "g1:c1")["actual"]["state"] == "connected"
assert fs.get("discord_streams", "g2:c1")["actual"]["state"] == "pending" # not n1's to report
assert fs.get("discord_streams", _sid(fs, "g1", "c1"))["actual"]["state"] == "connected"
assert fs.get("discord_streams", _sid(fs, "g2", "c1"))["actual"]["state"] == "pending" # not n1's to report
await streams.stop_stream("g1:c1")
await streams.stop_stream(_sid(fs, "g1", "c1"))
seq = _lease(fs, "t1")["release_seq"]
await streams.handle_actual("n1", {"applied_seq": seq, "sessions": [], "streams": {}})
assert _lease(fs, "t1") is None
@@ -295,7 +307,7 @@ async def test_failed_publish_is_retried_by_the_reconciler(fs):
n = len(fs.published)
await streams.reconcile_once()
assert len(fs.published) > n and fs.get("discord_nodes", "n1")["published"] is True
assert fs.published[-1][1]["streams"][0]["stream_id"] == "g1:c1"
assert fs.published[-1][1]["streams"][0]["stream_id"] == doc["stream_id"]
async def test_concurrent_publishes_keep_the_retained_list_newest(fs):
@@ -315,9 +327,9 @@ async def test_leave_in_one_guild_never_touches_another(fs):
await _start("gA", "c1")
await _start("gB", "c1")
stopped = await streams.stop_guild("gA", "leave")
assert stopped == ["gA:c1"]
assert fs.get("discord_streams", "gB:c1")["desired"] == "on"
assert fs.get("discord_streams", "gA:c1")["desired"] == "off"
assert stopped == [_sid(fs, "gA", "c1")]
assert fs.get("discord_streams", _sid(fs, "gB", "c1"))["desired"] == "on"
assert fs.get("discord_streams", _sid(fs, "gA", "c1"))["desired"] == "off"
async def test_guild_org_resolution(fs, monkeypatch):
@@ -340,7 +352,7 @@ async def test_offline_node_keeps_its_lease_inside_the_grace(fs, monkeypatch):
_node(fs, "n1", status="offline", age=120)
await streams.reconcile_once()
assert _lease(fs, "t1")["node_id"] == "n1"
assert fs.get("discord_streams", "g1:c1")["actual"]["state"] == "node_offline"
assert fs.get("discord_streams", _sid(fs, "g1", "c1"))["actual"]["state"] == "node_offline"
async def test_persistent_stream_moves_after_grace_adhoc_stops(fs, monkeypatch):
@@ -351,10 +363,10 @@ async def test_persistent_stream_moves_after_grace_adhoc_stops(fs, monkeypatch):
_node(fs, "n1", status="offline", age=900)
_node(fs, "n2")
await streams.reconcile_once()
moved = fs.get("discord_streams", "g1:c1")
moved = fs.get("discord_streams", _sid(fs, "g1", "c1"))
assert moved["desired"] == "on" and moved["node_id"] == "n2" and moved["token_id"]
assert _lease(fs, moved["token_id"])["node_id"] == "n2"
assert fs.get("discord_streams", "g2:c1")["desired"] == "off"
assert fs.get("discord_streams", _sid(fs, "g2", "c1"))["desired"] == "off"
assert all((_lease(fs, t) or {}).get("node_id") != "n1" for t in ("t1", "t2"))
@@ -365,7 +377,7 @@ async def test_leave_while_node_offline_frees_token_after_grace(fs, monkeypatch)
_system(fs); _node(fs, "n1"); _token(fs, "t1")
await _start()
_node(fs, "n1", status="offline", age=120)
await streams.stop_stream("g1:c1")
await streams.stop_stream(_sid(fs, "g1", "c1"))
await streams.reconcile_once()
assert _lease(fs, "t1")["releasing"] is True # inside the grace: held
_node(fs, "n1", status="offline", age=900)
@@ -377,7 +389,7 @@ async def test_deleted_node_lease_freed_after_grace_from_last_report(fs, monkeyp
monkeypatch.setattr(streams.settings, "discord_lease_offline_release_seconds", 600)
_system(fs); _node(fs, "n1"); _token(fs, "t1")
await _start()
await streams.stop_stream("g1:c1")
await streams.stop_stream(_sid(fs, "g1", "c1"))
fs.put("discord_nodes", "n1", {**(fs.get("discord_nodes", "n1") or {}),
"actual_at": datetime.now(timezone.utc).isoformat()})
await fs.doc_delete("nodes", "n1")
@@ -401,12 +413,12 @@ async def test_release_seq_never_names_a_list_that_still_holds_the_stream(fs, mo
out = await real(node_id)
if not fired:
fired.append(1)
await leases.detach_stream("g1:c1", {"desired": "off"})
await leases.detach_stream(_sid(fs, "g1", "c1"), {"desired": "off"})
return out
monkeypatch.setattr(streams, "desired_for_node", _racy)
await streams.publish_desired("n1")
racy_seq = fs.published[-1][1]["seq"]
assert any(d["stream_id"] == "g1:c1" for d in fs.published[-1][1]["streams"])
assert any(d["stream_id"] == _sid(fs, "g1", "c1") for d in fs.published[-1][1]["streams"])
rseq = _lease(fs, "t1")["release_seq"]
assert rseq is None or rseq > racy_seq
assert await leases.settle_releases("n1", set(), racy_seq) == []
@@ -417,7 +429,7 @@ async def test_retuned_node_gives_up_a_persistent_stream(fs):
await _start(mode="persistent")
_node(fs, "n1", system="sys-2")
await streams.reconcile_once()
s = fs.get("discord_streams", "g1:c1")
s = fs.get("discord_streams", _sid(fs, "g1", "c1"))
assert s["desired"] == "on" and s["token_id"] is None and s["actual"]["state"] == "error"
assert _lease(fs, "t1")["releasing"] is True
@@ -432,7 +444,7 @@ async def test_force_release_detaches_streams(fs):
old = await leases.force_release_token("t1")
assert old["node_id"] == "n1"
assert _lease(fs, "t1") is None
assert fs.get("discord_streams", "g1:c1")["token_id"] is None
assert fs.get("discord_streams", _sid(fs, "g1", "c1"))["token_id"] is None
def test_node_token_pickup_only_for_the_lease_holder(fs):