diff --git a/plugins/music.py b/plugins/music.py index dbc757b..ba37903 100644 --- a/plugins/music.py +++ b/plugins/music.py @@ -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 ") +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 (e.g. 1:30, +30, -30)") + return + + try: + mode, seconds = _parse_seek(parts[1].strip()) + except ValueError: + await bot.reply(message, "Usage: !seek (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", diff --git a/tests/test_music.py b/tests/test_music.py index a520d43..6b91f02 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -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