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:
user
2026-02-22 00:15:39 +01:00
parent 9d58a5d073
commit f189cbd290
5 changed files with 227 additions and 9 deletions

View File

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

View File

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

View File

@@ -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."""

View File

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

View File

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