feat: download audio before playback, add !keep and !kept commands
Audio is now downloaded to data/music/ before playback begins, eliminating CDN hiccups mid-stream. Falls back to streaming on download failure. Files are deleted after playback unless marked with !keep. stream_audio detects local files and uses a direct ffmpeg pipeline (no yt-dlp).
This commit is contained in:
112
plugins/music.py
112
plugins/music.py
@@ -3,12 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from derp.plugin import command
|
||||
|
||||
@@ -24,6 +26,8 @@ class _Track:
|
||||
title: str
|
||||
requester: str
|
||||
origin: str = "" # original user-provided URL for re-resolution
|
||||
local_path: Path | None = None # set before playback
|
||||
keep: bool = False # True = don't delete after playback
|
||||
|
||||
|
||||
# -- Per-bot runtime state ---------------------------------------------------
|
||||
@@ -138,6 +142,43 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
|
||||
return [(url, url)]
|
||||
|
||||
|
||||
# -- Download helpers --------------------------------------------------------
|
||||
|
||||
|
||||
_MUSIC_DIR = Path("data/music")
|
||||
|
||||
|
||||
def _download_track(url: str, track_id: str) -> Path | None:
|
||||
"""Download audio to data/music/. Blocking -- run in executor."""
|
||||
_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
||||
template = str(_MUSIC_DIR / f"{track_id}.%(ext)s")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "-f", "bestaudio", "--no-warnings",
|
||||
"-o", template, "--print", "after_move:filepath", url],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
filepath = result.stdout.strip().splitlines()[-1] if result.stdout.strip() else ""
|
||||
if filepath and Path(filepath).is_file():
|
||||
return Path(filepath)
|
||||
matches = list(_MUSIC_DIR.glob(f"{track_id}.*"))
|
||||
return matches[0] if matches else None
|
||||
except Exception:
|
||||
log.exception("download failed for %s", url)
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_track(track: _Track) -> None:
|
||||
"""Delete the local audio file unless marked to keep."""
|
||||
if track.local_path is None or track.keep:
|
||||
return
|
||||
try:
|
||||
track.local_path.unlink(missing_ok=True)
|
||||
log.info("music: deleted %s", track.local_path.name)
|
||||
except OSError:
|
||||
log.warning("music: failed to delete %s", track.local_path)
|
||||
|
||||
|
||||
# -- Duck monitor ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -303,9 +344,26 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
first = False
|
||||
progress = [0]
|
||||
|
||||
# Download phase
|
||||
source = track.url
|
||||
if track.local_path is None:
|
||||
loop = asyncio.get_running_loop()
|
||||
tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
|
||||
dl_path = await loop.run_in_executor(
|
||||
None, _download_track, track.url, tid,
|
||||
)
|
||||
if dl_path:
|
||||
track.local_path = dl_path
|
||||
source = str(dl_path)
|
||||
else:
|
||||
log.warning("music: download failed, streaming %s",
|
||||
track.url)
|
||||
else:
|
||||
source = str(track.local_path)
|
||||
|
||||
try:
|
||||
await bot.stream_audio(
|
||||
track.url,
|
||||
source,
|
||||
volume=lambda: (
|
||||
ps["duck_vol"]
|
||||
if ps["duck_vol"] is not None
|
||||
@@ -330,6 +388,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
|
||||
await done.wait()
|
||||
if progress[0] > 0:
|
||||
_clear_resume(bot)
|
||||
_cleanup_track(track)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
@@ -706,6 +765,57 @@ async def cmd_duck(bot, message):
|
||||
)
|
||||
|
||||
|
||||
@command("keep", help="Music: !keep -- keep current track's audio file")
|
||||
async def cmd_keep(bot, message):
|
||||
"""Mark the current track's local file to keep after playback."""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Mumble-only feature")
|
||||
return
|
||||
|
||||
ps = _ps(bot)
|
||||
track = ps["current"]
|
||||
if track is None:
|
||||
await bot.reply(message, "Nothing playing")
|
||||
return
|
||||
if track.local_path is None:
|
||||
await bot.reply(message, "No local file for current track")
|
||||
return
|
||||
track.keep = True
|
||||
await bot.reply(message, f"Keeping: {track.local_path.name}")
|
||||
|
||||
|
||||
@command("kept", help="Music: !kept [clear] -- list or clear kept files")
|
||||
async def cmd_kept(bot, message):
|
||||
"""List or clear kept audio files in data/music/."""
|
||||
if not _is_mumble(bot):
|
||||
await bot.reply(message, "Mumble-only feature")
|
||||
return
|
||||
|
||||
parts = message.text.split()
|
||||
if len(parts) >= 2 and parts[1].lower() == "clear":
|
||||
count = 0
|
||||
if _MUSIC_DIR.is_dir():
|
||||
for f in _MUSIC_DIR.iterdir():
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
count += 1
|
||||
await bot.reply(message, f"Deleted {count} file(s)")
|
||||
return
|
||||
|
||||
files = sorted(_MUSIC_DIR.iterdir()) if _MUSIC_DIR.is_dir() else []
|
||||
files = [f for f in files if f.is_file()]
|
||||
if not files:
|
||||
await bot.reply(message, "No kept files")
|
||||
return
|
||||
|
||||
lines = [f"Kept files ({len(files)}):"]
|
||||
for f in files:
|
||||
size_mb = f.stat().st_size / (1024 * 1024)
|
||||
lines.append(f" {f.name} ({size_mb:.1f}MB)")
|
||||
for line in lines:
|
||||
await bot.reply(message, line)
|
||||
|
||||
|
||||
# -- Plugin lifecycle --------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user