feat: add !seek command and persist volume across restarts

Seek to absolute or relative positions mid-track via !seek. Supports
M:SS and plain seconds with +/- prefixes. Volume is now saved to
bot.state and restored on connect.
This commit is contained in:
user
2026-02-22 03:31:35 +01:00
parent 7c099d8cf0
commit c493583a71
2 changed files with 282 additions and 0 deletions

View File

@@ -78,6 +78,42 @@ def _fmt_time(seconds: float) -> str:
return f"{m}:{s:02d}"
def _parse_seek(arg: str) -> tuple[str, float]:
"""Parse a seek offset string into (mode, seconds).
Returns ``("abs", seconds)`` for absolute seeks (``1:30``, ``90``)
or ``("rel", +/-seconds)`` for relative (``+30``, ``-1:00``).
Raises ``ValueError`` on invalid input.
"""
if not arg:
raise ValueError("empty seek argument")
mode = "abs"
raw = arg
if raw[0] in ("+", "-"):
mode = "rel"
sign = -1 if raw[0] == "-" else 1
raw = raw[1:]
else:
sign = 1
if ":" in raw:
parts = raw.split(":", 1)
try:
minutes = int(parts[0])
seconds = int(parts[1])
except ValueError:
raise ValueError(f"invalid seek format: {arg}")
total = minutes * 60 + seconds
else:
try:
total = int(raw)
except ValueError:
raise ValueError(f"invalid seek format: {arg}")
return (mode, sign * float(total))
# -- Resume state persistence ------------------------------------------------
@@ -343,6 +379,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
cur_seek = seek if first else 0.0
first = False
progress = [0]
ps["progress"] = progress
ps["cur_seek"] = cur_seek
# Download phase
source = track.url
@@ -399,6 +437,8 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
ps["task"] = None
ps["duck_vol"] = None
ps["duck_task"] = None
ps["progress"] = None
ps["cur_seek"] = 0.0
def _ensure_loop(bot, *, seek: float = 0.0) -> None:
@@ -572,6 +612,63 @@ async def cmd_skip(bot, message):
await bot.reply(message, "Skipped, queue empty")
@command("seek", help="Music: !seek <offset>")
async def cmd_seek(bot, message):
"""Seek to position in current track.
Usage:
!seek 1:30 Seek to 1 minute 30 seconds
!seek 90 Seek to 90 seconds
!seek +30 Jump forward 30 seconds
!seek -30 Jump backward 30 seconds
!seek +1:00 Jump forward 1 minute
"""
if not _is_mumble(bot):
return
ps = _ps(bot)
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !seek <offset> (e.g. 1:30, +30, -30)")
return
try:
mode, seconds = _parse_seek(parts[1].strip())
except ValueError:
await bot.reply(message, "Usage: !seek <offset> (e.g. 1:30, +30, -30)")
return
track = ps["current"]
if track is None:
await bot.reply(message, "Nothing playing")
return
# Compute target position
if mode == "abs":
target = seconds
else:
progress = ps.get("progress")
cur_seek = ps.get("cur_seek", 0.0)
elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0)
target = elapsed + seconds
target = max(0.0, target)
# Re-insert current track at front of queue (local_path intact)
ps["queue"].insert(0, track)
# Cancel the play loop
task = ps.get("task")
if task and not task.done():
task.cancel()
ps["current"] = None
ps["task"] = None
ps["duck_vol"] = None
_ensure_loop(bot, seek=target)
await bot.reply(message, f"Seeking to {_fmt_time(target)}")
@command("queue", help="Music: !queue [url]")
async def cmd_queue(bot, message):
"""Show queue or add a URL.
@@ -673,6 +770,7 @@ async def cmd_volume(bot, message):
return
ps["volume"] = val
bot.state.set("music", "volume", str(val))
await bot.reply(message, f"Volume set to {val}%")
@@ -828,6 +926,12 @@ async def on_connected(bot) -> None:
if not _is_mumble(bot):
return
ps = _ps(bot)
saved_vol = bot.state.get("music", "volume")
if saved_vol is not None:
try:
ps["volume"] = max(0, min(100, int(saved_vol)))
except ValueError:
pass
if ps["_watcher_task"] is None and hasattr(bot, "_spawn"):
ps["_watcher_task"] = bot._spawn(
_reconnect_watcher(bot), name="music-reconnect-watcher",

View File

@@ -1181,3 +1181,181 @@ class TestKeptCommand:
msg = _Msg(text="!kept")
asyncio.run(_mod.cmd_kept(bot, msg))
assert any("Mumble-only" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestParseSeek
# ---------------------------------------------------------------------------
class TestParseSeek:
def test_absolute_seconds(self):
assert _mod._parse_seek("90") == ("abs", 90.0)
def test_absolute_mss(self):
assert _mod._parse_seek("1:30") == ("abs", 90.0)
def test_relative_forward(self):
assert _mod._parse_seek("+30") == ("rel", 30.0)
def test_relative_backward(self):
assert _mod._parse_seek("-30") == ("rel", -30.0)
def test_relative_mss(self):
assert _mod._parse_seek("+1:30") == ("rel", 90.0)
def test_relative_backward_mss(self):
assert _mod._parse_seek("-1:30") == ("rel", -90.0)
def test_invalid_raises(self):
import pytest
with pytest.raises(ValueError):
_mod._parse_seek("abc")
def test_empty_raises(self):
import pytest
with pytest.raises(ValueError):
_mod._parse_seek("")
# ---------------------------------------------------------------------------
# TestSeekCommand
# ---------------------------------------------------------------------------
class TestSeekCommand:
def test_seek_nothing_playing(self):
bot = _FakeBot()
msg = _Msg(text="!seek 1:30")
asyncio.run(_mod.cmd_seek(bot, msg))
assert any("Nothing playing" in r for r in bot.replied)
def test_seek_non_mumble(self):
bot = _FakeBot(mumble=False)
msg = _Msg(text="!seek 1:30")
asyncio.run(_mod.cmd_seek(bot, msg))
assert bot.replied == []
def test_seek_no_arg(self):
bot = _FakeBot()
msg = _Msg(text="!seek")
asyncio.run(_mod.cmd_seek(bot, msg))
assert any("Usage" in r for r in bot.replied)
def test_seek_invalid_arg(self):
bot = _FakeBot()
msg = _Msg(text="!seek xyz")
asyncio.run(_mod.cmd_seek(bot, msg))
assert any("Usage" in r for r in bot.replied)
def test_seek_absolute(self):
bot = _FakeBot()
ps = _mod._ps(bot)
track = _mod._Track(url="x", title="Song", requester="a")
ps["current"] = track
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!seek 1:30")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_seek(bot, msg))
mock_loop.assert_called_once_with(bot, seek=90.0)
assert ps["queue"][0] is track
assert any("1:30" in r for r in bot.replied)
mock_task.cancel.assert_called_once()
def test_seek_relative_forward(self):
bot = _FakeBot()
ps = _mod._ps(bot)
track = _mod._Track(url="x", title="Song", requester="a")
ps["current"] = track
ps["progress"] = [1500] # 1500 * 0.02 = 30s
ps["cur_seek"] = 60.0 # started at 60s
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!seek +30")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_seek(bot, msg))
# elapsed = 60 + 30 = 90, target = 90 + 30 = 120
mock_loop.assert_called_once_with(bot, seek=120.0)
def test_seek_relative_backward_clamps(self):
bot = _FakeBot()
ps = _mod._ps(bot)
track = _mod._Track(url="x", title="Song", requester="a")
ps["current"] = track
ps["progress"] = [500] # 500 * 0.02 = 10s
ps["cur_seek"] = 0.0
mock_task = MagicMock()
mock_task.done.return_value = False
ps["task"] = mock_task
msg = _Msg(text="!seek -30")
with patch.object(_mod, "_ensure_loop") as mock_loop:
asyncio.run(_mod.cmd_seek(bot, msg))
# elapsed = 0 + 10 = 10, target = 10 - 30 = -20, clamped to 0
mock_loop.assert_called_once_with(bot, seek=0.0)
# ---------------------------------------------------------------------------
# TestVolumePersistence
# ---------------------------------------------------------------------------
class TestVolumePersistence:
def test_volume_persists_to_state(self):
bot = _FakeBot()
msg = _Msg(text="!volume 75")
asyncio.run(_mod.cmd_volume(bot, msg))
assert bot.state.get("music", "volume") == "75"
def test_volume_loads_on_connect(self):
bot = _FakeBot()
bot.state.set("music", "volume", "80")
spawned = []
def fake_spawn(coro, *, name=None):
task = MagicMock()
task.done.return_value = False
spawned.append(name)
coro.close()
return task
bot._spawn = fake_spawn
asyncio.run(_mod.on_connected(bot))
ps = _mod._ps(bot)
assert ps["volume"] == 80
def test_volume_loads_clamps_high(self):
bot = _FakeBot()
bot.state.set("music", "volume", "200")
spawned = []
def fake_spawn(coro, *, name=None):
task = MagicMock()
task.done.return_value = False
spawned.append(name)
coro.close()
return task
bot._spawn = fake_spawn
asyncio.run(_mod.on_connected(bot))
ps = _mod._ps(bot)
assert ps["volume"] == 100
def test_volume_loads_ignores_invalid(self):
bot = _FakeBot()
bot.state.set("music", "volume", "notanumber")
spawned = []
def fake_spawn(coro, *, name=None):
task = MagicMock()
task.done.return_value = False
spawned.append(name)
coro.close()
return task
bot._spawn = fake_spawn
asyncio.run(_mod.on_connected(bot))
ps = _mod._ps(bot)
assert ps["volume"] == 50 # default unchanged