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:
144
plugins/music.py
144
plugins/music.py
@@ -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 --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user