diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index c72ba92..a826011 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -548,6 +548,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops. ``` !play # Play audio (YouTube, SoundCloud, etc.) !play # Playlist tracks expanded into queue +!play classical music # YouTube search, random pick from top 10 !stop # Stop playback, clear queue !skip # Skip current track !queue # Show queue diff --git a/docs/USAGE.md b/docs/USAGE.md index 38da56e..f011140 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1566,6 +1566,7 @@ and voice transmission. ``` !play Play audio or add to queue (playlists expanded) +!play Search YouTube, play a random result !stop Stop playback, clear queue !skip Skip current track !queue Show queue @@ -1576,6 +1577,8 @@ and voice transmission. ``` - Queue holds up to 50 tracks +- Non-URL input is treated as a YouTube search; 10 results are fetched + and one is picked randomly - Playlists are expanded into individual tracks; excess tracks are truncated at the queue limit - Volume changes ramp smoothly over ~200ms (no abrupt jumps) diff --git a/plugins/music.py b/plugins/music.py index fa04cc1..ce9b87d 100644 --- a/plugins/music.py +++ b/plugins/music.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +import random import subprocess from dataclasses import dataclass @@ -51,6 +52,11 @@ def _truncate(text: str, max_len: int = _MAX_TITLE_LEN) -> str: return text[: max_len - 3].rstrip() + "..." +def _is_url(text: str) -> bool: + """Check if text looks like a URL rather than a search query.""" + return text.startswith(("http://", "https://", "ytsearch:")) + + 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. @@ -73,8 +79,10 @@ def _resolve_tracks(url: str, max_tracks: int = _MAX_QUEUE) -> list[tuple[str, s for i in range(0, len(lines) - 1, 2): track_url = lines[i].strip() track_title = lines[i + 1].strip() - if track_url: - tracks.append((track_url, track_title or track_url)) + # --flat-playlist prints "NA" for single videos (no extraction) + if not track_url or track_url == "NA": + track_url = url + tracks.append((track_url, track_title or track_url)) return tracks if tracks else [(url, url)] except Exception: return [(url, url)] @@ -126,12 +134,13 @@ def _ensure_loop(bot) -> None: # -- Commands ---------------------------------------------------------------- -@command("play", help="Music: !play ") +@command("play", help="Music: !play ") async def cmd_play(bot, message): """Play a URL or add to queue if already playing. Usage: - !play Play audio from URL (YouTube, SoundCloud, etc.) + !play Play audio from URL (YouTube, SoundCloud, etc.) + !play Search YouTube and play the first result Playlists are expanded into individual tracks. If the queue is nearly full, only as many tracks as will fit are enqueued. @@ -142,10 +151,13 @@ 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() + is_search = not _is_url(url) + if is_search: + url = f"ytsearch10:{url}" ps = _ps(bot) if len(ps["queue"]) >= _MAX_QUEUE: @@ -156,6 +168,10 @@ async def cmd_play(bot, message): loop = asyncio.get_running_loop() resolved = await loop.run_in_executor(None, _resolve_tracks, url, remaining) + # Search: pick one random result instead of enqueuing all + if is_search and len(resolved) > 1: + resolved = [random.choice(resolved)] + was_idle = ps["current"] is None requester = message.nick or "?" added = 0 diff --git a/tests/test_music.py b/tests/test_music.py index 6fe3363..0ecaccf 100644 --- a/tests/test_music.py +++ b/tests/test_music.py @@ -134,6 +134,25 @@ class TestPlayCommand: assert len(ps["queue"]) == 1 assert ps["queue"][0].title == "Test Track" + def test_play_search_query(self): + bot = _FakeBot() + msg = _Msg(text="!play classical music") + tracks = [ + ("https://youtube.com/watch?v=a", "Result 1"), + ("https://youtube.com/watch?v=b", "Result 2"), + ("https://youtube.com/watch?v=c", "Result 3"), + ] + with patch.object(_mod, "_resolve_tracks", return_value=tracks) as mock_rt: + with patch.object(_mod, "_ensure_loop"): + asyncio.run(_mod.cmd_play(bot, msg)) + # Should prepend ytsearch10: for non-URL input + mock_rt.assert_called_once() + assert mock_rt.call_args[0][0] == "ytsearch10:classical music" + # Should pick one random result, not enqueue all + ps = _mod._ps(bot) + assert len(ps["queue"]) == 1 + assert any("Playing" in r for r in bot.replied) + def test_play_shows_queued_when_busy(self): bot = _FakeBot() ps = _mod._ps(bot) @@ -354,6 +373,21 @@ class TestMusicHelpers: assert len(result) == 80 assert result.endswith("...") + def test_is_url_http(self): + assert _mod._is_url("https://youtube.com/watch?v=abc") is True + + def test_is_url_plain_http(self): + assert _mod._is_url("http://example.com") is True + + def test_is_url_ytsearch(self): + assert _mod._is_url("ytsearch:classical music") is True + + def test_is_url_search_query(self): + assert _mod._is_url("classical music") is False + + def test_is_url_single_word(self): + assert _mod._is_url("jazz") is False + # --------------------------------------------------------------------------- # TestPlaylistExpansion @@ -431,6 +465,14 @@ class TestPlaylistExpansion: tracks = _mod._resolve_tracks("https://example.com/v1") assert tracks == [("https://example.com/v1", "Single Video")] + def test_resolve_tracks_na_url_fallback(self): + """--flat-playlist prints NA for single videos; use original URL.""" + result = MagicMock() + result.stdout = "NA\nSingle Video\n" + with patch("subprocess.run", return_value=result): + tracks = _mod._resolve_tracks("https://example.com/v1") + assert tracks == [("https://example.com/v1", "Single Video")] + def test_resolve_tracks_playlist(self): """Subprocess returning multiple url+title pairs.""" result = MagicMock()