diff --git a/docs/USAGE.md b/docs/USAGE.md index 4e6ebc7..9f536c5 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1574,6 +1574,8 @@ and voice transmission. !queue Add to queue (alias for !play) !np Now playing !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 ``` @@ -1585,7 +1587,11 @@ and voice transmission. - Volume changes ramp smoothly over ~1s (no abrupt jumps) - Default volume: 50% - 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, other music commands silently no-op - 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_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`) diff --git a/plugins/music.py b/plugins/music.py index 8cef425..dbc757b 100644 --- a/plugins/music.py +++ b/plugins/music.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import random import subprocess import time from dataclasses import dataclass +from pathlib import Path from derp.plugin import command @@ -24,6 +26,8 @@ class _Track: title: str requester: str 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 --------------------------------------------------- @@ -138,6 +142,43 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s 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 ------------------------------------------------------------ @@ -303,9 +344,26 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None: first = False 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: await bot.stream_audio( - track.url, + source, volume=lambda: ( ps["duck_vol"] if ps["duck_vol"] is not None @@ -330,6 +388,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None: await done.wait() if progress[0] > 0: _clear_resume(bot) + _cleanup_track(track) except asyncio.CancelledError: pass 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 -------------------------------------------------------- diff --git a/src/derp/mumble.py b/src/derp/mumble.py index 481a9df..e9be033 100644 --- a/src/derp/mumble.py +++ b/src/derp/mumble.py @@ -6,6 +6,7 @@ import array import asyncio import html import logging +import os import re import struct import time @@ -531,11 +532,15 @@ class MumbleBot: url, _get_vol() * 100, seek) 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( - "sh", "-c", - 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", + "sh", "-c", cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) diff --git a/tests/test_music.py b/tests/test_music.py index e41481b..a520d43 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -4,6 +4,7 @@ import asyncio import importlib.util import sys import time +from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch # -- Load plugin module directly --------------------------------------------- @@ -1006,3 +1007,177 @@ class TestAutoResume: asyncio.run(_mod.on_connected(bot)) asyncio.run(_mod.on_connected(bot)) 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)