Files
derp/plugins/music.py
user e4e1e219f0 feat: add YouTube search to !play and fix NA URL fallback
Non-URL input (e.g. !play classical music) searches YouTube for 10
results and picks one randomly. Also fixes --flat-playlist returning
"NA" as the URL for single videos by falling back to the original
input URL.
2026-02-21 23:52:01 +01:00

347 lines
9.2 KiB
Python

"""Plugin: music playback for Mumble voice channels."""
from __future__ import annotations
import asyncio
import logging
import random
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 _is_url(text: str) -> bool:
"""Check if text looks like a URL rather than a search query."""
return text.startswith(("http://", "https://", "ytsearch:"))
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()
# --flat-playlist prints "NA" for single videos (no extraction)
if not track_url or track_url == "NA":
track_url = 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|query>")
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.)
!play <query> Search YouTube and play the first result
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|query>")
return
url = parts[1].strip()
is_search = not _is_url(url)
if is_search:
url = f"ytsearch10:{url}"
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)
# Search: pick one random result instead of enqueuing all
if is_search and len(resolved) > 1:
resolved = [random.choice(resolved)]
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}%")