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

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