feat: music library management, smooth fades, clickable URLs

- Audio-only downloads (-x), resume (-c), skip existing (--no-overwrites)
- Title-based filenames (e.g. never-gonna-give-you-up.opus)
- Separate cache (data/music/cache/) from kept tracks (data/music/)
- Kept track IDs: !keep assigns #id, !play #id, !kept shows IDs
- Linear fade-in (5s) and fade-out (3s) with volume-proportional step
- Fix ramp click: threshold-based convergence instead of float equality
- Clean up cache files for skipped/stopped tracks
- Auto-linkify URLs in Mumble text chat (clickable <a> tags)
- FlaskPaste links use /raw endpoint for direct content access
- Metadata fetch uses --no-playlist for reliable results

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-22 08:11:29 +01:00
parent ad1de1653e
commit b88a459142
4 changed files with 224 additions and 85 deletions

View File

@@ -114,7 +114,7 @@ def _create_paste(base_url: str, content: str) -> str:
body = json.loads(resp.read())
paste_id = body.get("id", "")
if paste_id:
return f"{base_url}/{paste_id}"
return f"{base_url}/{paste_id}/raw"
return body.get("url", "")

View File

@@ -7,6 +7,8 @@ import hashlib
import json
import logging
import random
import re
import shutil
import subprocess
import time
from dataclasses import dataclass
@@ -184,7 +186,8 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s
# -- Download helpers --------------------------------------------------------
_MUSIC_DIR = Path("data/music")
_MUSIC_DIR = Path("data/music") # kept tracks (persistent)
_CACHE_DIR = Path("data/music/cache") # temporary playback downloads
def _fetch_metadata(url: str) -> dict:
@@ -192,7 +195,8 @@ def _fetch_metadata(url: str) -> dict:
try:
result = subprocess.run(
["yt-dlp", "--print", "title", "--print", "artist",
"--print", "duration", "--no-warnings", "--no-download", url],
"--print", "duration", "--no-warnings", "--no-download",
"--no-playlist", url],
capture_output=True, text=True, timeout=15,
)
lines = result.stdout.strip().splitlines()
@@ -209,20 +213,46 @@ def _fetch_metadata(url: str) -> dict:
return {"title": "", "artist": "", "duration": 0}
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")
def _sanitize_filename(title: str, fallback: str) -> str:
"""Convert a track title to a clean, filesystem-safe filename.
Keeps alphanumeric chars, hyphens, and underscores. Collapses
whitespace/separators into single hyphens. Falls back to the
URL-based hash if the title produces nothing usable.
"""
name = re.sub(r"[^\w\s-]", "", title.lower())
name = re.sub(r"[\s_-]+", "-", name).strip("-")
if not name:
return fallback
return name[:80]
def _download_track(url: str, track_id: str, title: str = "") -> Path | None:
"""Download audio to cache dir. Blocking -- run in executor.
Checks the kept directory first (reuse kept files). New downloads
go to the cache dir and are cleaned up after playback unless kept.
"""
filename = _sanitize_filename(title, track_id) if title else track_id
# Reuse existing kept or cached file
for d in (_MUSIC_DIR, _CACHE_DIR):
for name in (filename, track_id):
existing = list(d.glob(f"{name}.*")) if d.is_dir() else []
if existing:
return existing[0]
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
template = str(_CACHE_DIR / f"{track_id}.%(ext)s")
try:
result = subprocess.run(
["yt-dlp", "-f", "bestaudio", "--no-warnings",
"-o", template, "--print", "after_move:filepath", url],
["yt-dlp", "-f", "bestaudio", "-x", "-c", "--no-overwrites",
"--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}.*"))
matches = list(_CACHE_DIR.glob(f"{track_id}.*"))
return matches[0] if matches else None
except Exception:
log.exception("download failed for %s", url)
@@ -415,7 +445,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> 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,
None, _download_track, track.url, tid, track.title,
)
if dl_path:
track.local_path = dl_path
@@ -440,6 +470,7 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
seek=cur_seek,
progress=progress,
fade_step=lambda: ps.get("fade_step"),
fade_in=True,
)
except asyncio.CancelledError:
elapsed = cur_seek + progress[0] * 0.02
@@ -466,6 +497,10 @@ async def _play_loop(bot, *, seek: float = 0.0) -> None:
except asyncio.CancelledError:
pass
finally:
# Clean up current track's cached file (skipped/stopped tracks)
current = ps.get("current")
if current:
_cleanup_track(current)
if duck_task and not duck_task.done():
duck_task.cancel()
ps["current"] = None
@@ -493,16 +528,24 @@ def _ensure_loop(bot, *, seek: float = 0.0) -> None:
_MAX_HISTORY = 10
async def _fade_and_cancel(bot, duration: float = 0.8) -> None:
async def _fade_and_cancel(bot, duration: float = 3.0) -> None:
"""Fade audio to zero over ``duration`` seconds, then cancel the task."""
ps = _ps(bot)
task = ps.get("task")
if not task or task.done():
return
# Fast ramp: reach 0 from any volume in ~duration
# e.g. 0.8s = 40 frames at 20ms, step = 1.0/40 = 0.025
ps["fade_step"] = 1.0 / (duration / 0.02)
# Compute step from actual current volume so fade always spans `duration`.
# At 3% vol: step = 0.03/40 = 0.00075 (still ~0.8s fade).
# At 50% vol: step = 0.50/40 = 0.0125.
cur_vol = (
ps["duck_vol"] if ps["duck_vol"] is not None else ps["volume"]
) / 100.0
n_frames = max(duration / 0.02, 1)
step = max(cur_vol / n_frames, 0.0001)
ps["fade_step"] = step
ps["fade_vol"] = 0
log.debug("music: fading out (vol=%.2f, step=%.5f, duration=%.1fs)",
cur_vol, step, duration)
await asyncio.sleep(duration)
ps["fade_step"] = None
if not task.done():
@@ -533,14 +576,45 @@ async def cmd_play(bot, message):
parts = message.text.split(None, 1)
if len(parts) < 2:
await bot.reply(message, "Usage: !play <url|query>")
await bot.reply(message, "Usage: !play <url|query|#id>")
return
url = parts[1].strip()
ps = _ps(bot)
# Play a kept track by ID: !play #3
if url.startswith("#"):
kid = url[1:]
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):
await bot.reply(message, f"Bad metadata for #{kid}")
return
fpath = _MUSIC_DIR / meta.get("filename", "")
if not fpath.is_file():
await bot.reply(message, f"File missing for #{kid}")
return
title = meta.get("title") or meta.get("filename", kid)
track = _Track(url=meta.get("url", str(fpath)), title=title,
requester=message.nick or "?")
track.local_path = fpath
track.keep = True
was_idle = ps["current"] is None
ps["queue"].append(track)
if was_idle:
await bot.reply(message, f"Playing: {_truncate(title)}")
else:
await bot.reply(message, f"Queued #{len(ps['queue'])}: {_truncate(title)}")
_ensure_loop(bot)
return
is_search = not _is_url(url)
if is_search:
url = f"ytsearch10:{url}"
ps = _ps(bot)
if len(ps["queue"]) >= _MAX_QUEUE:
await bot.reply(message, f"Queue full ({_MAX_QUEUE} tracks)")
@@ -972,26 +1046,42 @@ async def cmd_keep(bot, message):
await bot.reply(message, "No local file for current track")
return
track.keep = True
filename = track.local_path.name
# Assign a unique short ID
last_id = int(bot.state.get("music", "keep_next_id") or "1")
keep_id = last_id
bot.state.set("music", "keep_next_id", str(last_id + 1))
# Fetch metadata in background
loop = asyncio.get_running_loop()
meta = await loop.run_in_executor(None, _fetch_metadata, track.url)
# Move file from cache to kept directory with a clean name
_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
tid = hashlib.md5(track.url.encode()).hexdigest()[:12]
clean_name = _sanitize_filename(meta.get("title", ""), tid)
ext = track.local_path.suffix
dest = _MUSIC_DIR / f"{clean_name}{ext}"
if track.local_path != dest and not dest.exists():
shutil.move(str(track.local_path), str(dest))
track.local_path = dest
filename = track.local_path.name
meta["filename"] = filename
meta["url"] = track.url
bot.state.set("music", f"keep:{filename}", json.dumps(meta))
meta["id"] = keep_id
bot.state.set("music", f"keep:{keep_id}", json.dumps(meta))
# Build display string
parts = []
title = meta.get("title") or track.title
artist = meta.get("artist", "")
dur = meta.get("duration", 0)
parts.append(_truncate(title))
label = _truncate(title)
if artist and artist.lower() not in ("na", "unknown", ""):
parts[0] += f" -- {artist}"
label += f" -- {artist}"
if dur > 0:
parts[0] += f" ({_fmt_time(dur)})"
await bot.reply(message, f"Keeping: {parts[0]}")
label += f" ({_fmt_time(dur)})"
await bot.reply(message, f"Keeping #{keep_id}: {label}")
@command("kept", help="Music: !kept [clear] -- list or clear kept files")
@@ -1013,40 +1103,51 @@ async def cmd_kept(bot, message):
if f.is_file():
f.unlink()
count += 1
# Clear stored metadata
# Clear stored metadata and reset ID counter
for key in bot.state.keys("music"):
if key.startswith("keep:"):
if key.startswith("keep:") or key == "keep_next_id":
bot.state.delete("music", key)
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")
# Collect kept entries from state
entries = []
for key in bot.state.keys("music"):
if not key.startswith("keep:"):
continue
raw = bot.state.get("music", key)
if not raw:
continue
try:
meta = json.loads(raw)
except (json.JSONDecodeError, TypeError):
continue
entries.append(meta)
if not entries:
await bot.reply(message, "No kept tracks")
return
lines = [f"Kept files ({len(files)}):"]
for f in files:
size_mb = f.stat().st_size / (1024 * 1024)
raw = bot.state.get("music", f"keep:{f.name}")
if raw:
try:
meta = json.loads(raw)
except (json.JSONDecodeError, TypeError):
meta = {}
title = meta.get("title", "")
artist = meta.get("artist", "")
dur = meta.get("duration", 0)
label = _truncate(title) if title else f.name
if artist and artist.lower() not in ("na", "unknown", ""):
label += f" -- {artist}"
if dur > 0:
label += f" ({_fmt_time(dur)})"
lines.append(f" {label} [{size_mb:.1f}MB]")
else:
lines.append(f" {f.name} ({size_mb:.1f}MB)")
await bot.long_reply(message, lines, label="kept files")
entries.sort(key=lambda m: m.get("id", 0))
lines = [f"Kept tracks ({len(entries)}):"]
for meta in entries:
kid = meta.get("id", "?")
title = meta.get("title", "")
artist = meta.get("artist", "")
dur = meta.get("duration", 0)
filename = meta.get("filename", "")
label = _truncate(title) if title else filename
if artist and artist.lower() not in ("na", "unknown", ""):
label += f" -- {artist}"
if dur > 0:
label += f" ({_fmt_time(dur)})"
# Show file size if file exists
fpath = _MUSIC_DIR / filename if filename else None
size = ""
if fpath and fpath.is_file():
size = f" [{fpath.stat().st_size / (1024 * 1024):.1f}MB]"
lines.append(f" #{kid} {label}{size}")
await bot.long_reply(message, lines, label="kept tracks")
# -- Plugin lifecycle --------------------------------------------------------