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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user