feat: download audio before playback, add !keep and !kept commands
Audio is now downloaded to data/music/ before playback begins, eliminating CDN hiccups mid-stream. Falls back to streaming on download failure. Files are deleted after playback unless marked with !keep. stream_audio detects local files and uses a direct ffmpeg pipeline (no yt-dlp).
This commit is contained in:
@@ -1574,6 +1574,8 @@ and voice transmission.
|
|||||||
!queue <url> Add to queue (alias for !play)
|
!queue <url> Add to queue (alias for !play)
|
||||||
!np Now playing
|
!np Now playing
|
||||||
!volume [0-100] Get/set volume
|
!volume [0-100] Get/set volume
|
||||||
|
!keep Keep current track's audio file after playback
|
||||||
|
!kept [clear] List kept files or clear all
|
||||||
!testtone Play 3-second 440Hz test tone
|
!testtone Play 3-second 440Hz test tone
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1585,7 +1587,11 @@ and voice transmission.
|
|||||||
- Volume changes ramp smoothly over ~1s (no abrupt jumps)
|
- Volume changes ramp smoothly over ~1s (no abrupt jumps)
|
||||||
- Default volume: 50%
|
- Default volume: 50%
|
||||||
- Titles resolved via `yt-dlp --flat-playlist` before playback
|
- Titles resolved via `yt-dlp --flat-playlist` before playback
|
||||||
- Audio pipeline: `yt-dlp | ffmpeg` subprocess, PCM fed to pymumble
|
- Audio is downloaded before playback (`data/music/`); files are deleted
|
||||||
|
after playback unless `!keep` is used. Falls back to streaming on
|
||||||
|
download failure.
|
||||||
|
- Audio pipeline: `ffmpeg` subprocess for local files, `yt-dlp | ffmpeg`
|
||||||
|
for streaming fallback, PCM fed to pymumble
|
||||||
- Commands are Mumble-only; `!play` on other adapters replies with an error,
|
- Commands are Mumble-only; `!play` on other adapters replies with an error,
|
||||||
other music commands silently no-op
|
other music commands silently no-op
|
||||||
- Playback runs as an asyncio background task; the bot remains responsive
|
- Playback runs as an asyncio background task; the bot remains responsive
|
||||||
@@ -1657,3 +1663,17 @@ duck_floor = 1 # Floor volume % during ducking (default: 1)
|
|||||||
duck_silence = 15 # Seconds of silence before restoring (default: 15)
|
duck_silence = 15 # Seconds of silence before restoring (default: 15)
|
||||||
duck_restore = 30 # Seconds for smooth volume restore (default: 30)
|
duck_restore = 30 # Seconds for smooth volume restore (default: 30)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Download-First Playback
|
||||||
|
|
||||||
|
Audio is downloaded to `data/music/` before playback begins. This
|
||||||
|
eliminates CDN hiccups mid-stream and enables instant seeking. Files
|
||||||
|
are identified by a hash of the URL so the same URL reuses the same
|
||||||
|
file (natural dedup).
|
||||||
|
|
||||||
|
- If download fails, playback falls back to streaming (`yt-dlp | ffmpeg`)
|
||||||
|
- After a track finishes, the local file is automatically deleted
|
||||||
|
- Use `!keep` during playback to preserve the file
|
||||||
|
- Use `!kept` to list preserved files and their sizes
|
||||||
|
- Use `!kept clear` to delete all preserved files
|
||||||
|
- On cancel/error, files are not deleted (needed for `!resume`)
|
||||||
|
|||||||
112
plugins/music.py
112
plugins/music.py
@@ -3,12 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from derp.plugin import command
|
from derp.plugin import command
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ class _Track:
|
|||||||
title: str
|
title: str
|
||||||
requester: str
|
requester: str
|
||||||
origin: str = "" # original user-provided URL for re-resolution
|
origin: str = "" # original user-provided URL for re-resolution
|
||||||
|
local_path: Path | None = None # set before playback
|
||||||
|
keep: bool = False # True = don't delete after playback
|
||||||
|
|
||||||
|
|
||||||
# -- Per-bot runtime state ---------------------------------------------------
|
# -- Per-bot runtime state ---------------------------------------------------
|
||||||
@@ -138,6 +142,43 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
|
|||||||
return [(url, url)]
|
return [(url, url)]
|
||||||
|
|
||||||
|
|
||||||
|
# -- Download helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_MUSIC_DIR = Path("data/music")
|
||||||
|
|
||||||
|
|
||||||
|
def _download_track(url: str, track_id: str) -> Path | None:
|
||||||
|
"""Download audio to data/music/. Blocking -- run in executor."""
|
||||||
|
_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
template = str(_MUSIC_DIR / f"{track_id}.%(ext)s")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["yt-dlp", "-f", "bestaudio", "--no-warnings",
|
||||||
|
"-o", template, "--print", "after_move:filepath", url],
|
||||||
|
capture_output=True, text=True, timeout=300,
|
||||||
|
)
|
||||||
|
filepath = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
|
||||||
|
if filepath and Path(filepath).is_file():
|
||||||
|
return Path(filepath)
|
||||||
|
matches = list(_MUSIC_DIR.glob(f"{track_id}.*"))
|
||||||
|
return matches[0] if matches else None
|
||||||
|
except Exception:
|
||||||
|
log.exception("download failed for %s", url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_track(track: _Track) -> None:
|
||||||
|
"""Delete the local audio file unless marked to keep."""
|
||||||
|
if track.local_path is None or track.keep:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
track.local_path.unlink(missing_ok=True)
|
||||||
|
log.info("music: deleted %s", track.local_path.name)
|
||||||
|
except OSError:
|
||||||
|
log.warning("music: failed to delete %s", track.local_path)
|
||||||
|
|
||||||
|
|
||||||
# -- Duck monitor ------------------------------------------------------------
|
# -- Duck monitor ------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -303,9 +344,26 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
|||||||
first = False
|
first = False
|
||||||
progress = [0]
|
progress = [0]
|
||||||
|
|
||||||
|
# Download phase
|
||||||
|
source = track.url
|
||||||
|
if track.local_path is None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
|
||||||
|
dl_path = await loop.run_in_executor(
|
||||||
|
None, _download_track, track.url, tid,
|
||||||
|
)
|
||||||
|
if dl_path:
|
||||||
|
track.local_path = dl_path
|
||||||
|
source = str(dl_path)
|
||||||
|
else:
|
||||||
|
log.warning("music: download failed, streaming %s",
|
||||||
|
track.url)
|
||||||
|
else:
|
||||||
|
source = str(track.local_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.stream_audio(
|
await bot.stream_audio(
|
||||||
track.url,
|
source,
|
||||||
volume=lambda: (
|
volume=lambda: (
|
||||||
ps["duck_vol"]
|
ps["duck_vol"]
|
||||||
if ps["duck_vol"] is not None
|
if ps["duck_vol"] is not None
|
||||||
@@ -330,6 +388,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
|||||||
await done.wait()
|
await done.wait()
|
||||||
if progress[0] > 0:
|
if progress[0] > 0:
|
||||||
_clear_resume(bot)
|
_clear_resume(bot)
|
||||||
|
_cleanup_track(track)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -706,6 +765,57 @@ async def cmd_duck(bot, message):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@command("keep", help="Music: !keep -- keep current track's audio file")
|
||||||
|
async def cmd_keep(bot, message):
|
||||||
|
"""Mark the current track's local file to keep after playback."""
|
||||||
|
if not _is_mumble(bot):
|
||||||
|
await bot.reply(message, "Mumble-only feature")
|
||||||
|
return
|
||||||
|
|
||||||
|
ps = _ps(bot)
|
||||||
|
track = ps["current"]
|
||||||
|
if track is None:
|
||||||
|
await bot.reply(message, "Nothing playing")
|
||||||
|
return
|
||||||
|
if track.local_path is None:
|
||||||
|
await bot.reply(message, "No local file for current track")
|
||||||
|
return
|
||||||
|
track.keep = True
|
||||||
|
await bot.reply(message, f"Keeping: {track.local_path.name}")
|
||||||
|
|
||||||
|
|
||||||
|
@command("kept", help="Music: !kept [clear] -- list or clear kept files")
|
||||||
|
async def cmd_kept(bot, message):
|
||||||
|
"""List or clear kept audio files in data/music/."""
|
||||||
|
if not _is_mumble(bot):
|
||||||
|
await bot.reply(message, "Mumble-only feature")
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = message.text.split()
|
||||||
|
if len(parts) >= 2 and parts[1].lower() == "clear":
|
||||||
|
count = 0
|
||||||
|
if _MUSIC_DIR.is_dir():
|
||||||
|
for f in _MUSIC_DIR.iterdir():
|
||||||
|
if f.is_file():
|
||||||
|
f.unlink()
|
||||||
|
count += 1
|
||||||
|
await bot.reply(message, f"Deleted {count} file(s)")
|
||||||
|
return
|
||||||
|
|
||||||
|
files = sorted(_MUSIC_DIR.iterdir()) if _MUSIC_DIR.is_dir() else []
|
||||||
|
files = [f for f in files if f.is_file()]
|
||||||
|
if not files:
|
||||||
|
await bot.reply(message, "No kept files")
|
||||||
|
return
|
||||||
|
|
||||||
|
lines = [f"Kept files ({len(files)}):"]
|
||||||
|
for f in files:
|
||||||
|
size_mb = f.stat().st_size / (1024 * 1024)
|
||||||
|
lines.append(f" {f.name} ({size_mb:.1f}MB)")
|
||||||
|
for line in lines:
|
||||||
|
await bot.reply(message, line)
|
||||||
|
|
||||||
|
|
||||||
# -- Plugin lifecycle --------------------------------------------------------
|
# -- Plugin lifecycle --------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import array
|
|||||||
import asyncio
|
import asyncio
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
import time
|
import time
|
||||||
@@ -531,11 +532,15 @@ class MumbleBot:
|
|||||||
url, _get_vol() * 100, seek)
|
url, _get_vol() * 100, seek)
|
||||||
|
|
||||||
seek_flag = f" -ss {seek:.3f}" if seek > 0 else ""
|
seek_flag = f" -ss {seek:.3f}" if seek > 0 else ""
|
||||||
|
if os.path.isfile(url):
|
||||||
|
cmd = (f"ffmpeg{seek_flag} -i {_shell_quote(url)}"
|
||||||
|
f" -f s16le -ar 48000 -ac 1 -loglevel error pipe:1")
|
||||||
|
else:
|
||||||
|
cmd = (f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
|
||||||
|
f" | ffmpeg{seek_flag} -i pipe:0 -f s16le -ar 48000 -ac 1"
|
||||||
|
f" -loglevel error pipe:1")
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"sh", "-c",
|
"sh", "-c", cmd,
|
||||||
f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
|
|
||||||
f" | ffmpeg{seek_flag} -i pipe:0 -f s16le -ar 48000 -ac 1"
|
|
||||||
f" -loglevel error pipe:1",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
# -- Load plugin module directly ---------------------------------------------
|
# -- Load plugin module directly ---------------------------------------------
|
||||||
@@ -1006,3 +1007,177 @@ class TestAutoResume:
|
|||||||
asyncio.run(_mod.on_connected(bot))
|
asyncio.run(_mod.on_connected(bot))
|
||||||
asyncio.run(_mod.on_connected(bot))
|
asyncio.run(_mod.on_connected(bot))
|
||||||
assert spawned.count("music-reconnect-watcher") == 1
|
assert spawned.count("music-reconnect-watcher") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestDownloadTrack
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDownloadTrack:
|
||||||
|
def test_download_success(self, tmp_path):
|
||||||
|
"""Successful download returns a Path."""
|
||||||
|
music_dir = tmp_path / "music"
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||||
|
result = MagicMock()
|
||||||
|
result.stdout = str(music_dir / "abc123.opus") + "\n"
|
||||||
|
result.returncode = 0
|
||||||
|
# Create the file so is_file() returns True
|
||||||
|
music_dir.mkdir(parents=True)
|
||||||
|
(music_dir / "abc123.opus").write_bytes(b"audio")
|
||||||
|
with patch("subprocess.run", return_value=result):
|
||||||
|
path = _mod._download_track("https://example.com/v", "abc123")
|
||||||
|
assert path is not None
|
||||||
|
assert path.name == "abc123.opus"
|
||||||
|
|
||||||
|
def test_download_fallback_glob(self, tmp_path):
|
||||||
|
"""Falls back to glob when --print output is empty."""
|
||||||
|
music_dir = tmp_path / "music"
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||||
|
result = MagicMock()
|
||||||
|
result.stdout = ""
|
||||||
|
result.returncode = 0
|
||||||
|
music_dir.mkdir(parents=True)
|
||||||
|
(music_dir / "abc123.webm").write_bytes(b"audio")
|
||||||
|
with patch("subprocess.run", return_value=result):
|
||||||
|
path = _mod._download_track("https://example.com/v", "abc123")
|
||||||
|
assert path is not None
|
||||||
|
assert path.name == "abc123.webm"
|
||||||
|
|
||||||
|
def test_download_failure_returns_none(self, tmp_path):
|
||||||
|
"""Exception during download returns None."""
|
||||||
|
music_dir = tmp_path / "music"
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||||
|
with patch("subprocess.run", side_effect=Exception("fail")):
|
||||||
|
path = _mod._download_track("https://example.com/v", "abc123")
|
||||||
|
assert path is None
|
||||||
|
|
||||||
|
def test_download_no_file_returns_none(self, tmp_path):
|
||||||
|
"""No matching file on disk returns None."""
|
||||||
|
music_dir = tmp_path / "music"
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||||
|
result = MagicMock()
|
||||||
|
result.stdout = "/nonexistent/path.opus\n"
|
||||||
|
result.returncode = 0
|
||||||
|
music_dir.mkdir(parents=True)
|
||||||
|
with patch("subprocess.run", return_value=result):
|
||||||
|
path = _mod._download_track("https://example.com/v", "abc123")
|
||||||
|
assert path is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestCleanupTrack
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCleanupTrack:
|
||||||
|
def test_cleanup_deletes_file(self, tmp_path):
|
||||||
|
"""Cleanup deletes the local file when keep=False."""
|
||||||
|
f = tmp_path / "test.opus"
|
||||||
|
f.write_bytes(b"audio")
|
||||||
|
track = _mod._Track(
|
||||||
|
url="x", title="t", requester="a",
|
||||||
|
local_path=f, keep=False,
|
||||||
|
)
|
||||||
|
_mod._cleanup_track(track)
|
||||||
|
assert not f.exists()
|
||||||
|
|
||||||
|
def test_cleanup_keeps_file_when_flagged(self, tmp_path):
|
||||||
|
"""Cleanup preserves the file when keep=True."""
|
||||||
|
f = tmp_path / "test.opus"
|
||||||
|
f.write_bytes(b"audio")
|
||||||
|
track = _mod._Track(
|
||||||
|
url="x", title="t", requester="a",
|
||||||
|
local_path=f, keep=True,
|
||||||
|
)
|
||||||
|
_mod._cleanup_track(track)
|
||||||
|
assert f.exists()
|
||||||
|
|
||||||
|
def test_cleanup_noop_when_no_path(self):
|
||||||
|
"""Cleanup does nothing when local_path is None."""
|
||||||
|
track = _mod._Track(url="x", title="t", requester="a")
|
||||||
|
_mod._cleanup_track(track) # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestKeepCommand
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeepCommand:
|
||||||
|
def test_keep_nothing_playing(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
msg = _Msg(text="!keep")
|
||||||
|
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||||
|
assert any("Nothing playing" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_keep_no_local_file(self):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
ps["current"] = _mod._Track(url="x", title="t", requester="a")
|
||||||
|
msg = _Msg(text="!keep")
|
||||||
|
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||||
|
assert any("No local file" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_keep_marks_track(self, tmp_path):
|
||||||
|
bot = _FakeBot()
|
||||||
|
ps = _mod._ps(bot)
|
||||||
|
f = tmp_path / "abc123.opus"
|
||||||
|
f.write_bytes(b"audio")
|
||||||
|
track = _mod._Track(
|
||||||
|
url="x", title="t", requester="a", local_path=f,
|
||||||
|
)
|
||||||
|
ps["current"] = track
|
||||||
|
msg = _Msg(text="!keep")
|
||||||
|
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||||
|
assert track.keep is True
|
||||||
|
assert any("Keeping" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_keep_non_mumble(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
msg = _Msg(text="!keep")
|
||||||
|
asyncio.run(_mod.cmd_keep(bot, msg))
|
||||||
|
assert any("Mumble-only" in r for r in bot.replied)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TestKeptCommand
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeptCommand:
|
||||||
|
def test_kept_empty(self, tmp_path):
|
||||||
|
bot = _FakeBot()
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", tmp_path / "empty"):
|
||||||
|
msg = _Msg(text="!kept")
|
||||||
|
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||||
|
assert any("No kept files" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_kept_lists_files(self, tmp_path):
|
||||||
|
bot = _FakeBot()
|
||||||
|
music_dir = tmp_path / "music"
|
||||||
|
music_dir.mkdir()
|
||||||
|
(music_dir / "abc123.opus").write_bytes(b"x" * 1024)
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||||
|
msg = _Msg(text="!kept")
|
||||||
|
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||||
|
assert any("Kept files" in r for r in bot.replied)
|
||||||
|
assert any("abc123.opus" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_kept_clear(self, tmp_path):
|
||||||
|
bot = _FakeBot()
|
||||||
|
music_dir = tmp_path / "music"
|
||||||
|
music_dir.mkdir()
|
||||||
|
(music_dir / "abc123.opus").write_bytes(b"audio")
|
||||||
|
(music_dir / "def456.webm").write_bytes(b"audio")
|
||||||
|
with patch.object(_mod, "_MUSIC_DIR", music_dir):
|
||||||
|
msg = _Msg(text="!kept clear")
|
||||||
|
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||||
|
assert any("Deleted 2 file(s)" in r for r in bot.replied)
|
||||||
|
assert not list(music_dir.iterdir())
|
||||||
|
|
||||||
|
def test_kept_non_mumble(self):
|
||||||
|
bot = _FakeBot(mumble=False)
|
||||||
|
msg = _Msg(text="!kept")
|
||||||
|
asyncio.run(_mod.cmd_kept(bot, msg))
|
||||||
|
assert any("Mumble-only" in r for r in bot.replied)
|
||||||
|
|||||||
Reference in New Issue
Block a user