feat: add YouTube search to !play and fix NA URL fallback

Non-URL input (e.g. !play classical music) searches YouTube for 10
results and picks one randomly. Also fixes --flat-playlist returning
"NA" as the URL for single videos by falling back to the original
input URL.
This commit is contained in:
user
2026-02-21 23:52:01 +01:00
parent 6b7d733650
commit e4e1e219f0
4 changed files with 67 additions and 5 deletions

View File

@@ -548,6 +548,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
``` ```
!play <url|playlist> # Play audio (YouTube, SoundCloud, etc.) !play <url|playlist> # Play audio (YouTube, SoundCloud, etc.)
!play <playlist-url> # Playlist tracks expanded into queue !play <playlist-url> # Playlist tracks expanded into queue
!play classical music # YouTube search, random pick from top 10
!stop # Stop playback, clear queue !stop # Stop playback, clear queue
!skip # Skip current track !skip # Skip current track
!queue # Show queue !queue # Show queue

View File

@@ -1566,6 +1566,7 @@ and voice transmission.
``` ```
!play <url|playlist> Play audio or add to queue (playlists expanded) !play <url|playlist> Play audio or add to queue (playlists expanded)
!play <query> Search YouTube, play a random result
!stop Stop playback, clear queue !stop Stop playback, clear queue
!skip Skip current track !skip Skip current track
!queue Show queue !queue Show queue
@@ -1576,6 +1577,8 @@ and voice transmission.
``` ```
- Queue holds up to 50 tracks - 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 - Playlists are expanded into individual tracks; excess tracks are
truncated at the queue limit truncated at the queue limit
- Volume changes ramp smoothly over ~200ms (no abrupt jumps) - Volume changes ramp smoothly over ~200ms (no abrupt jumps)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import random
import subprocess import subprocess
from dataclasses import dataclass 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() + "..." 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]]: 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. """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): for i in range(0, len(lines) - 1, 2):
track_url = lines[i].strip() track_url = lines[i].strip()
track_title = lines[i + 1].strip() track_title = lines[i + 1].strip()
if track_url: # --flat-playlist prints "NA" for single videos (no extraction)
tracks.append((track_url, track_title or track_url)) 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)] return tracks if tracks else [(url, url)]
except Exception: except Exception:
return [(url, url)] return [(url, url)]
@@ -126,12 +134,13 @@ def _ensure_loop(bot) -> None:
# -- Commands ---------------------------------------------------------------- # -- Commands ----------------------------------------------------------------
@command("play", help="Music: !play <url|playlist>") @command("play", help="Music: !play <url|query>")
async def cmd_play(bot, message): async def cmd_play(bot, message):
"""Play a URL or add to queue if already playing. """Play a URL or add to queue if already playing.
Usage: Usage:
!play <url> Play audio from URL (YouTube, SoundCloud, etc.) !play <url> Play audio from URL (YouTube, SoundCloud, etc.)
!play <query> Search YouTube and play the first result
Playlists are expanded into individual tracks. If the queue is nearly Playlists are expanded into individual tracks. If the queue is nearly
full, only as many tracks as will fit are enqueued. 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) parts = message.text.split(None, 1)
if len(parts) < 2: if len(parts) < 2:
await bot.reply(message, "Usage: !play <url>") await bot.reply(message, "Usage: !play <url|query>")
return return
url = parts[1].strip() url = parts[1].strip()
is_search = not _is_url(url)
if is_search:
url = f"ytsearch10:{url}"
ps = _ps(bot) ps = _ps(bot)
if len(ps["queue"]) >= _MAX_QUEUE: if len(ps["queue"]) >= _MAX_QUEUE:
@@ -156,6 +168,10 @@ async def cmd_play(bot, message):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
resolved = await loop.run_in_executor(None, _resolve_tracks, url, remaining) 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 was_idle = ps["current"] is None
requester = message.nick or "?" requester = message.nick or "?"
added = 0 added = 0

View File

@@ -134,6 +134,25 @@ class TestPlayCommand:
assert len(ps["queue"]) == 1 assert len(ps["queue"]) == 1
assert ps["queue"][0].title == "Test Track" 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): def test_play_shows_queued_when_busy(self):
bot = _FakeBot() bot = _FakeBot()
ps = _mod._ps(bot) ps = _mod._ps(bot)
@@ -354,6 +373,21 @@ class TestMusicHelpers:
assert len(result) == 80 assert len(result) == 80
assert result.endswith("...") 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 # TestPlaylistExpansion
@@ -431,6 +465,14 @@ class TestPlaylistExpansion:
tracks = _mod._resolve_tracks("https://example.com/v1") tracks = _mod._resolve_tracks("https://example.com/v1")
assert tracks == [("https://example.com/v1", "Single Video")] 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): def test_resolve_tracks_playlist(self):
"""Subprocess returning multiple url+title pairs.""" """Subprocess returning multiple url+title pairs."""
result = MagicMock() result = MagicMock()