diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index 4cf0023..1ea7242 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -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 # 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 diff --git a/docs/USAGE.md b/docs/USAGE.md index 3502530..b659750 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1569,6 +1569,7 @@ and voice transmission. !play 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 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 diff --git a/plugins/music.py b/plugins/music.py index ce9b87d..b39a894 100644 --- a/plugins/music.py +++ b/plugins/music.py @@ -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.""" diff --git a/src/derp/mumble.py b/src/derp/mumble.py index caf5041..f0e9ac7 100644 --- a/src/derp/mumble.py +++ b/src/derp/mumble.py @@ -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 - | 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") diff --git a/tests/test_music.py b/tests/test_music.py index 0ecaccf..3eaaf0d 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -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"