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:
user
2026-02-22 00:15:39 +01:00
parent 9d58a5d073
commit f189cbd290
5 changed files with 227 additions and 9 deletions

View File

@@ -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."""