feat: add !resume to continue playback from last interruption
Tracks playback position via frame counting in stream_audio(). On stop/skip, saves URL + elapsed time to bot.state (SQLite). !resume reloads the track and seeks to the saved position via ffmpeg -ss. State persists across bot restarts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -499,3 +499,119 @@ class TestPlaylistExpansion:
|
||||
with patch("subprocess.run", return_value=result):
|
||||
tracks = _mod._resolve_tracks("https://example.com/empty")
|
||||
assert tracks == [("https://example.com/empty", "https://example.com/empty")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestResumeState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResumeState:
|
||||
def test_save_load_roundtrip(self):
|
||||
bot = _FakeBot()
|
||||
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
|
||||
_mod._save_resume(bot, track, 125.5)
|
||||
data = _mod._load_resume(bot)
|
||||
assert data is not None
|
||||
assert data["url"] == "https://example.com/a"
|
||||
assert data["title"] == "Song"
|
||||
assert data["requester"] == "Alice"
|
||||
assert data["elapsed"] == 125.5
|
||||
|
||||
def test_clear_removes_state(self):
|
||||
bot = _FakeBot()
|
||||
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
|
||||
_mod._save_resume(bot, track, 60.0)
|
||||
_mod._clear_resume(bot)
|
||||
assert _mod._load_resume(bot) is None
|
||||
|
||||
def test_load_returns_none_when_empty(self):
|
||||
bot = _FakeBot()
|
||||
assert _mod._load_resume(bot) is None
|
||||
|
||||
def test_load_returns_none_on_corrupt_json(self):
|
||||
bot = _FakeBot()
|
||||
bot.state.set("music", "resume", "not-json{{{")
|
||||
assert _mod._load_resume(bot) is None
|
||||
|
||||
def test_load_returns_none_on_missing_url(self):
|
||||
bot = _FakeBot()
|
||||
bot.state.set("music", "resume", '{"title": "x"}')
|
||||
assert _mod._load_resume(bot) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestResumeCommand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestResumeCommand:
|
||||
def test_nothing_saved(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!resume")
|
||||
asyncio.run(_mod.cmd_resume(bot, msg))
|
||||
assert any("Nothing to resume" in r for r in bot.replied)
|
||||
|
||||
def test_already_playing(self):
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["current"] = _mod._Track(url="x", title="Playing", requester="a")
|
||||
msg = _Msg(text="!resume")
|
||||
asyncio.run(_mod.cmd_resume(bot, msg))
|
||||
assert any("Already playing" in r for r in bot.replied)
|
||||
|
||||
def test_non_mumble(self):
|
||||
bot = _FakeBot(mumble=False)
|
||||
msg = _Msg(text="!resume")
|
||||
asyncio.run(_mod.cmd_resume(bot, msg))
|
||||
assert any("Mumble-only" in r for r in bot.replied)
|
||||
|
||||
def test_loads_track_and_seeks(self):
|
||||
bot = _FakeBot()
|
||||
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
|
||||
_mod._save_resume(bot, track, 225.0)
|
||||
msg = _Msg(text="!resume")
|
||||
with patch.object(_mod, "_ensure_loop") as mock_loop:
|
||||
asyncio.run(_mod.cmd_resume(bot, msg))
|
||||
mock_loop.assert_called_once_with(bot, seek=225.0)
|
||||
ps = _mod._ps(bot)
|
||||
assert len(ps["queue"]) == 1
|
||||
assert ps["queue"][0].url == "https://example.com/a"
|
||||
assert any("Resuming" in r for r in bot.replied)
|
||||
|
||||
def test_time_format_in_reply(self):
|
||||
bot = _FakeBot()
|
||||
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
|
||||
_mod._save_resume(bot, track, 225.0)
|
||||
msg = _Msg(text="!resume")
|
||||
with patch.object(_mod, "_ensure_loop"):
|
||||
asyncio.run(_mod.cmd_resume(bot, msg))
|
||||
assert any("3:45" in r for r in bot.replied)
|
||||
|
||||
def test_clears_resume_state_after_loading(self):
|
||||
bot = _FakeBot()
|
||||
track = _mod._Track(url="https://example.com/a", title="Song", requester="Alice")
|
||||
_mod._save_resume(bot, track, 60.0)
|
||||
msg = _Msg(text="!resume")
|
||||
with patch.object(_mod, "_ensure_loop"):
|
||||
asyncio.run(_mod.cmd_resume(bot, msg))
|
||||
assert _mod._load_resume(bot) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestFmtTime
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFmtTime:
|
||||
def test_zero(self):
|
||||
assert _mod._fmt_time(0) == "0:00"
|
||||
|
||||
def test_seconds_only(self):
|
||||
assert _mod._fmt_time(45) == "0:45"
|
||||
|
||||
def test_minutes_and_seconds(self):
|
||||
assert _mod._fmt_time(225) == "3:45"
|
||||
|
||||
def test_large_value(self):
|
||||
assert _mod._fmt_time(3661) == "61:01"
|
||||
|
||||
Reference in New Issue
Block a user