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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user