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:
104
plugins/music.py
104
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 <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",
|
||||
|
||||
Reference in New Issue
Block a user