diff --git a/plugins/flaskpaste.py b/plugins/flaskpaste.py index 3343d57..1fa0832 100644 --- a/plugins/flaskpaste.py +++ b/plugins/flaskpaste.py @@ -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", "") diff --git a/plugins/music.py b/plugins/music.py index c1841b3..5462e78 100644 --- a/plugins/music.py +++ b/plugins/music.py @@ -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 ") + await bot.reply(message, "Usage: !play ") 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 -------------------------------------------------------- diff --git a/src/derp/mumble.py b/src/derp/mumble.py index efa6e85..eed53b1 100644 --- a/src/derp/mumble.py +++ b/src/derp/mumble.py @@ -45,9 +45,13 @@ def _strip_html(text: str) -> str: return html.unescape(_TAG_RE.sub("", text)) +_URL_RE = re.compile(r'(https?://[^\s<>&]+)') + + def _escape_html(text: str) -> str: - """Escape text for Mumble HTML messages.""" - return html.escape(text, quote=False) + """Escape text for Mumble HTML messages, auto-linking URLs.""" + escaped = html.escape(text, quote=False) + return _URL_RE.sub(r'\1', escaped) def _shell_quote(s: str) -> str: @@ -522,6 +526,7 @@ class MumbleBot: seek: float = 0.0, progress: list | None = None, fade_step=None, + fade_in: bool = False, ) -> None: """Stream audio from URL through yt-dlp|ffmpeg to voice channel. @@ -538,6 +543,8 @@ class MumbleBot: current frame count each frame. ``fade_step`` is an optional callable returning a float or None; when non-None it overrides the default ramp step for fast fades (e.g. skip/stop). + ``fade_in`` starts playback from silence and ramps up to the + target volume over ~0.8s. """ if self._mumble is None: return @@ -561,7 +568,12 @@ class MumbleBot: ) _max_step = 0.005 # max volume change per frame (~4s full ramp) - _cur_vol = _get_vol() + _fade_in_target = _get_vol() + _cur_vol = 0.0 if fade_in else _fade_in_target + # Fade-in: constant step to ramp linearly over ~5s (250 frames) + _fade_in_total = int(5.0 / 0.02) if fade_in else 0 + _fade_in_frames = _fade_in_total + _fade_in_step = (_fade_in_target / _fade_in_total) if _fade_in_total else 0 _was_feeding = True # track connected/disconnected transitions frames = 0 @@ -593,17 +605,21 @@ class MumbleBot: target = _get_vol() step = _max_step - if fade_step is not None: + if _fade_in_frames > 0: + step = _fade_in_step + _fade_in_frames -= 1 + elif fade_step is not None: fs = fade_step() if fs: step = fs - if _cur_vol == target: - # Fast path: flat scaling + diff = target - _cur_vol + if abs(diff) < 0.0001: + # Close enough -- flat scaling (no ramp artifacts) if target != 1.0: pcm = _scale_pcm(pcm, target) + _cur_vol = target else: # Ramp toward target, clamped to step per frame - diff = target - _cur_vol if abs(diff) <= step: next_vol = target elif diff > 0: diff --git a/tests/test_music.py b/tests/test_music.py index 44219c5..d8ff378 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -1134,7 +1134,12 @@ class TestKeepCommand: ) ps["current"] = track msg = _Msg(text="!keep") - asyncio.run(_mod.cmd_keep(bot, msg)) + music_dir = tmp_path / "kept" + music_dir.mkdir() + meta = {"title": "t", "artist": "", "duration": 0} + with patch.object(_mod, "_MUSIC_DIR", music_dir), \ + patch.object(_mod, "_fetch_metadata", return_value=meta): + asyncio.run(_mod.cmd_keep(bot, msg)) assert track.keep is True assert any("Keeping" in r for r in bot.replied) @@ -1151,23 +1156,27 @@ class TestKeepCommand: class TestKeptCommand: - def test_kept_empty(self, tmp_path): + def test_kept_empty(self): bot = _FakeBot() - with patch.object(_mod, "_MUSIC_DIR", tmp_path / "empty"): - msg = _Msg(text="!kept") - asyncio.run(_mod.cmd_kept(bot, msg)) - assert any("No kept files" in r for r in bot.replied) + msg = _Msg(text="!kept") + asyncio.run(_mod.cmd_kept(bot, msg)) + assert any("No kept tracks" in r for r in bot.replied) - def test_kept_lists_files(self, tmp_path): + def test_kept_lists_tracks(self, tmp_path): bot = _FakeBot() music_dir = tmp_path / "music" music_dir.mkdir() (music_dir / "abc123.opus").write_bytes(b"x" * 1024) + bot.state.set("music", "keep:1", json.dumps({ + "title": "Test Track", "artist": "", "duration": 0, + "filename": "abc123.opus", "id": 1, + })) with patch.object(_mod, "_MUSIC_DIR", music_dir): msg = _Msg(text="!kept") asyncio.run(_mod.cmd_kept(bot, msg)) - assert any("Kept files" in r for r in bot.replied) - assert any("abc123.opus" in r for r in bot.replied) + assert any("Kept tracks" in r for r in bot.replied) + assert any("#1" in r for r in bot.replied) + assert any("Test Track" in r for r in bot.replied) def test_kept_clear(self, tmp_path): bot = _FakeBot() @@ -1175,11 +1184,13 @@ class TestKeptCommand: music_dir.mkdir() (music_dir / "abc123.opus").write_bytes(b"audio") (music_dir / "def456.webm").write_bytes(b"audio") + bot.state.set("music", "keep:1", json.dumps({"id": 1})) with patch.object(_mod, "_MUSIC_DIR", music_dir): msg = _Msg(text="!kept clear") asyncio.run(_mod.cmd_kept(bot, msg)) assert any("Deleted 2 file(s)" in r for r in bot.replied) assert not list(music_dir.iterdir()) + assert bot.state.get("music", "keep:1") is None def test_kept_non_mumble(self): bot = _FakeBot(mumble=False) @@ -1533,7 +1544,7 @@ class TestFadeState: class TestKeepMetadata: def test_keep_stores_metadata(self, tmp_path): - """!keep stores metadata JSON in bot.state.""" + """!keep stores metadata JSON in bot.state keyed by ID.""" bot = _FakeBot() ps = _mod._ps(bot) f = tmp_path / "abc123.opus" @@ -1545,15 +1556,19 @@ class TestKeepMetadata: ps["current"] = track msg = _Msg(text="!keep") meta = {"title": "My Song", "artist": "Artist", "duration": 195.0} - with patch.object(_mod, "_fetch_metadata", return_value=meta): + music_dir = tmp_path / "kept" + music_dir.mkdir() + with patch.object(_mod, "_fetch_metadata", return_value=meta), \ + patch.object(_mod, "_MUSIC_DIR", music_dir): asyncio.run(_mod.cmd_keep(bot, msg)) assert track.keep is True - raw = bot.state.get("music", "keep:abc123.opus") + raw = bot.state.get("music", "keep:1") assert raw is not None stored = json.loads(raw) assert stored["title"] == "My Song" assert stored["artist"] == "Artist" assert stored["duration"] == 195.0 + assert stored["id"] == 1 assert any("My Song" in r for r in bot.replied) assert any("Artist" in r for r in bot.replied) assert any("3:15" in r for r in bot.replied) @@ -1571,7 +1586,10 @@ class TestKeepMetadata: ps["current"] = track msg = _Msg(text="!keep") meta = {"title": "Song", "artist": "NA", "duration": 60.0} - with patch.object(_mod, "_fetch_metadata", return_value=meta): + music_dir = tmp_path / "kept" + music_dir.mkdir() + with patch.object(_mod, "_fetch_metadata", return_value=meta), \ + patch.object(_mod, "_MUSIC_DIR", music_dir): asyncio.run(_mod.cmd_keep(bot, msg)) # Should not contain "NA" as artist assert not any("NA" in r and "--" in r for r in bot.replied) @@ -1584,13 +1602,14 @@ class TestKeepMetadata: class TestKeptMetadata: def test_kept_shows_metadata(self, tmp_path): - """!kept displays metadata from bot.state when available.""" + """!kept displays metadata from bot.state.""" bot = _FakeBot() music_dir = tmp_path / "music" music_dir.mkdir() (music_dir / "abc123.opus").write_bytes(b"x" * 2048) - bot.state.set("music", "keep:abc123.opus", json.dumps({ + bot.state.set("music", "keep:1", json.dumps({ "title": "Cool Song", "artist": "DJ Test", "duration": 225.0, + "filename": "abc123.opus", "id": 1, })) with patch.object(_mod, "_MUSIC_DIR", music_dir): msg = _Msg(text="!kept") @@ -1598,31 +1617,34 @@ class TestKeptMetadata: assert any("Cool Song" in r for r in bot.replied) assert any("DJ Test" in r for r in bot.replied) assert any("3:45" in r for r in bot.replied) + assert any("#1" in r for r in bot.replied) - def test_kept_fallback_no_metadata(self, tmp_path): - """!kept falls back to filename when no metadata stored.""" + def test_kept_fallback_no_title(self): + """!kept falls back to filename when no title in metadata.""" bot = _FakeBot() - music_dir = tmp_path / "music" - music_dir.mkdir() - (music_dir / "xyz789.webm").write_bytes(b"x" * 1024) - with patch.object(_mod, "_MUSIC_DIR", music_dir): - msg = _Msg(text="!kept") - asyncio.run(_mod.cmd_kept(bot, msg)) + bot.state.set("music", "keep:1", json.dumps({ + "title": "", "artist": "", "duration": 0, + "filename": "xyz789.webm", "id": 1, + })) + msg = _Msg(text="!kept") + asyncio.run(_mod.cmd_kept(bot, msg)) assert any("xyz789.webm" in r for r in bot.replied) def test_kept_clear_removes_metadata(self, tmp_path): - """!kept clear also removes stored metadata.""" + """!kept clear also removes stored metadata and resets ID.""" bot = _FakeBot() music_dir = tmp_path / "music" music_dir.mkdir() (music_dir / "abc123.opus").write_bytes(b"audio") - bot.state.set("music", "keep:abc123.opus", json.dumps({ - "title": "Song", "artist": "", "duration": 0, + bot.state.set("music", "keep:1", json.dumps({ + "title": "Song", "artist": "", "duration": 0, "id": 1, })) + bot.state.set("music", "keep_next_id", "2") with patch.object(_mod, "_MUSIC_DIR", music_dir): msg = _Msg(text="!kept clear") asyncio.run(_mod.cmd_kept(bot, msg)) - assert bot.state.get("music", "keep:abc123.opus") is None + assert bot.state.get("music", "keep:1") is None + assert bot.state.get("music", "keep_next_id") is None assert any("Deleted 1 file(s)" in r for r in bot.replied)