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:
@@ -551,6 +551,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
|
||||
!play classical music # YouTube search, random pick from top 10
|
||||
!stop # Stop playback, clear queue
|
||||
!skip # Skip current track
|
||||
!resume # Resume last stopped/skipped track
|
||||
!queue # Show queue
|
||||
!queue <url> # Add to queue (alias for !play)
|
||||
!np # Now playing
|
||||
@@ -561,6 +562,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
|
||||
Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.
|
||||
Max 50 tracks in queue. Playlists auto-expand; excess truncated at limit.
|
||||
Volume ramps smoothly over ~1s (no abrupt jumps mid-playback).
|
||||
`!resume` restores position across restarts (persisted via `bot.state`).
|
||||
Mumble-only: `!play` replies with error on other adapters, others silently no-op.
|
||||
|
||||
## Plugin Template
|
||||
|
||||
@@ -1569,6 +1569,7 @@ and voice transmission.
|
||||
!play <query> Search YouTube, play a random result
|
||||
!stop Stop playback, clear queue
|
||||
!skip Skip current track
|
||||
!resume Resume last stopped/skipped track from saved position
|
||||
!queue Show queue
|
||||
!queue <url> Add to queue (alias for !play)
|
||||
!np Now playing
|
||||
@@ -1589,3 +1590,5 @@ and voice transmission.
|
||||
other music commands silently no-op
|
||||
- Playback runs as an asyncio background task; the bot remains responsive
|
||||
to text commands during streaming
|
||||
- `!resume` continues from where playback was interrupted (`!stop`/`!skip`);
|
||||
position is persisted via `bot.state` and survives bot restarts
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import subprocess
|
||||
@@ -57,6 +58,45 @@ def _is_url(text: str) -> bool:
|
||||
return text.startswith(("http://", "https://", "ytsearch:"))
|
||||
|
||||
|
||||
def _fmt_time(seconds: float) -> str:
|
||||
"""Format seconds as M:SS."""
|
||||
m, s = divmod(int(seconds), 60)
|
||||
return f"{m}:{s:02d}"
|
||||
|
||||
|
||||
# -- Resume state persistence ------------------------------------------------
|
||||
|
||||
|
||||
def _save_resume(bot, track: _Track, elapsed: float) -> None:
|
||||
"""Persist current track and elapsed position for later resumption."""
|
||||
data = json.dumps({
|
||||
"url": track.url,
|
||||
"title": track.title,
|
||||
"requester": track.requester,
|
||||
"elapsed": round(elapsed, 2),
|
||||
})
|
||||
bot.state.set("music", "resume", data)
|
||||
|
||||
|
||||
def _load_resume(bot) -> dict | None:
|
||||
"""Load resume data, or None if absent/corrupt."""
|
||||
raw = bot.state.get("music", "resume")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, dict) or "url" not in data:
|
||||
return None
|
||||
return data
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _clear_resume(bot) -> None:
|
||||
"""Remove persisted resume state."""
|
||||
bot.state.delete("music", "resume")
|
||||
|
||||
|
||||
def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, str]]:
|
||||
"""Resolve URL into (url, title) pairs via yt-dlp. Blocking, run in executor.
|
||||
|
||||
@@ -91,9 +131,10 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
|
||||
# -- Play loop ---------------------------------------------------------------
|
||||
|
||||
|
||||
async def _play_loop(bot) -> None:
|
||||
async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
"""Pop tracks from queue and stream them sequentially."""
|
||||
ps = _ps(bot)
|
||||
first = True
|
||||
try:
|
||||
while ps["queue"]:
|
||||
track = ps["queue"].pop(0)
|
||||
@@ -102,17 +143,27 @@ async def _play_loop(bot) -> None:
|
||||
done = asyncio.Event()
|
||||
ps["done_event"] = done
|
||||
|
||||
cur_seek = seek if first else 0.0
|
||||
first = False
|
||||
progress = [0]
|
||||
|
||||
try:
|
||||
await bot.stream_audio(
|
||||
track.url,
|
||||
volume=lambda: ps["volume"] / 100.0,
|
||||
on_done=done,
|
||||
seek=cur_seek,
|
||||
progress=progress,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
elapsed = cur_seek + progress[0] * 0.02
|
||||
if elapsed > 1.0:
|
||||
_save_resume(bot, track, elapsed)
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("music: stream error for %s", track.url)
|
||||
|
||||
_clear_resume(bot)
|
||||
await done.wait()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
@@ -122,13 +173,15 @@ async def _play_loop(bot) -> None:
|
||||
ps["task"] = None
|
||||
|
||||
|
||||
def _ensure_loop(bot) -> None:
|
||||
def _ensure_loop(bot, *, seek: float = 0.0) -> None:
|
||||
"""Start the play loop if not already running."""
|
||||
ps = _ps(bot)
|
||||
task = ps.get("task")
|
||||
if task and not task.done():
|
||||
return
|
||||
ps["task"] = bot._spawn(_play_loop(bot), name="music-play-loop")
|
||||
ps["task"] = bot._spawn(
|
||||
_play_loop(bot, seek=seek), name="music-play-loop",
|
||||
)
|
||||
|
||||
|
||||
# -- Commands ----------------------------------------------------------------
|
||||
@@ -220,6 +273,43 @@ async def cmd_stop(bot, message):
|
||||
await bot.reply(message, "Stopped")
|
||||
|
||||
|
||||
@command("resume", help="Music: !resume -- resume last stopped track")
|
||||
async def cmd_resume(bot, message):
|
||||
"""Resume playback from the last interrupted position.
|
||||
|
||||
Loads the track URL and elapsed time saved when playback was stopped
|
||||
or skipped. The position persists across bot restarts.
|
||||
"""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Music playback is Mumble-only")
|
||||
return
|
||||
|
||||
ps = _ps(bot)
|
||||
if ps["current"] is not None:
|
||||
await bot.reply(message, "Already playing")
|
||||
return
|
||||
|
||||
data = _load_resume(bot)
|
||||
if data is None:
|
||||
await bot.reply(message, "Nothing to resume")
|
||||
return
|
||||
|
||||
elapsed = data.get("elapsed", 0.0)
|
||||
track = _Track(
|
||||
url=data["url"],
|
||||
title=data.get("title", data["url"]),
|
||||
requester=data.get("requester", "?"),
|
||||
)
|
||||
ps["queue"].insert(0, track)
|
||||
_clear_resume(bot)
|
||||
|
||||
await bot.reply(
|
||||
message,
|
||||
f"Resuming: {_truncate(track.title)} from {_fmt_time(elapsed)}",
|
||||
)
|
||||
_ensure_loop(bot, seek=elapsed)
|
||||
|
||||
|
||||
@command("skip", help="Music: !skip")
|
||||
async def cmd_skip(bot, message):
|
||||
"""Skip current track, advance to next in queue."""
|
||||
|
||||
@@ -446,36 +446,41 @@ class MumbleBot:
|
||||
*,
|
||||
volume=0.5,
|
||||
on_done=None,
|
||||
seek: float = 0.0,
|
||||
progress: list | None = None,
|
||||
) -> None:
|
||||
"""Stream audio from URL through yt-dlp|ffmpeg to voice channel.
|
||||
|
||||
Pipeline:
|
||||
yt-dlp -o - -f bestaudio <url>
|
||||
| ffmpeg -i pipe:0 -f s16le -ar 48000 -ac 1 pipe:1
|
||||
| ffmpeg [-ss N.NNN] -i pipe:0 -f s16le -ar 48000 -ac 1 pipe:1
|
||||
|
||||
Feeds raw PCM to pymumble's sound_output which handles Opus
|
||||
encoding, packetization, and timing.
|
||||
|
||||
``volume`` may be a float (static) or a callable returning float
|
||||
(dynamic, re-read each frame).
|
||||
(dynamic, re-read each frame). ``seek`` skips into the track
|
||||
(seconds). ``progress`` is a mutable ``[0]`` list updated to the
|
||||
current frame count each frame.
|
||||
"""
|
||||
if self._mumble is None:
|
||||
return
|
||||
|
||||
_get_vol = volume if callable(volume) else lambda: volume
|
||||
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%)",
|
||||
url, _get_vol() * 100)
|
||||
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%, seek=%.1fs)",
|
||||
url, _get_vol() * 100, seek)
|
||||
|
||||
seek_flag = f" -ss {seek:.3f}" if seek > 0 else ""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"sh", "-c",
|
||||
f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
|
||||
f" | ffmpeg -i pipe:0 -f s16le -ar 48000 -ac 1"
|
||||
f" | ffmpeg{seek_flag} -i pipe:0 -f s16le -ar 48000 -ac 1"
|
||||
f" -loglevel error pipe:1",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
_max_step = 0.02 # max volume change per frame (~1s full ramp)
|
||||
_max_step = 0.005 # max volume change per frame (~4s full ramp)
|
||||
_cur_vol = _get_vol()
|
||||
|
||||
frames = 0
|
||||
@@ -506,6 +511,8 @@ class MumbleBot:
|
||||
|
||||
self._mumble.sound_output.add_sound(pcm)
|
||||
frames += 1
|
||||
if progress is not None:
|
||||
progress[0] = frames
|
||||
|
||||
if frames == 1:
|
||||
log.info("stream_audio: first frame fed to pymumble")
|
||||
|
||||
@@ -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