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
|
!play classical music # YouTube search, random pick from top 10
|
||||||
!stop # Stop playback, clear queue
|
!stop # Stop playback, clear queue
|
||||||
!skip # Skip current track
|
!skip # Skip current track
|
||||||
|
!resume # Resume last stopped/skipped track
|
||||||
!queue # Show queue
|
!queue # Show queue
|
||||||
!queue <url> # Add to queue (alias for !play)
|
!queue <url> # Add to queue (alias for !play)
|
||||||
!np # Now playing
|
!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.
|
Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.
|
||||||
Max 50 tracks in queue. Playlists auto-expand; excess truncated at limit.
|
Max 50 tracks in queue. Playlists auto-expand; excess truncated at limit.
|
||||||
Volume ramps smoothly over ~1s (no abrupt jumps mid-playback).
|
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.
|
Mumble-only: `!play` replies with error on other adapters, others silently no-op.
|
||||||
|
|
||||||
## Plugin Template
|
## Plugin Template
|
||||||
|
|||||||
@@ -1569,6 +1569,7 @@ and voice transmission.
|
|||||||
!play <query> Search YouTube, play a random result
|
!play <query> Search YouTube, play a random result
|
||||||
!stop Stop playback, clear queue
|
!stop Stop playback, clear queue
|
||||||
!skip Skip current track
|
!skip Skip current track
|
||||||
|
!resume Resume last stopped/skipped track from saved position
|
||||||
!queue Show queue
|
!queue Show queue
|
||||||
!queue <url> Add to queue (alias for !play)
|
!queue <url> Add to queue (alias for !play)
|
||||||
!np Now playing
|
!np Now playing
|
||||||
@@ -1589,3 +1590,5 @@ and voice transmission.
|
|||||||
other music commands silently no-op
|
other music commands silently no-op
|
||||||
- Playback runs as an asyncio background task; the bot remains responsive
|
- Playback runs as an asyncio background task; the bot remains responsive
|
||||||
to text commands during streaming
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -57,6 +58,45 @@ def _is_url(text: str) -> bool:
|
|||||||
return text.startswith(("http://", "https://", "ytsearch:"))
|
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]]:
|
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.
|
"""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 ---------------------------------------------------------------
|
# -- 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."""
|
"""Pop tracks from queue and stream them sequentially."""
|
||||||
ps = _ps(bot)
|
ps = _ps(bot)
|
||||||
|
first = True
|
||||||
try:
|
try:
|
||||||
while ps["queue"]:
|
while ps["queue"]:
|
||||||
track = ps["queue"].pop(0)
|
track = ps["queue"].pop(0)
|
||||||
@@ -102,17 +143,27 @@ async def _play_loop(bot) -> None:
|
|||||||
done = asyncio.Event()
|
done = asyncio.Event()
|
||||||
ps["done_event"] = done
|
ps["done_event"] = done
|
||||||
|
|
||||||
|
cur_seek = seek if first else 0.0
|
||||||
|
first = False
|
||||||
|
progress = [0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await bot.stream_audio(
|
await bot.stream_audio(
|
||||||
track.url,
|
track.url,
|
||||||
volume=lambda: ps["volume"] / 100.0,
|
volume=lambda: ps["volume"] / 100.0,
|
||||||
on_done=done,
|
on_done=done,
|
||||||
|
seek=cur_seek,
|
||||||
|
progress=progress,
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
|
elapsed = cur_seek + progress[0] * 0.02
|
||||||
|
if elapsed > 1.0:
|
||||||
|
_save_resume(bot, track, elapsed)
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("music: stream error for %s", track.url)
|
log.exception("music: stream error for %s", track.url)
|
||||||
|
|
||||||
|
_clear_resume(bot)
|
||||||
await done.wait()
|
await done.wait()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
@@ -122,13 +173,15 @@ async def _play_loop(bot) -> None:
|
|||||||
ps["task"] = 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."""
|
"""Start the play loop if not already running."""
|
||||||
ps = _ps(bot)
|
ps = _ps(bot)
|
||||||
task = ps.get("task")
|
task = ps.get("task")
|
||||||
if task and not task.done():
|
if task and not task.done():
|
||||||
return
|
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 ----------------------------------------------------------------
|
# -- Commands ----------------------------------------------------------------
|
||||||
@@ -220,6 +273,43 @@ async def cmd_stop(bot, message):
|
|||||||
await bot.reply(message, "Stopped")
|
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")
|
@command("skip", help="Music: !skip")
|
||||||
async def cmd_skip(bot, message):
|
async def cmd_skip(bot, message):
|
||||||
"""Skip current track, advance to next in queue."""
|
"""Skip current track, advance to next in queue."""
|
||||||
|
|||||||
@@ -446,36 +446,41 @@ class MumbleBot:
|
|||||||
*,
|
*,
|
||||||
volume=0.5,
|
volume=0.5,
|
||||||
on_done=None,
|
on_done=None,
|
||||||
|
seek: float = 0.0,
|
||||||
|
progress: list | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Stream audio from URL through yt-dlp|ffmpeg to voice channel.
|
"""Stream audio from URL through yt-dlp|ffmpeg to voice channel.
|
||||||
|
|
||||||
Pipeline:
|
Pipeline:
|
||||||
yt-dlp -o - -f bestaudio <url>
|
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
|
Feeds raw PCM to pymumble's sound_output which handles Opus
|
||||||
encoding, packetization, and timing.
|
encoding, packetization, and timing.
|
||||||
|
|
||||||
``volume`` may be a float (static) or a callable returning float
|
``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:
|
if self._mumble is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
_get_vol = volume if callable(volume) else lambda: volume
|
_get_vol = volume if callable(volume) else lambda: volume
|
||||||
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%)",
|
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%, seek=%.1fs)",
|
||||||
url, _get_vol() * 100)
|
url, _get_vol() * 100, seek)
|
||||||
|
|
||||||
|
seek_flag = f" -ss {seek:.3f}" if seek > 0 else ""
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"sh", "-c",
|
"sh", "-c",
|
||||||
f"yt-dlp -o - -f bestaudio --no-warnings {_shell_quote(url)}"
|
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",
|
f" -loglevel error pipe:1",
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=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()
|
_cur_vol = _get_vol()
|
||||||
|
|
||||||
frames = 0
|
frames = 0
|
||||||
@@ -506,6 +511,8 @@ class MumbleBot:
|
|||||||
|
|
||||||
self._mumble.sound_output.add_sound(pcm)
|
self._mumble.sound_output.add_sound(pcm)
|
||||||
frames += 1
|
frames += 1
|
||||||
|
if progress is not None:
|
||||||
|
progress[0] = frames
|
||||||
|
|
||||||
if frames == 1:
|
if frames == 1:
|
||||||
log.info("stream_audio: first frame fed to pymumble")
|
log.info("stream_audio: first frame fed to pymumble")
|
||||||
|
|||||||
@@ -499,3 +499,119 @@ class TestPlaylistExpansion:
|
|||||||
with patch("subprocess.run", return_value=result):
|
with patch("subprocess.run", return_value=result):
|
||||||
tracks = _mod._resolve_tracks("https://example.com/empty")
|
tracks = _mod._resolve_tracks("https://example.com/empty")
|
||||||
assert tracks == [("https://example.com/empty", "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