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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user