fix: kept file protection, skip/autoplay, TTS routing, video ID expansion

- _cleanup_track: never delete files from kept directory (data/music/)
  even when track.keep=False -- fixes kept files vanishing on replay
- !kept rm: skip to next track if removing the currently playing one
- !skip: silent (no reply), restarts play loop for autoplay on empty queue
- TTS plays through merlin's own connection instead of derp's, preventing
  choppy audio when music and TTS compete for the same output buffer
- !play recognizes bare YouTube video IDs (11-char alphanumeric)
- !kept rm <id> subcommand for removing individual kept tracks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 18:00:23 +01:00
parent 36da191b45
commit 068734d931
5 changed files with 163 additions and 34 deletions

View File

@@ -79,6 +79,16 @@ def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str:
return text[: max_len - 3].rstrip() + "..."
_YT_VIDEO_ID_RE = re.compile(r"^[A-Za-z0-9_-]{11}$")
def _expand_video_id(text: str) -> str:
"""Expand a bare YouTube video ID to a full URL."""
if _YT_VIDEO_ID_RE.match(text):
return f"https://www.youtube.com/watch?v={text}"
return text
def _is_url(text: str) -> bool:
"""Check if text looks like a URL rather than a search query."""
return text.startswith(("http://", "https://", "ytsearch:"))
@@ -309,9 +319,13 @@ def _download_track(url: str, track_id: str, title: str = "") -> Path | None:
def _cleanup_track(track: _Track) -> None:
"""Delete the local audio file unless marked to keep."""
"""Delete the local audio file unless marked to keep or in kept dir."""
if track.local_path is None or track.keep:
return
# Never delete files from the kept directory -- they may have been
# reused by _download_track for a non-kept playback of the same URL.
if track.local_path.parent == _MUSIC_DIR:
return
try:
track.local_path.unlink(missing_ok=True)
log.info("music: deleted %s", track.local_path.name)
@@ -844,6 +858,9 @@ async def cmd_play(bot, message):
_ensure_loop(bot)
return
# Expand bare YouTube video IDs (e.g. "U1yQMjFZ6j4")
url = _expand_video_id(url)
# Strip #random fragment before URL classification / resolution
shuffle = False
if _is_url(url) and url.endswith("#random"):
@@ -1074,14 +1091,7 @@ async def cmd_skip(bot, message):
await _fade_and_cancel(bot)
if ps["queue"]:
_ensure_loop(bot)
await bot.reply(
message,
f"Skipped: {_truncate(skipped.title)}",
)
else:
await bot.reply(message, "Skipped, queue empty")
_ensure_loop(bot)
@command("prev", help="Music: !prev -- play previous track")
@@ -1475,14 +1485,15 @@ async def cmd_keep(bot, message):
await bot.reply(message, f"Keeping #{keep_id}: {label}")
@command("kept", help="Music: !kept [clear|repair] -- list, clear, or repair kept files")
@command("kept", help="Music: !kept [rm <id>|clear|repair] -- manage kept files")
async def cmd_kept(bot, message):
"""List, clear, or repair kept audio files in data/music/.
"""List, clear, remove, or repair kept audio files in data/music/.
Usage:
!kept List kept tracks with metadata and file status
!kept clear Delete all kept files and metadata
!kept repair Re-download kept tracks whose local files are missing
!kept List kept tracks with metadata and file status
!kept rm <id> Remove a single kept track by ID
!kept clear Delete all kept files and metadata
!kept repair Re-download kept tracks whose local files are missing
"""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
@@ -1491,6 +1502,34 @@ async def cmd_kept(bot, message):
parts = message.text.split()
sub = parts[1].lower() if len(parts) >= 2 else ""
if sub == "rm":
if len(parts) < 3:
await bot.reply(message, "Usage: !kept rm <id>")
return
kid = parts[2].lstrip("#")
raw = bot.state.get("music", f"keep:{kid}")
if not raw:
await bot.reply(message, f"No kept track with ID #{kid}")
return
try:
meta = json.loads(raw)
except (json.JSONDecodeError, TypeError):
meta = {}
filename = meta.get("filename", "")
if filename:
fpath = _MUSIC_DIR / filename
fpath.unlink(missing_ok=True)
bot.state.delete("music", f"keep:{kid}")
title = meta.get("title") or filename or kid
await bot.reply(message, f"Removed #{kid}: {_truncate(title)}")
# Skip if this track is currently playing
ps = _ps(bot)
cur = ps.get("current")
if cur and filename and cur.local_path and cur.local_path.name == filename:
await _fade_and_cancel(bot)
_ensure_loop(bot)
return
if sub == "clear":
count = 0
if _MUSIC_DIR.is_dir():