_resolve_title replaced with _resolve_tracks using --flat-playlist to enumerate playlist entries. cmd_play enqueues each track individually, with truncation when the queue is nearly full. Single-video behavior unchanged.
331 lines
8.6 KiB
Python
331 lines
8.6 KiB
Python
"""Plugin: music playback for Mumble voice channels."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import subprocess
|
|
from dataclasses import dataclass
|
|
|
|
from derp.plugin import command
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
_MAX_QUEUE = 50
|
|
_MAX_TITLE_LEN = 80
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _Track:
|
|
url: str
|
|
title: str
|
|
requester: str
|
|
|
|
|
|
# -- Per-bot runtime state ---------------------------------------------------
|
|
|
|
|
|
def _ps(bot):
|
|
"""Per-bot plugin runtime state."""
|
|
return bot._pstate.setdefault("music", {
|
|
"queue": [],
|
|
"current": None,
|
|
"volume": 50,
|
|
"task": None,
|
|
"done_event": None,
|
|
})
|
|
|
|
|
|
# -- Helpers -----------------------------------------------------------------
|
|
|
|
|
|
def _is_mumble(bot) -> bool:
|
|
"""Check if bot supports voice streaming."""
|
|
return hasattr(bot, "stream_audio")
|
|
|
|
|
|
def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
|
|
"""Truncate text with ellipsis if needed."""
|
|
if len(text) <= max_len:
|
|
return text
|
|
return text[: max_len - 3].rstrip() + "..."
|
|
|
|
|
|
def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, str]]:
|
|
"""Resolve URL into (url, title) pairs via yt-dlp. Blocking, run in executor.
|
|
|
|
Handles both single videos and playlists. For playlists, returns up to
|
|
``max_tracks`` individual entries. Falls back to ``[(url, url)]`` on error.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"yt-dlp", "--flat-playlist", "--print", "url",
|
|
"--print", "title", "--no-warnings",
|
|
f"--playlist-end={max_tracks}", url,
|
|
],
|
|
capture_output=True, text=True, timeout=30,
|
|
)
|
|
lines = result.stdout.strip().splitlines()
|
|
if len(lines) < 2:
|
|
return [(url, url)]
|
|
tracks = []
|
|
for i in range(0, len(lines) - 1, 2):
|
|
track_url = lines[i].strip()
|
|
track_title = lines[i + 1].strip()
|
|
if track_url:
|
|
tracks.append((track_url, track_title or track_url))
|
|
return tracks if tracks else [(url, url)]
|
|
except Exception:
|
|
return [(url, url)]
|
|
|
|
|
|
# -- Play loop ---------------------------------------------------------------
|
|
|
|
|
|
async def _play_loop(bot) -> None:
|
|
"""Pop tracks from queue and stream them sequentially."""
|
|
ps = _ps(bot)
|
|
try:
|
|
while ps["queue"]:
|
|
track = ps["queue"].pop(0)
|
|
ps["current"] = track
|
|
|
|
done = asyncio.Event()
|
|
ps["done_event"] = done
|
|
|
|
try:
|
|
await bot.stream_audio(
|
|
track.url,
|
|
volume=lambda: ps["volume"] / 100.0,
|
|
on_done=done,
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
log.exception("music: stream error for %s", track.url)
|
|
|
|
await done.wait()
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
ps["current"] = None
|
|
ps["done_event"] = None
|
|
ps["task"] = None
|
|
|
|
|
|
def _ensure_loop(bot) -> None:
|
|
"""Start the play loop if not already running."""
|
|
ps = _ps(bot)
|
|
task = ps.get("task")
|
|
if task and not task.done():
|
|
return
|
|
ps["task"] = bot._spawn(_play_loop(bot), name="music-play-loop")
|
|
|
|
|
|
# -- Commands ----------------------------------------------------------------
|
|
|
|
|
|
@command("play", help="Music: !play <url|playlist>")
|
|
async def cmd_play(bot, message):
|
|
"""Play a URL or add to queue if already playing.
|
|
|
|
Usage:
|
|
!play <url> Play audio from URL (YouTube, SoundCloud, etc.)
|
|
|
|
Playlists are expanded into individual tracks. If the queue is nearly
|
|
full, only as many tracks as will fit are enqueued.
|
|
"""
|
|
if not _is_mumble(bot):
|
|
await bot.reply(message, "Music playback is Mumble-only")
|
|
return
|
|
|
|
parts = message.text.split(None, 1)
|
|
if len(parts) < 2:
|
|
await bot.reply(message, "Usage: !play <url>")
|
|
return
|
|
|
|
url = parts[1].strip()
|
|
ps = _ps(bot)
|
|
|
|
if len(ps["queue"]) >= _MAX_QUEUE:
|
|
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
|
|
return
|
|
|
|
remaining = _MAX_QUEUE - len(ps["queue"])
|
|
loop = asyncio.get_running_loop()
|
|
resolved = await loop.run_in_executor(None, _resolve_tracks, url, remaining)
|
|
|
|
was_idle = ps["current"] is None
|
|
requester = message.nick or "?"
|
|
added = 0
|
|
for track_url, track_title in resolved[:remaining]:
|
|
ps["queue"].append(_Track(url=track_url, title=track_title,
|
|
requester=requester))
|
|
added += 1
|
|
|
|
total_resolved = len(resolved)
|
|
|
|
if added == 1:
|
|
title = _truncate(resolved[0][1])
|
|
if was_idle:
|
|
await bot.reply(message, f"Playing: {title}")
|
|
else:
|
|
pos = len(ps["queue"])
|
|
await bot.reply(message, f"Queued #{pos}: {title}")
|
|
elif added < total_resolved:
|
|
await bot.reply(
|
|
message,
|
|
f"Queued {added} of {total_resolved} tracks (queue full)",
|
|
)
|
|
else:
|
|
await bot.reply(message, f"Queued {added} tracks")
|
|
|
|
if was_idle:
|
|
_ensure_loop(bot)
|
|
|
|
|
|
@command("stop", help="Music: !stop")
|
|
async def cmd_stop(bot, message):
|
|
"""Stop playback and clear queue."""
|
|
if not _is_mumble(bot):
|
|
return
|
|
|
|
ps = _ps(bot)
|
|
ps["queue"].clear()
|
|
|
|
task = ps.get("task")
|
|
if task and not task.done():
|
|
task.cancel()
|
|
ps["current"] = None
|
|
ps["task"] = None
|
|
ps["done_event"] = None
|
|
|
|
await bot.reply(message, "Stopped")
|
|
|
|
|
|
@command("skip", help="Music: !skip")
|
|
async def cmd_skip(bot, message):
|
|
"""Skip current track, advance to next in queue."""
|
|
if not _is_mumble(bot):
|
|
return
|
|
|
|
ps = _ps(bot)
|
|
if ps["current"] is None:
|
|
await bot.reply(message, "Nothing playing")
|
|
return
|
|
|
|
task = ps.get("task")
|
|
if task and not task.done():
|
|
task.cancel()
|
|
|
|
skipped = ps["current"]
|
|
ps["current"] = None
|
|
ps["task"] = None
|
|
|
|
if ps["queue"]:
|
|
_ensure_loop(bot)
|
|
await bot.reply(
|
|
message,
|
|
f"Skipped: {_truncate(skipped.title)}",
|
|
)
|
|
else:
|
|
await bot.reply(message, "Skipped, queue empty")
|
|
|
|
|
|
@command("queue", help="Music: !queue [url]")
|
|
async def cmd_queue(bot, message):
|
|
"""Show queue or add a URL.
|
|
|
|
Usage:
|
|
!queue Show current queue
|
|
!queue <url> Add URL to queue (alias for !play)
|
|
"""
|
|
if not _is_mumble(bot):
|
|
return
|
|
|
|
parts = message.text.split(None, 1)
|
|
if len(parts) >= 2:
|
|
# Alias for !play
|
|
await cmd_play(bot, message)
|
|
return
|
|
|
|
ps = _ps(bot)
|
|
lines = []
|
|
if ps["current"]:
|
|
lines.append(
|
|
f"Now: {_truncate(ps['current'].title)}"
|
|
f" [{ps['current'].requester}]"
|
|
)
|
|
if ps["queue"]:
|
|
for i, track in enumerate(ps["queue"], 1):
|
|
lines.append(
|
|
f" {i}. {_truncate(track.title)} [{track.requester}]"
|
|
)
|
|
else:
|
|
if not ps["current"]:
|
|
lines.append("Queue empty")
|
|
|
|
for line in lines:
|
|
await bot.reply(message, line)
|
|
|
|
|
|
@command("np", help="Music: !np")
|
|
async def cmd_np(bot, message):
|
|
"""Show now-playing track."""
|
|
if not _is_mumble(bot):
|
|
return
|
|
|
|
ps = _ps(bot)
|
|
if ps["current"] is None:
|
|
await bot.reply(message, "Nothing playing")
|
|
return
|
|
|
|
track = ps["current"]
|
|
await bot.reply(
|
|
message,
|
|
f"Now playing: {_truncate(track.title)} [{track.requester}]",
|
|
)
|
|
|
|
|
|
@command("testtone", help="Music: !testtone -- debug sine wave")
|
|
async def cmd_testtone(bot, message):
|
|
"""Send a 3-second test tone for voice debugging."""
|
|
if not _is_mumble(bot):
|
|
await bot.reply(message, "Mumble-only feature")
|
|
return
|
|
await bot.reply(message, "Sending 440Hz test tone (3s)...")
|
|
await bot.test_tone(3.0)
|
|
await bot.reply(message, "Test tone complete")
|
|
|
|
|
|
@command("volume", help="Music: !volume [0-100]")
|
|
async def cmd_volume(bot, message):
|
|
"""Get or set playback volume.
|
|
|
|
Usage:
|
|
!volume Show current volume
|
|
!volume <0-100> Set volume (takes effect immediately)
|
|
"""
|
|
if not _is_mumble(bot):
|
|
return
|
|
|
|
ps = _ps(bot)
|
|
parts = message.text.split(None, 1)
|
|
if len(parts) < 2:
|
|
await bot.reply(message, f"Volume: {ps['volume']}%")
|
|
return
|
|
|
|
try:
|
|
val = int(parts[1])
|
|
except ValueError:
|
|
await bot.reply(message, "Usage: !volume <0-100>")
|
|
return
|
|
|
|
if val < 0 or val > 100:
|
|
await bot.reply(message, "Volume must be 0-100")
|
|
return
|
|
|
|
ps["volume"] = val
|
|
await bot.reply(message, f"Volume set to {val}%")
|