feat: playlist save/load, queue durations, whisper bias, greet fix

- Move TTS greeting from mumble._play_greet to voice.on_connected
  (fires once on first connect, gated on _is_audio_ready)
- Add initial_prompt multipart field to Whisper STT for trigger word
  bias (auto-generated from trigger config, overridable)
- Enhanced !queue: elapsed/total on now-playing, per-track durations,
  footer with track count and total time
- New !playlist command: save/load/list/del named playlists via
  bot.state persistence (playlist:<name> keys)
- Fix duck floor test (1% -> 2% to match default change)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 19:23:03 +01:00
parent 5d0e200fbe
commit 717bf59a05
7 changed files with 187 additions and 26 deletions

View File

@@ -1194,15 +1194,30 @@ async def cmd_queue(bot, message):
ps = _ps(bot)
lines = []
if ps["current"]:
track = ps["current"]
progress = ps.get("progress")
cur_seek = ps.get("cur_seek", 0.0)
elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0)
pos = _fmt_time(elapsed)
if track.duration > 0:
pos = f"{pos}/{_fmt_time(track.duration)}"
lines.append(
f"Now: {_truncate(ps['current'].title)}"
f" [{ps['current'].requester}]"
f"Now: {_truncate(track.title)}"
f" [{track.requester}] ({pos})"
)
if ps["queue"]:
total_dur = 0.0
for i, track in enumerate(ps["queue"], 1):
dur = f" ({_fmt_time(track.duration)})" if track.duration > 0 else ""
total_dur += track.duration
lines.append(
f" {i}. {_truncate(track.title)} [{track.requester}]"
f" {i}. {_truncate(track.title)} [{track.requester}]{dur}"
)
count = len(ps["queue"])
footer = f"Queue: {count} track{'s' if count != 1 else ''}"
if total_dur > 0:
footer += f", {_fmt_time(total_dur)} total"
lines.append(footer)
else:
if not ps["current"]:
lines.append("Queue empty")
@@ -1659,6 +1674,129 @@ async def _kept_repair(bot, message) -> None:
await bot.reply(message, msg)
@command("playlist", help="Music: !playlist save|load|list|del <name>")
async def cmd_playlist(bot, message):
"""Save, load, list, or delete named playlists.
Usage:
!playlist save <name> Save current + queued tracks as a playlist
!playlist load <name> Append saved playlist to queue and start playback
!playlist list Show saved playlists with track counts
!playlist del <name> Delete a saved playlist
"""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
return
parts = message.text.split()
if len(parts) < 2:
await bot.reply(
message, "Usage: !playlist save|load|list|del <name>",
)
return
sub = parts[1].lower()
if sub == "save":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist save <name>")
return
name = parts[2].lower()
ps = _ps(bot)
entries = []
if ps["current"]:
t = ps["current"]
entries.append({"url": t.url, "title": t.title,
"requester": t.requester})
for t in ps["queue"]:
entries.append({"url": t.url, "title": t.title,
"requester": t.requester})
if not entries:
await bot.reply(message, "Nothing to save")
return
bot.state.set("music", f"playlist:{name}", json.dumps(entries))
await bot.reply(
message,
f"Saved playlist '{name}' ({len(entries)} track"
f"{'s' if len(entries) != 1 else ''})",
)
elif sub == "load":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist load <name>")
return
name = parts[2].lower()
raw = bot.state.get("music", f"playlist:{name}")
if not raw:
await bot.reply(message, f"No playlist named '{name}'")
return
try:
entries = json.loads(raw)
except (json.JSONDecodeError, TypeError):
await bot.reply(message, f"Corrupt playlist '{name}'")
return
ps = _ps(bot)
was_idle = ps["current"] is None
added = 0
for e in entries:
if len(ps["queue"]) >= _MAX_QUEUE:
break
ps["queue"].append(_Track(
url=e["url"], title=e.get("title", e["url"]),
requester=e.get("requester", "?"),
))
added += 1
await bot.reply(
message,
f"Loaded '{name}': {added} track{'s' if added != 1 else ''}",
)
if was_idle:
_ensure_loop(bot)
elif sub == "list":
names = []
for key in bot.state.keys("music"):
if not key.startswith("playlist:"):
continue
pname = key.split(":", 1)[1]
raw = bot.state.get("music", key)
count = 0
if raw:
try:
count = len(json.loads(raw))
except (json.JSONDecodeError, TypeError):
pass
names.append((pname, count))
if not names:
await bot.reply(message, "No saved playlists")
return
names.sort()
lines = [f"Playlists ({len(names)}):"]
for pname, count in names:
lines.append(
f" {pname} ({count} track{'s' if count != 1 else ''})",
)
for line in lines:
await bot.reply(message, line)
elif sub == "del":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist del <name>")
return
name = parts[2].lower()
raw = bot.state.get("music", f"playlist:{name}")
if not raw:
await bot.reply(message, f"No playlist named '{name}'")
return
bot.state.delete("music", f"playlist:{name}")
await bot.reply(message, f"Deleted playlist '{name}'")
else:
await bot.reply(
message, "Usage: !playlist save|load|list|del <name>",
)
# -- Plugin lifecycle --------------------------------------------------------