feat: auto-resume music on reconnect, sorcerer tier, cert auth
Auto-resume: save playback position on stream errors and cancellation, restore automatically after reconnect or container restart once the channel is silent. Plugin lifecycle hook (on_connected) ensures the reconnect watcher starts without waiting for user commands. Sorcerer tier: new permission level between oper and admin. Configured via [mumble] sorcerers list in derp.toml. Mumble cert auth: pass certfile/keyfile to pymumble for client certificate authentication. Fixes: stream_audio now re-raises CancelledError and Exception so _play_loop detects failures correctly. Subprocess cleanup uses 3s timeout. Graceful shutdown cancels background tasks before stopping pymumble. Safe getattr for _opers in core plugin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -145,7 +145,8 @@ async def cmd_whoami(bot, message):
|
||||
prefix = message.prefix or "unknown"
|
||||
tier = bot._get_tier(message)
|
||||
tags = [tier]
|
||||
if message.prefix and message.prefix in bot._opers:
|
||||
opers = getattr(bot, "_opers", set())
|
||||
if message.prefix and message.prefix in opers:
|
||||
tags.append("IRCOP")
|
||||
await bot.reply(message, f"{prefix} [{', '.join(tags)}]")
|
||||
|
||||
@@ -158,12 +159,16 @@ async def cmd_admins(bot, message):
|
||||
parts.append(f"Admin: {', '.join(bot._admins)}")
|
||||
else:
|
||||
parts.append("Admin: (none)")
|
||||
sorcerers = getattr(bot, "_sorcerers", [])
|
||||
if sorcerers:
|
||||
parts.append(f"Sorcerer: {', '.join(sorcerers)}")
|
||||
if bot._operators:
|
||||
parts.append(f"Oper: {', '.join(bot._operators)}")
|
||||
if bot._trusted:
|
||||
parts.append(f"Trusted: {', '.join(bot._trusted)}")
|
||||
if bot._opers:
|
||||
parts.append(f"IRCOPs: {', '.join(sorted(bot._opers))}")
|
||||
opers = getattr(bot, "_opers", set())
|
||||
if opers:
|
||||
parts.append(f"IRCOPs: {', '.join(sorted(opers))}")
|
||||
else:
|
||||
parts.append("IRCOPs: (none)")
|
||||
await bot.reply(message, " | ".join(parts))
|
||||
|
||||
274
plugins/music.py
274
plugins/music.py
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from derp.plugin import command
|
||||
@@ -22,6 +23,7 @@ class _Track:
|
||||
url: str
|
||||
title: str
|
||||
requester: str
|
||||
origin: str = "" # original user-provided URL for re-resolution
|
||||
|
||||
|
||||
# -- Per-bot runtime state ---------------------------------------------------
|
||||
@@ -29,12 +31,20 @@ class _Track:
|
||||
|
||||
def _ps(bot):
|
||||
"""Per-bot plugin runtime state."""
|
||||
cfg = getattr(bot, "config", {}).get("music", {})
|
||||
return bot._pstate.setdefault("music", {
|
||||
"queue": [],
|
||||
"current": None,
|
||||
"volume": 50,
|
||||
"task": None,
|
||||
"done_event": None,
|
||||
"duck_enabled": cfg.get("duck_enabled", True),
|
||||
"duck_floor": cfg.get("duck_floor", 1),
|
||||
"duck_silence": cfg.get("duck_silence", 15),
|
||||
"duck_restore": cfg.get("duck_restore", 30),
|
||||
"duck_vol": None,
|
||||
"duck_task": None,
|
||||
"_watcher_task": None,
|
||||
})
|
||||
|
||||
|
||||
@@ -70,7 +80,7 @@ def _fmt_time(seconds: float) -> str:
|
||||
def _save_resume(bot, track: _Track, elapsed: float) -> None:
|
||||
"""Persist current track and elapsed position for later resumption."""
|
||||
data = json.dumps({
|
||||
"url": track.url,
|
||||
"url": track.origin or track.url,
|
||||
"title": track.title,
|
||||
"requester": track.requester,
|
||||
"elapsed": round(elapsed, 2),
|
||||
@@ -128,12 +138,143 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
|
||||
return [(url, url)]
|
||||
|
||||
|
||||
# -- Duck monitor ------------------------------------------------------------
|
||||
|
||||
|
||||
async def _duck_monitor(bot) -> None:
|
||||
"""Background task: duck volume when voice is detected, restore on silence.
|
||||
|
||||
Ducking is immediate (snap to floor). Restoration is a single smooth
|
||||
linear ramp from floor to user volume over ``duck_restore`` seconds.
|
||||
The per-frame volume ramp in ``stream_audio`` further smooths each
|
||||
1-second update, eliminating audible steps.
|
||||
"""
|
||||
ps = _ps(bot)
|
||||
restore_start: float = 0.0 # monotonic ts when restore began
|
||||
restore_from: float = 0.0 # duck_vol at restore start
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
if not ps["duck_enabled"]:
|
||||
if ps["duck_vol"] is not None:
|
||||
ps["duck_vol"] = None
|
||||
restore_start = 0.0
|
||||
continue
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
if ts == 0.0:
|
||||
continue
|
||||
silence = time.monotonic() - ts
|
||||
if silence < ps["duck_silence"]:
|
||||
# Voice active -- duck immediately
|
||||
if ps["duck_vol"] is None:
|
||||
log.info("duck: voice detected, ducking to %d%%",
|
||||
ps["duck_floor"])
|
||||
ps["duck_vol"] = float(ps["duck_floor"])
|
||||
restore_start = 0.0
|
||||
elif ps["duck_vol"] is not None:
|
||||
# Silence exceeded -- smooth linear restore
|
||||
if restore_start == 0.0:
|
||||
restore_start = time.monotonic()
|
||||
restore_from = ps["duck_vol"]
|
||||
log.info("duck: restoring %d%% -> %d%% over %ds",
|
||||
int(restore_from), ps["volume"],
|
||||
ps["duck_restore"])
|
||||
elapsed = time.monotonic() - restore_start
|
||||
dur = ps["duck_restore"]
|
||||
if dur <= 0 or elapsed >= dur:
|
||||
ps["duck_vol"] = None
|
||||
restore_start = 0.0
|
||||
else:
|
||||
target = ps["volume"]
|
||||
ps["duck_vol"] = restore_from + (target - restore_from) * (elapsed / dur)
|
||||
except asyncio.CancelledError:
|
||||
ps["duck_vol"] = None
|
||||
|
||||
|
||||
# -- Auto-resume on reconnect ------------------------------------------------
|
||||
|
||||
|
||||
async def _auto_resume(bot) -> None:
|
||||
"""Wait for silence after reconnect, then resume saved playback."""
|
||||
ps = _ps(bot)
|
||||
if ps["current"] is not None:
|
||||
return
|
||||
|
||||
data = _load_resume(bot)
|
||||
if data is None:
|
||||
return
|
||||
|
||||
# Let pymumble fully stabilize after reconnect
|
||||
await asyncio.sleep(10)
|
||||
|
||||
deadline = time.monotonic() + 60
|
||||
silence_needed = ps.get("duck_silence", 15)
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(2)
|
||||
ts = getattr(bot, "_last_voice_ts", 0.0)
|
||||
if ts == 0.0:
|
||||
break
|
||||
if time.monotonic() - ts >= silence_needed:
|
||||
break
|
||||
else:
|
||||
log.info("music: auto-resume aborted, channel not silent after 60s")
|
||||
return
|
||||
|
||||
# Re-check after waiting -- someone may have started playback manually
|
||||
if ps["current"] is not None:
|
||||
return
|
||||
data = _load_resume(bot)
|
||||
if data is None:
|
||||
return
|
||||
|
||||
elapsed = data.get("elapsed", 0.0)
|
||||
track = _Track(
|
||||
url=data["url"],
|
||||
title=data.get("title", data["url"]),
|
||||
requester=data.get("requester", "?"),
|
||||
)
|
||||
ps["queue"].insert(0, track)
|
||||
_clear_resume(bot)
|
||||
log.info("music: auto-resuming '%s' from %s",
|
||||
track.title, _fmt_time(elapsed))
|
||||
_ensure_loop(bot, seek=elapsed)
|
||||
|
||||
|
||||
async def _reconnect_watcher(bot) -> None:
|
||||
"""Poll for reconnections and trigger auto-resume.
|
||||
|
||||
Also handles cold-start resume: if saved state exists on first
|
||||
run, waits for the connection to stabilize then resumes.
|
||||
"""
|
||||
last_seen = getattr(bot, "_connect_count", 0)
|
||||
boot_checked = False
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
count = getattr(bot, "_connect_count", 0)
|
||||
|
||||
# Cold-start: resume saved state after first connection
|
||||
if not boot_checked and count >= 1:
|
||||
boot_checked = True
|
||||
if _load_resume(bot) is not None:
|
||||
log.info("music: saved state found on boot, attempting auto-resume")
|
||||
await _auto_resume(bot)
|
||||
continue
|
||||
|
||||
if count > last_seen and count > 1:
|
||||
last_seen = count
|
||||
log.info("music: reconnection detected, attempting auto-resume")
|
||||
await _auto_resume(bot)
|
||||
last_seen = max(last_seen, count)
|
||||
|
||||
|
||||
# -- Play loop ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
"""Pop tracks from queue and stream them sequentially."""
|
||||
ps = _ps(bot)
|
||||
duck_task = bot._spawn(_duck_monitor(bot), name="music-duck-monitor")
|
||||
ps["duck_task"] = duck_task
|
||||
first = True
|
||||
try:
|
||||
while ps["queue"]:
|
||||
@@ -150,7 +291,11 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
try:
|
||||
await bot.stream_audio(
|
||||
track.url,
|
||||
volume=lambda: ps["volume"] / 100.0,
|
||||
volume=lambda: (
|
||||
ps["duck_vol"]
|
||||
if ps["duck_vol"] is not None
|
||||
else ps["volume"]
|
||||
) / 100.0,
|
||||
on_done=done,
|
||||
seek=cur_seek,
|
||||
progress=progress,
|
||||
@@ -162,15 +307,24 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("music: stream error for %s", track.url)
|
||||
elapsed = cur_seek + progress[0] * 0.02
|
||||
if elapsed > 1.0:
|
||||
_save_resume(bot, track, elapsed)
|
||||
break
|
||||
|
||||
_clear_resume(bot)
|
||||
await done.wait()
|
||||
if progress[0] > 0:
|
||||
_clear_resume(bot)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
if duck_task and not duck_task.done():
|
||||
duck_task.cancel()
|
||||
ps["current"] = None
|
||||
ps["done_event"] = None
|
||||
ps["task"] = None
|
||||
ps["duck_vol"] = None
|
||||
ps["duck_task"] = None
|
||||
|
||||
|
||||
def _ensure_loop(bot, *, seek: float = 0.0) -> None:
|
||||
@@ -228,9 +382,12 @@ async def cmd_play(bot, message):
|
||||
was_idle = ps["current"] is None
|
||||
requester = message.nick or "?"
|
||||
added = 0
|
||||
# Only set origin for direct URLs (not searches) so resume uses the
|
||||
# resolved video URL rather than an ephemeral search query
|
||||
origin = url if not is_search else ""
|
||||
for track_url, track_title in resolved[:remaining]:
|
||||
ps["queue"].append(_Track(url=track_url, title=track_title,
|
||||
requester=requester))
|
||||
requester=requester, origin=origin))
|
||||
added += 1
|
||||
|
||||
total_resolved = len(resolved)
|
||||
@@ -269,6 +426,7 @@ async def cmd_stop(bot, message):
|
||||
ps["current"] = None
|
||||
ps["task"] = None
|
||||
ps["done_event"] = None
|
||||
ps["duck_vol"] = None
|
||||
|
||||
await bot.reply(message, "Stopped")
|
||||
|
||||
@@ -328,6 +486,7 @@ async def cmd_skip(bot, message):
|
||||
skipped = ps["current"]
|
||||
ps["current"] = None
|
||||
ps["task"] = None
|
||||
ps["duck_vol"] = None
|
||||
|
||||
if ps["queue"]:
|
||||
_ensure_loop(bot)
|
||||
@@ -441,3 +600,110 @@ async def cmd_volume(bot, message):
|
||||
|
||||
ps["volume"] = val
|
||||
await bot.reply(message, f"Volume set to {val}%")
|
||||
|
||||
|
||||
@command("duck", help="Music: !duck [on|off|floor N|silence N|restore N]")
|
||||
async def cmd_duck(bot, message):
|
||||
"""Configure voice-activated volume ducking.
|
||||
|
||||
Usage:
|
||||
!duck Show ducking status and settings
|
||||
!duck on Enable voice ducking
|
||||
!duck off Disable voice ducking
|
||||
!duck floor <0-100> Set floor volume %
|
||||
!duck silence <sec> Set silence timeout (seconds)
|
||||
!duck restore <sec> Set restore ramp duration (seconds)
|
||||
"""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Mumble-only feature")
|
||||
return
|
||||
|
||||
ps = _ps(bot)
|
||||
parts = message.text.split()
|
||||
|
||||
if len(parts) < 2:
|
||||
state = "on" if ps["duck_enabled"] else "off"
|
||||
ducking = ""
|
||||
if ps["duck_vol"] is not None:
|
||||
ducking = f", ducked to {int(ps['duck_vol'])}%"
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Duck: {state} | floor={ps['duck_floor']}%"
|
||||
f" silence={ps['duck_silence']}s"
|
||||
f" restore={ps['duck_restore']}s{ducking}",
|
||||
)
|
||||
return
|
||||
|
||||
sub = parts[1].lower()
|
||||
|
||||
if sub == "on":
|
||||
ps["duck_enabled"] = True
|
||||
await bot.reply(message, "Voice ducking enabled")
|
||||
elif sub == "off":
|
||||
ps["duck_enabled"] = False
|
||||
ps["duck_vol"] = None
|
||||
await bot.reply(message, "Voice ducking disabled")
|
||||
elif sub == "floor":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !duck floor <0-100>")
|
||||
return
|
||||
try:
|
||||
val = int(parts[2])
|
||||
except ValueError:
|
||||
await bot.reply(message, "Usage: !duck floor <0-100>")
|
||||
return
|
||||
if val < 0 or val > 100:
|
||||
await bot.reply(message, "Floor must be 0-100")
|
||||
return
|
||||
ps["duck_floor"] = val
|
||||
await bot.reply(message, f"Duck floor set to {val}%")
|
||||
elif sub == "silence":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !duck silence <seconds>")
|
||||
return
|
||||
try:
|
||||
val = int(parts[2])
|
||||
except ValueError:
|
||||
await bot.reply(message, "Usage: !duck silence <seconds>")
|
||||
return
|
||||
if val < 1:
|
||||
await bot.reply(message, "Silence timeout must be >= 1")
|
||||
return
|
||||
ps["duck_silence"] = val
|
||||
await bot.reply(message, f"Duck silence set to {val}s")
|
||||
elif sub == "restore":
|
||||
if len(parts) < 3:
|
||||
await bot.reply(message, "Usage: !duck restore <seconds>")
|
||||
return
|
||||
try:
|
||||
val = int(parts[2])
|
||||
except ValueError:
|
||||
await bot.reply(message, "Usage: !duck restore <seconds>")
|
||||
return
|
||||
if val < 1:
|
||||
await bot.reply(message, "Restore duration must be >= 1")
|
||||
return
|
||||
ps["duck_restore"] = val
|
||||
await bot.reply(message, f"Duck restore set to {val}s")
|
||||
else:
|
||||
await bot.reply(
|
||||
message, "Usage: !duck [on|off|floor N|silence N|restore N]",
|
||||
)
|
||||
|
||||
|
||||
# -- Plugin lifecycle --------------------------------------------------------
|
||||
|
||||
|
||||
async def on_connected(bot) -> None:
|
||||
"""Called by MumbleBot after each (re)connection.
|
||||
|
||||
Ensures the reconnect watcher is running -- triggers boot-resume
|
||||
and reconnect-resume without waiting for a user command.
|
||||
"""
|
||||
if not _is_mumble(bot):
|
||||
return
|
||||
ps = _ps(bot)
|
||||
if ps["_watcher_task"] is None and hasattr(bot, "_spawn"):
|
||||
ps["_watcher_task"] = bot._spawn(
|
||||
_reconnect_watcher(bot), name="music-reconnect-watcher",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user