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:
user
2026-02-22 02:52:51 +01:00
parent ab924444de
commit df20c154ca
4 changed files with 316 additions and 6 deletions

View File

@@ -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`)

View File

@@ -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 --------------------------------------------------------

View File

@@ -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,
) )

View File

@@ -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)