Compare commits

...

7 Commits

Author SHA1 Message Date
user
6e1c32f22c fix: add pythonpath to pytest config for CI plugin imports
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Successful in 23s
CI / test (3.11) (push) Failing after 2m48s
CI / test (3.13) (push) Successful in 2m50s
CI / test (3.12) (push) Successful in 2m52s
CI / build (push) Has been skipped
The patch("plugins._musicbrainz...") calls in tests need plugins/
on sys.path. Works locally from repo root but fails in CI containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:57:00 +01:00
user
f470d6d958 fix: lint errors in test_musicbrainz (unused import, line length)
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Successful in 23s
CI / test (3.11) (push) Failing after 2m43s
CI / test (3.13) (push) Failing after 2m47s
CI / test (3.12) (push) Failing after 2m50s
CI / build (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:40:43 +01:00
user
28f4c63e99 fix: delegate !similar playback to music bot, not calling bot
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
When merlin ran !similar, music operations (fade, queue, play loop)
targeted merlin instead of derp, causing audio to play over derp's
stream. New _music_bot() helper resolves the DJ bot via active state
or plugin filter config, so playback always routes to derp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:39:38 +01:00
user
dd4c6b95b7 feat: rework !similar to build and play discovery playlists
Default !similar now discovers similar artists/tracks, resolves each
against YouTube in parallel via ThreadPoolExecutor, fades out current
playback, and starts the new playlist. Old display behavior moves to
!similar list subcommand.

New helpers: _search_queries() normalizes Last.fm/MB results into search
strings, _resolve_playlist() resolves queries to _Track objects in
parallel. Falls back to display mode when music plugin not loaded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:56:51 +01:00
user
b658053711 docs: describe 3-level help paste hierarchy
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
USAGE.md: add Detailed Help section with format example.
CHEATSHEET.md: note hierarchy layout in help description.
TASKS.md: update sprint with hierarchy task and test count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:22:49 +01:00
user
20c1d738be fix: 3-level hierarchy in help paste output
Some checks failed
CI / gitleaks (push) Failing after 4s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
Plugin name at column 0, command at indent 4, docstring at indent 8.
Single-command paste keeps command at 0, docstring at 4.
Only paste when actual docstring content exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:21:10 +01:00
user
ecfa7cea39 fix: indent docstring body in help paste output
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 23s
CI / test (3.11) (push) Has been skipped
CI / test (3.12) (push) Has been skipped
CI / test (3.13) (push) Has been skipped
CI / build (push) Has been skipped
Command name stays flush-left, docstring lines indented 4 spaces.
Plugin descriptions in the full reference also indented under headers.
Adds test_help_paste_body_indented to verify formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:12:10 +01:00
9 changed files with 602 additions and 228 deletions

View File

@@ -1,15 +1,29 @@
# derp - Tasks # derp - Tasks
## Current Sprint -- Enhanced Help with FlaskPaste (2026-02-23) ## Current Sprint -- Discovery Playlists (2026-02-23)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `!similar` default: discover + resolve + play (playlist mode) |
| P0 | [x] | `!similar list` subcommand for display-only (old default) |
| P0 | [x] | `_search_queries()` normalizes Last.fm/MB results to search strings |
| P0 | [x] | `_resolve_playlist()` parallel yt-dlp resolution via ThreadPoolExecutor |
| P1 | [x] | Playback transition: fade out, clear queue, load playlist, fade in |
| P1 | [x] | Fallback to display when music plugin not loaded |
| P1 | [x] | Tests: 11 new cases (81 total in test_lastfm.py, 1949 suite total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) |
## Previous Sprint -- Enhanced Help with FlaskPaste (2026-02-23)
| Pri | Status | Task | | Pri | Status | Task |
|-----|--------|------| |-----|--------|------|
| P0 | [x] | `!help <cmd>` pastes docstring detail via FlaskPaste, appends URL | | P0 | [x] | `!help <cmd>` pastes docstring detail via FlaskPaste, appends URL |
| P0 | [x] | `!help <plugin>` pastes all plugin command details | | P0 | [x] | `!help <plugin>` pastes all plugin command details |
| P0 | [x] | `!help` (no args) pastes full reference grouped by plugin | | P0 | [x] | `!help` (no args) pastes full reference grouped by plugin |
| P1 | [x] | 3-level hierarchy: plugin (col 0), command (indent 4), docstring (indent 8) |
| P1 | [x] | Graceful fallback when FlaskPaste not loaded or paste fails | | P1 | [x] | Graceful fallback when FlaskPaste not loaded or paste fails |
| P1 | [x] | Helper functions: `_build_cmd_detail`, `_paste` | | P1 | [x] | Helper functions: `_build_cmd_detail(indent=)`, `_paste` |
| P1 | [x] | Tests: 5 new cases in test_core.py (9 total) | | P1 | [x] | Tests: 7 new cases in test_core.py (11 total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) | | P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) |
## Previous Sprint -- MusicBrainz Fallback (2026-02-23) ## Previous Sprint -- MusicBrainz Fallback (2026-02-23)
@@ -318,6 +332,7 @@
| Date | Task | | Date | Task |
|------|------| |------|------|
| 2026-02-23 | `!similar` discovery playlists (parallel resolve, fade transition, list subcommand) |
| 2026-02-23 | Enhanced `!help` with FlaskPaste detail pages (docstrings, grouped reference) | | 2026-02-23 | Enhanced `!help` with FlaskPaste detail pages (docstrings, grouped reference) |
| 2026-02-23 | MusicBrainz fallback for `!similar` and `!tags` (no Last.fm key required) | | 2026-02-23 | MusicBrainz fallback for `!similar` and `!tags` (no Last.fm key required) |
| 2026-02-22 | v2.3.0 (voice profiles, rubberband FX, multi-bot, self-mute, container tools) | | 2026-02-22 | v2.3.0 (voice profiles, rubberband FX, multi-bot, self-mute, container tools) |

View File

@@ -95,9 +95,10 @@ Profile data written on graceful shutdown when bot runs with `--cprofile`.
!h # Shorthand (any unambiguous prefix works) !h # Shorthand (any unambiguous prefix works)
``` ```
Detailed help output (docstrings, subcommands, examples) is pasted to Detailed help is pasted to FlaskPaste and appended as a URL. Paste
FlaskPaste and appended as a URL. Falls back gracefully if FlaskPaste layout uses a 3-level hierarchy: `[plugin]` at column 0, `!command`
is not loaded. at indent 4, docstring body at indent 8. Falls back gracefully if
FlaskPaste is not loaded.
## Permission Tiers ## Permission Tiers
@@ -612,14 +613,17 @@ Mumble-only: `!play` replies with error on other adapters, others silently no-op
## Music Discovery ## Music Discovery
``` ```
!similar # Similar to currently playing track !similar # Discover + play similar to current track
!similar <artist> # Similar artists to named artist !similar <artist> # Discover + play similar to named artist
!similar play # Queue a random similar track !similar list # Show similar (display only)
!similar play <artist># Queue similar track for named artist !similar list <artist># Show similar for named artist
!tags # Genre tags for current artist !tags # Genre tags for current artist
!tags <artist> # Genre tags for named artist !tags <artist> # Genre tags for named artist
``` ```
Default `!similar` builds a playlist: discovers similar artists, resolves
via YouTube in parallel, fades out current, plays the new playlist.
`!similar list` shows results without playing.
Uses Last.fm when API key is set; falls back to MusicBrainz automatically. Uses Last.fm when API key is set; falls back to MusicBrainz automatically.
Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var. Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var.

View File

@@ -206,6 +206,30 @@ unchanged. The server name is derived from the hostname automatically.
| `!cron <add\|del\|list>` | Scheduled command execution (admin) | | `!cron <add\|del\|list>` | Scheduled command execution (admin) |
| `!webhook` | Show webhook listener status (admin) | | `!webhook` | Show webhook listener status (admin) |
### Detailed Help (FlaskPaste)
`!help` pastes detailed reference output to FlaskPaste and appends the
URL. The paste uses a 3-level indentation hierarchy:
```
[plugin-name]
Plugin description.
!command -- short help
Full docstring with usage, subcommands,
and examples.
!other -- another command
Its docstring here.
```
- `!help` (no args) -- pastes the full reference grouped by plugin
- `!help <cmd>` -- pastes the command's docstring (command at column 0)
- `!help <plugin>` -- pastes all commands under the plugin header
If FlaskPaste is not loaded or the paste fails, the short IRC reply
still works -- no regression.
### Command Shorthand ### Command Shorthand
Commands can be abbreviated to any unambiguous prefix: Commands can be abbreviated to any unambiguous prefix:
@@ -1767,19 +1791,22 @@ key is configured; falls back to MusicBrainz automatically (no key
required). required).
``` ```
!similar Similar to currently playing track !similar Discover + play similar to current track
!similar <artist> Similar artists to named artist !similar <artist> Discover + play similar to named artist
!similar play Queue a random similar track !similar list Show similar (display only)
!similar play <artist> Queue a similar track for named artist !similar list <artist> Show similar for named artist
!tags Genre tags for currently playing artist !tags Genre tags for currently playing artist
!tags <artist> Genre tags for named artist !tags <artist> Genre tags for named artist
``` ```
- Default `!similar` builds a discovery playlist: finds similar artists/tracks,
resolves each against YouTube in parallel, fades out current playback, and
starts the new playlist
- `!similar list` shows results without playing (old default behavior)
- When an API key is set, Last.fm is tried first for richer results - When an API key is set, Last.fm is tried first for richer results
- When no API key is set (or Last.fm returns empty), MusicBrainz is - When no API key is set (or Last.fm returns empty), MusicBrainz is
used as a fallback (artist search -> tags -> similar recordings) used as a fallback (artist search -> tags -> similar recordings)
- `!similar play` picks a random result and delegates to `!play` - Without the music plugin loaded, `!similar` falls back to display mode
(searches YouTube for the artist + title)
- MusicBrainz rate limit: 1 request/second (handled automatically) - MusicBrainz rate limit: 1 request/second (handled automatically)
Configuration (optional): Configuration (optional):

View File

@@ -8,15 +8,21 @@ from derp import __version__
from derp.plugin import command from derp.plugin import command
def _build_cmd_detail(handler, prefix: str) -> str: def _build_cmd_detail(handler, prefix: str, indent: int = 0) -> str:
"""Extract and format a command's docstring into a detail block.""" """Format command header + docstring at the given indent level.
doc = textwrap.dedent(handler.callback.__doc__ or "").strip()
if not doc: Command name sits at *indent*, docstring body at *indent + 4*.
return "" Returns just the header line when no docstring exists.
header = f"{prefix}{handler.name}" """
pad = " " * indent
header = f"{pad}{prefix}{handler.name}"
if handler.help: if handler.help:
header += f" -- {handler.help}" header += f" -- {handler.help}"
return f"{header}\n{doc}" doc = textwrap.dedent(handler.callback.__doc__ or "").strip()
if not doc:
return header
indented = textwrap.indent(doc, " " * (indent + 4))
return f"{header}\n{indented}"
async def _paste(bot, text: str) -> str | None: async def _paste(bot, text: str) -> str | None:
@@ -53,8 +59,8 @@ async def cmd_help(bot, message):
if handler and bot._plugin_allowed(handler.plugin, channel): if handler and bot._plugin_allowed(handler.plugin, channel):
help_text = handler.help or "No help available." help_text = handler.help or "No help available."
reply = f"{bot.prefix}{name} -- {help_text}" reply = f"{bot.prefix}{name} -- {help_text}"
detail = _build_cmd_detail(handler, bot.prefix) if (handler.callback.__doc__ or "").strip():
if detail: detail = _build_cmd_detail(handler, bot.prefix)
url = await _paste(bot, detail) url = await _paste(bot, detail)
if url: if url:
reply += f" | {url}" reply += f" | {url}"
@@ -73,15 +79,20 @@ async def cmd_help(bot, message):
if cmds: if cmds:
lines.append(f"Commands: {', '.join(bot.prefix + c for c in cmds)}") lines.append(f"Commands: {', '.join(bot.prefix + c for c in cmds)}")
reply = " | ".join(lines) reply = " | ".join(lines)
# Build detail block for all plugin commands # Build detail: plugin header + indented commands
blocks = [] section_lines = [f"[{name}]"]
if desc:
section_lines.append(f" {desc}")
section_lines.append("")
has_detail = False
for cmd_name in cmds: for cmd_name in cmds:
h = bot.registry.commands[cmd_name] h = bot.registry.commands[cmd_name]
blk = _build_cmd_detail(h, bot.prefix) section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4))
if blk: section_lines.append("")
blocks.append(blk) if (h.callback.__doc__ or "").strip():
if blocks: has_detail = True
url = await _paste(bot, "\n\n".join(blocks)) if has_detail:
url = await _paste(bot, "\n".join(section_lines).rstrip())
if url: if url:
reply += f" | {url}" reply += f" | {url}"
await bot.reply(message, reply) await bot.reply(message, reply)
@@ -102,27 +113,21 @@ async def cmd_help(bot, message):
for cmd_name in names: for cmd_name in names:
h = bot.registry.commands[cmd_name] h = bot.registry.commands[cmd_name]
plugins.setdefault(h.plugin, []).append(cmd_name) plugins.setdefault(h.plugin, []).append(cmd_name)
blocks = [] sections = []
for plugin_name in sorted(plugins): for plugin_name in sorted(plugins):
mod = bot.registry._modules.get(plugin_name) mod = bot.registry._modules.get(plugin_name)
desc = (getattr(mod, "__doc__", "") or "").split("\n")[0].strip() if mod else "" desc = (getattr(mod, "__doc__", "") or "").split("\n")[0].strip() if mod else ""
header = f"[{plugin_name}]" section_lines = [f"[{plugin_name}]"]
if desc: if desc:
header += f" {desc}" section_lines.append(f" {desc}")
cmd_lines = [] section_lines.append("")
for cmd_name in plugins[plugin_name]: for cmd_name in plugins[plugin_name]:
h = bot.registry.commands[cmd_name] h = bot.registry.commands[cmd_name]
detail = _build_cmd_detail(h, bot.prefix) section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4))
if detail: section_lines.append("")
cmd_lines.append(detail) sections.append("\n".join(section_lines).rstrip())
else: if sections:
line = f"{bot.prefix}{cmd_name}" url = await _paste(bot, "\n\n".join(sections))
if h.help:
line += f" -- {h.help}"
cmd_lines.append(line)
blocks.append(header + "\n" + "\n\n".join(cmd_lines))
if blocks:
url = await _paste(bot, "\n\n".join(blocks))
if url: if url:
reply += f" | {url}" reply += f" | {url}"
await bot.reply(message, reply) await bot.reply(message, reply)

View File

@@ -105,26 +105,41 @@ def _parse_title(raw_title: str) -> tuple[str, str]:
return ("", raw_title) return ("", raw_title)
def _current_meta(bot) -> tuple[str, str]: def _music_bot(bot):
"""Extract artist and title from the currently playing track. """Return the bot instance that owns music playback.
Returns (artist, title). Either or both may be empty. Checks the calling bot first, then peer bots via the shared registry.
Tries the music plugin's current track metadata on this bot first, Returns the first bot with an active music state, or ``bot`` as fallback.
then checks peer bots (shared registry) so extra bots can see what
the music bot is playing.
""" """
# Check this bot first, then peers
candidates = [bot] candidates = [bot]
for peer in getattr(getattr(bot, "registry", None), "_bots", {}).values(): for peer in getattr(getattr(bot, "registry", None), "_bots", {}).values():
if peer is not bot: if peer is not bot:
candidates.append(peer) candidates.append(peer)
for b in candidates: for b in candidates:
music_ps = getattr(b, "_pstate", {}).get("music", {}) music_ps = getattr(b, "_pstate", {}).get("music", {})
current = music_ps.get("current") if music_ps.get("current") is not None or music_ps.get("queue"):
if current is not None: return b
raw_title = current.title or "" # No active music state -- prefer a bot that allows the music plugin
if raw_title: for b in candidates:
return _parse_title(raw_title) only = getattr(b, "_only_plugins", None)
if only is not None and "music" in only:
return b
return bot
def _current_meta(bot) -> tuple[str, str]:
"""Extract artist and title from the currently playing track.
Returns (artist, title). Either or both may be empty.
Checks the music bot (via ``_music_bot``) for now-playing metadata.
"""
mb = _music_bot(bot)
music_ps = getattr(mb, "_pstate", {}).get("music", {})
current = music_ps.get("current")
if current is not None:
raw_title = current.title or ""
if raw_title:
return _parse_title(raw_title)
return ("", "") return ("", "")
@@ -192,30 +207,141 @@ def _fmt_match(m: float | str) -> str:
return "" return ""
# -- Playlist helpers --------------------------------------------------------
def _search_queries(similar: list[dict], similar_artists: list[dict],
mb_results: list[dict], limit: int = 10) -> list[str]:
"""Normalize discovery results into YouTube search strings.
Processes track results (``{artist: {name}, name}``), artist results
(``{name}``), and MusicBrainz results (``{artist, title}``) into a
flat list of search query strings, up to *limit*.
"""
queries: list[str] = []
for t in similar:
a = t.get("artist", {}).get("name", "")
n = t.get("name", "")
q = f"{a} {n}".strip()
if q:
queries.append(q)
for a in similar_artists:
name = a.get("name", "")
if name:
queries.append(name)
for r in mb_results:
q = f"{r.get('artist', '')} {r.get('title', '')}".strip()
if q:
queries.append(q)
return queries[:limit]
async def _resolve_playlist(bot, queries: list[str],
requester: str) -> list:
"""Resolve search queries to Track objects via yt-dlp in parallel.
Uses the music plugin's ``_resolve_tracks`` and ``_Track`` to build
a playlist. Returns a list of ``_Track`` objects (empty on failure).
"""
music_mod = bot.registry._modules.get("music")
if not music_mod:
return []
loop = asyncio.get_running_loop()
resolve = music_mod._resolve_tracks
Track = music_mod._Track
pool = _get_yt_pool()
async def _resolve_one(query: str):
try:
pairs = await loop.run_in_executor(
pool, resolve, f"ytsearch1:{query}", 1,
)
if pairs:
url, title = pairs[0]
return Track(url=url, title=title, requester=requester)
except Exception:
log.debug("lastfm: resolve failed for %r", query)
return None
tasks = [_resolve_one(q) for q in queries]
results = await asyncio.gather(*tasks)
return [t for t in results if t is not None]
_yt_pool = None
def _get_yt_pool():
"""Lazy-init a shared ThreadPoolExecutor for yt-dlp resolution."""
global _yt_pool
if _yt_pool is None:
from concurrent.futures import ThreadPoolExecutor
_yt_pool = ThreadPoolExecutor(max_workers=4)
return _yt_pool
async def _display_results(bot, message, similar: list[dict],
similar_artists: list[dict],
mb_results: list[dict],
artist: str, title: str) -> None:
"""Format and display discovery results (list mode)."""
if similar:
lines = [f"Similar to {artist} - {title}:"]
for t in similar[:8]:
t_artist = t.get("artist", {}).get("name", "")
t_name = t.get("name", "?")
match = _fmt_match(t.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {t_artist} - {t_name}{suffix}")
await bot.long_reply(message, lines, label="similar tracks")
return
search_artist = artist or title
if similar_artists:
lines = [f"Similar to {search_artist}:"]
for a in similar_artists[:8]:
name = a.get("name", "?")
match = _fmt_match(a.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {name}{suffix}")
await bot.long_reply(message, lines, label="similar artists")
return
if mb_results:
lines = [f"Similar to {search_artist}:"]
for r in mb_results[:8]:
lines.append(f" {r['artist']} - {r['title']}")
await bot.long_reply(message, lines, label="similar tracks")
return
await bot.reply(message, f"No similar artists found for '{search_artist}'")
# -- Commands ---------------------------------------------------------------- # -- Commands ----------------------------------------------------------------
@command("similar", help="Music: !similar [artist|play] -- find similar music") @command("similar", help="Music: !similar [list] [artist] -- discover & play similar music")
async def cmd_similar(bot, message): async def cmd_similar(bot, message):
"""Find similar artists or tracks. """Discover and play similar music.
Usage: Usage:
!similar Similar to currently playing track !similar Discover + play similar to current track
!similar <artist> Similar artists to named artist !similar <artist> Discover + play similar to named artist
!similar play Queue a random similar track !similar list Show similar (display only)
!similar play <artist> Queue a similar track for named artist !similar list <artist> Show similar for named artist
""" """
api_key = _get_api_key(bot) api_key = _get_api_key(bot)
parts = message.text.split(None, 2) parts = message.text.split(None, 2)
# !similar play [artist] # !similar list [artist]
play_mode = len(parts) >= 2 and parts[1].lower() == "play" list_mode = len(parts) >= 2 and parts[1].lower() == "list"
if play_mode: if list_mode:
query = parts[2].strip() if len(parts) > 2 else "" query = parts[2].strip() if len(parts) > 2 else ""
else: else:
query = parts[1].strip() if len(parts) > 1 else "" query = parts[1].strip() if len(parts) > 1 else ""
import asyncio
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Resolve artist from query or current track # Resolve artist from query or current track
@@ -229,8 +355,8 @@ async def cmd_similar(bot, message):
return return
# -- Last.fm path -- # -- Last.fm path --
similar = [] similar: list[dict] = []
similar_artists = [] similar_artists: list[dict] = []
if api_key: if api_key:
# Try track-level similarity first if we have both artist + title # Try track-level similarity first if we have both artist + title
if artist and title: if artist and title:
@@ -245,7 +371,7 @@ async def cmd_similar(bot, message):
) )
# -- MusicBrainz fallback -- # -- MusicBrainz fallback --
mb_results = [] mb_results: list[dict] = []
if not similar and not similar_artists: if not similar and not similar_artists:
search_artist = artist or title search_artist = artist or title
try: try:
@@ -267,77 +393,47 @@ async def cmd_similar(bot, message):
except Exception: except Exception:
log.warning("lastfm: MusicBrainz fallback failed", exc_info=True) log.warning("lastfm: MusicBrainz fallback failed", exc_info=True)
# -- Track-level results (Last.fm) -- # Nothing found at all
if similar: if not similar and not similar_artists and not mb_results:
if play_mode:
pick = random.choice(similar[:10])
pick_artist = pick.get("artist", {}).get("name", "")
pick_title = pick.get("name", "")
search = f"{pick_artist} {pick_title}".strip()
if not search:
await bot.reply(message, "No playable result found")
return
message.text = f"!play {search}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
lines = [f"Similar to {artist} - {title}:"]
for t in similar[:8]:
t_artist = t.get("artist", {}).get("name", "")
t_name = t.get("name", "?")
match = _fmt_match(t.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {t_artist} - {t_name}{suffix}")
await bot.long_reply(message, lines, label="similar tracks")
return
# -- Artist-level results (Last.fm) --
if similar_artists:
search_artist = artist or title search_artist = artist or title
if play_mode: await bot.reply(message, f"No similar artists found for '{search_artist}'")
pick = random.choice(similar_artists[:10])
pick_name = pick.get("name", "")
if not pick_name:
await bot.reply(message, "No playable result found")
return
message.text = f"!play {pick_name}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
lines = [f"Similar to {search_artist}:"]
for a in similar_artists[:8]:
name = a.get("name", "?")
match = _fmt_match(a.get("match", ""))
suffix = f" ({match})" if match else ""
lines.append(f" {name}{suffix}")
await bot.long_reply(message, lines, label="similar artists")
return return
# -- MusicBrainz results -- # -- List mode (display only) --
if mb_results: if list_mode:
search_artist = artist or title await _display_results(bot, message, similar, similar_artists,
if play_mode: mb_results, artist, title)
pick = random.choice(mb_results[:10])
search = f"{pick['artist']} {pick['title']}".strip()
message.text = f"!play {search}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
lines = [f"Similar to {search_artist}:"]
for r in mb_results[:8]:
lines.append(f" {r['artist']} - {r['title']}")
await bot.long_reply(message, lines, label="similar tracks")
return return
# Nothing found # -- Play mode (default): build playlist and transition --
search_artist = artist or title search_artist = artist or title
await bot.reply(message, f"No similar artists found for '{search_artist}'") queries = _search_queries(similar, similar_artists, mb_results, limit=10)
if not queries:
await bot.reply(message, f"No similar artists found for '{search_artist}'")
return
music_mod = bot.registry._modules.get("music")
if not music_mod:
# No music plugin -- fall back to display
await _display_results(bot, message, similar, similar_artists,
mb_results, artist, title)
return
await bot.reply(message, f"Discovering similar to {search_artist}...")
tracks = await _resolve_playlist(bot, queries, message.nick)
if not tracks:
await bot.reply(message, "No playable tracks resolved")
return
# Transition on the music bot (derp), not the calling bot (may be merlin)
dj = _music_bot(bot)
ps = music_mod._ps(dj)
await music_mod._fade_and_cancel(dj, duration=3.0)
ps["queue"].clear()
ps["current"] = None
ps["queue"] = list(tracks)
music_mod._ensure_loop(dj, fade_in=True)
await bot.reply(message, f"Playing {len(tracks)} similar tracks for {search_artist}")
@command("tags", help="Music: !tags [artist] -- show genre tags") @command("tags", help="Music: !tags [artist] -- show genre tags")
@@ -353,7 +449,6 @@ async def cmd_tags(bot, message):
parts = message.text.split(None, 1) parts = message.text.split(None, 1)
query = parts[1].strip() if len(parts) > 1 else "" query = parts[1].strip() if len(parts) > 1 else ""
import asyncio
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
if query: if query:

View File

@@ -22,6 +22,7 @@ where = ["src"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff] [tool.ruff]
line-length = 99 line-length = 99

View File

@@ -130,10 +130,19 @@ def _cmd_no_doc():
pass pass
def _make_fp_module(url="https://paste.example.com/abc/raw"): def _make_fp_module(url="https://paste.example.com/abc/raw", capture=None):
"""Create a fake flaskpaste module that returns a fixed URL.""" """Create a fake flaskpaste module that returns a fixed URL.
If capture is a list, appended paste content is stored there.
"""
mod = types.ModuleType("flaskpaste") mod = types.ModuleType("flaskpaste")
mod.create_paste = lambda bot, text: url
def _create(bot, text):
if capture is not None:
capture.append(text)
return url
mod.create_paste = _create
return mod return mod
@@ -227,3 +236,53 @@ class TestHelpCommand:
assert len(bot.replied) == 1 assert len(bot.replied) == 1
assert "!widget -- Manage widgets" in bot.replied[0] assert "!widget -- Manage widgets" in bot.replied[0]
assert "https://" not in bot.replied[0] assert "https://" not in bot.replied[0]
def test_help_cmd_paste_hierarchy(self):
"""Single-command paste: header at 0, docstring at 4."""
bot = _FakeBot()
pastes: list[str] = []
bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes)
bot.registry.commands["widget"] = _FakeHandler(
name="widget", callback=_cmd_with_doc,
help="Manage widgets", plugin="widgets",
)
msg = _Msg(text="!help widget")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(pastes) == 1
lines = pastes[0].split("\n")
# Level 0: command header flush-left
assert lines[0] == "!widget -- Manage widgets"
# Level 1: docstring lines indented 4 spaces
for line in lines[1:]:
if line.strip():
assert line.startswith(" "), f"not indented: {line!r}"
def test_help_list_paste_hierarchy(self):
"""Full reference paste: plugin at 0, command at 4, doc at 8."""
bot = _FakeBot()
pastes: list[str] = []
bot.registry._modules["flaskpaste"] = _make_fp_module(capture=pastes)
mod = types.ModuleType("core")
mod.__doc__ = "Core plugin."
bot.registry._modules["core"] = mod
bot.registry.commands["state"] = _FakeHandler(
name="state", callback=_cmd_with_doc,
help="Inspect state", plugin="core",
)
msg = _Msg(text="!help")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(pastes) == 1
text = pastes[0]
lines = text.split("\n")
# Level 0: plugin header
assert lines[0] == "[core]"
# Level 1: plugin description
assert lines[1] == " Core plugin."
# Blank separator
assert lines[2] == ""
# Level 1: command header at indent 4
assert lines[3] == " !state -- Inspect state"
# Level 2: docstring at indent 8
for line in lines[4:]:
if line.strip():
assert line.startswith(" "), f"not at indent 8: {line!r}"

View File

@@ -2,7 +2,6 @@
import asyncio import asyncio
import importlib.util import importlib.util
import json
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -21,14 +20,17 @@ _spec.loader.exec_module(_mod)
class _FakeRegistry: class _FakeRegistry:
def __init__(self): def __init__(self):
self._modules: dict = {} self._modules: dict = {}
self._bots: dict = {}
class _FakeBot: class _FakeBot:
def __init__(self, *, api_key: str = "test-key"): def __init__(self, *, api_key: str = "test-key", name: str = "derp"):
self.replied: list[str] = [] self.replied: list[str] = []
self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {} self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {}
self._pstate: dict = {} self._pstate: dict = {}
self._only_plugins: set[str] | None = None
self.registry = _FakeRegistry() self.registry = _FakeRegistry()
self._username = name
async def reply(self, message, text: str) -> None: async def reply(self, message, text: str) -> None:
self.replied.append(text) self.replied.append(text)
@@ -363,11 +365,54 @@ class TestFmtMatch:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestSearchQueries:
def test_track_results(self):
similar = [
{"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"},
{"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"},
]
result = _mod._search_queries(similar, [], [])
assert result == ["Band A Track X", "Band B Track Y"]
def test_artist_results(self):
artists = [{"name": "Deftones"}, {"name": "APC"}]
result = _mod._search_queries([], artists, [])
assert result == ["Deftones", "APC"]
def test_mb_results(self):
mb = [{"artist": "MB Band", "title": "MB Song"}]
result = _mod._search_queries([], [], mb)
assert result == ["MB Band MB Song"]
def test_mixed_sources(self):
"""Track results come first, then artist, then MB."""
similar = [{"name": "T1", "artist": {"name": "A1"}}]
artists = [{"name": "A2"}]
mb = [{"artist": "MB", "title": "S1"}]
result = _mod._search_queries(similar, artists, mb)
assert result == ["A1 T1", "A2", "MB S1"]
def test_limit(self):
artists = [{"name": f"Band {i}"} for i in range(20)]
result = _mod._search_queries([], artists, [], limit=5)
assert len(result) == 5
def test_skips_empty(self):
similar = [{"name": "", "artist": {"name": ""}}]
artists = [{"name": ""}]
mb = [{"artist": "", "title": ""}]
result = _mod._search_queries(similar, artists, mb)
assert result == []
def test_empty_inputs(self):
assert _mod._search_queries([], [], []) == []
class TestCmdSimilar: class TestCmdSimilar:
def test_no_api_key_mb_fallback(self): def test_no_api_key_mb_list_fallback(self):
"""No API key falls back to MusicBrainz for similar results.""" """No API key + list mode falls back to MusicBrainz for results."""
bot = _FakeBot(api_key="") bot = _FakeBot(api_key="")
msg = _Msg(text="!similar Tool") msg = _Msg(text="!similar list Tool")
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}] mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
with patch.dict("os.environ", {}, clear=True), \ with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist", patch("plugins._musicbrainz.mb_search_artist",
@@ -390,40 +435,16 @@ class TestCmdSimilar:
asyncio.run(_mod.cmd_similar(bot, msg)) asyncio.run(_mod.cmd_similar(bot, msg))
assert any("No similar artists" in r for r in bot.replied) assert any("No similar artists" in r for r in bot.replied)
def test_no_api_key_play_mode(self):
"""No API key + play mode delegates to cmd_play via MB results."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar play Tool")
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
play_called = []
async def fake_play(b, m):
play_called.append(m.text)
music_mod = MagicMock()
music_mod.cmd_play = fake_play
bot.registry._modules["music"] = music_mod
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value="mbid-123"), \
patch("plugins._musicbrainz.mb_artist_tags",
return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert len(play_called) == 1
assert "MB Band" in play_called[0]
def test_no_artist_nothing_playing(self): def test_no_artist_nothing_playing(self):
bot = _FakeBot() bot = _FakeBot()
msg = _Msg(text="!similar") msg = _Msg(text="!similar")
asyncio.run(_mod.cmd_similar(bot, msg)) asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Nothing playing" in r for r in bot.replied) assert any("Nothing playing" in r for r in bot.replied)
def test_artist_query_shows_similar(self): def test_list_artist_shows_similar(self):
"""!similar list <artist> shows similar artists (display only)."""
bot = _FakeBot() bot = _FakeBot()
msg = _Msg(text="!similar Tool") msg = _Msg(text="!similar list Tool")
with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", with patch.object(_mod, "_get_similar_artists",
return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]): return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]):
@@ -431,26 +452,26 @@ class TestCmdSimilar:
assert any("Similar to Tool" in r for r in bot.replied) assert any("Similar to Tool" in r for r in bot.replied)
assert any("Artist B" in r for r in bot.replied) assert any("Artist B" in r for r in bot.replied)
def test_track_level_similarity(self): def test_list_track_level(self):
"""When current track has artist + title, tries track similarity first.""" """!similar list with track results shows track similarity."""
bot = _FakeBot() bot = _FakeBot()
bot._pstate["music"] = { bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"), "current": _FakeTrack(title="Tool - Lateralus"),
} }
msg = _Msg(text="!similar") msg = _Msg(text="!similar list")
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"] tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
with patch.object(_mod, "_get_similar_tracks", return_value=tracks): with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
asyncio.run(_mod.cmd_similar(bot, msg)) asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Similar to Tool - Lateralus" in r for r in bot.replied) assert any("Similar to Tool - Lateralus" in r for r in bot.replied)
assert any("Track X" in r for r in bot.replied) assert any("Track X" in r for r in bot.replied)
def test_falls_back_to_artist(self): def test_list_falls_back_to_artist(self):
"""Falls back to artist similarity when no track results.""" """!similar list falls back to artist similarity when no track results."""
bot = _FakeBot() bot = _FakeBot()
bot._pstate["music"] = { bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"), "current": _FakeTrack(title="Tool - Lateralus"),
} }
msg = _Msg(text="!similar") msg = _Msg(text="!similar list")
artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"] artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"]
with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", return_value=artists): with patch.object(_mod, "_get_similar_artists", return_value=artists):
@@ -465,71 +486,178 @@ class TestCmdSimilar:
asyncio.run(_mod.cmd_similar(bot, msg)) asyncio.run(_mod.cmd_similar(bot, msg))
assert any("No similar artists" in r for r in bot.replied) assert any("No similar artists" in r for r in bot.replied)
def test_play_mode_artist(self): def test_list_match_score_displayed(self):
"""!similar play delegates to music cmd_play."""
bot = _FakeBot() bot = _FakeBot()
msg = _Msg(text="!similar play Tool") msg = _Msg(text="!similar list Tool")
artists = [{"name": "Deftones", "match": "0.8"}]
play_called = []
async def fake_play(b, m):
play_called.append(m.text)
music_mod = MagicMock()
music_mod.cmd_play = fake_play
bot.registry._modules["music"] = music_mod
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", return_value=artists):
asyncio.run(_mod.cmd_similar(bot, msg))
assert len(play_called) == 1
assert "Deftones" in play_called[0]
def test_play_mode_track(self):
"""!similar play with track-level results delegates to cmd_play."""
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
}
msg = _Msg(text="!similar play")
tracks = [{"name": "Schism", "artist": {"name": "Tool"}, "match": "0.9"}]
play_called = []
async def fake_play(b, m):
play_called.append(m.text)
music_mod = MagicMock()
music_mod.cmd_play = fake_play
bot.registry._modules["music"] = music_mod
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert len(play_called) == 1
assert "Tool" in play_called[0]
assert "Schism" in play_called[0]
def test_match_score_displayed(self):
bot = _FakeBot()
msg = _Msg(text="!similar Tool")
artists = [{"name": "Deftones", "match": "0.85"}] artists = [{"name": "Deftones", "match": "0.85"}]
with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", return_value=artists): with patch.object(_mod, "_get_similar_artists", return_value=artists):
asyncio.run(_mod.cmd_similar(bot, msg)) asyncio.run(_mod.cmd_similar(bot, msg))
assert any("85%" in r for r in bot.replied) assert any("85%" in r for r in bot.replied)
def test_current_track_no_separator(self): def test_list_current_track_no_separator(self):
"""Title without separator uses whole title as search artist.""" """Title without separator uses whole title as search artist."""
bot = _FakeBot() bot = _FakeBot()
bot._pstate["music"] = { bot._pstate["music"] = {
"current": _FakeTrack(title="Lateralus"), "current": _FakeTrack(title="Lateralus"),
} }
msg = _Msg(text="!similar") msg = _Msg(text="!similar list")
artists = [{"name": "APC", "match": "0.7"}] artists = [{"name": "APC", "match": "0.7"}]
with patch.object(_mod, "_get_similar_tracks", return_value=[]): with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", return_value=artists): with patch.object(_mod, "_get_similar_artists", return_value=artists):
asyncio.run(_mod.cmd_similar(bot, msg)) asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Similar to Lateralus" in r for r in bot.replied) assert any("Similar to Lateralus" in r for r in bot.replied)
def test_builds_playlist(self):
"""Default !similar builds playlist and starts playback."""
bot = _FakeBot()
msg = _Msg(text="!similar Tool")
artists = [{"name": "Deftones", "match": "0.8"}]
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
music_mod = MagicMock()
music_mod._ps.return_value = {
"queue": [], "current": None, "task": None,
}
music_mod._fade_and_cancel = AsyncMock()
music_mod._ensure_loop = MagicMock()
bot.registry._modules["music"] = music_mod
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
patch.object(_mod, "_get_similar_artists", return_value=artists), \
patch.object(_mod, "_resolve_playlist",
return_value=fake_tracks):
asyncio.run(_mod.cmd_similar(bot, msg))
music_mod._fade_and_cancel.assert_called_once()
music_mod._ensure_loop.assert_called_once()
ps = music_mod._ps(bot)
assert ps["queue"] == fake_tracks
assert any("Playing 1 similar" in r for r in bot.replied)
def test_builds_playlist_from_current_track(self):
"""!similar with no args discovers from currently playing track."""
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
}
msg = _Msg(text="!similar")
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
music_mod = MagicMock()
music_mod._ps.return_value = {
"queue": [], "current": None, "task": None,
}
music_mod._fade_and_cancel = AsyncMock()
music_mod._ensure_loop = MagicMock()
bot.registry._modules["music"] = music_mod
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
patch.object(_mod, "_resolve_playlist",
return_value=fake_tracks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Playing 1 similar" in r for r in bot.replied)
assert any("Tool" in r for r in bot.replied)
def test_no_music_mod_falls_back_to_display(self):
"""Without music plugin, !similar falls back to display mode."""
bot = _FakeBot()
msg = _Msg(text="!similar Tool")
artists = [{"name": "Deftones", "match": "0.8"}]
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", return_value=artists):
asyncio.run(_mod.cmd_similar(bot, msg))
# Falls back to display since no music module registered
assert any("Similar to Tool" in r for r in bot.replied)
assert any("Deftones" in r for r in bot.replied)
def test_no_playable_tracks_resolved(self):
"""Shows error when resolution returns empty."""
bot = _FakeBot()
msg = _Msg(text="!similar Tool")
artists = [{"name": "Deftones", "match": "0.8"}]
music_mod = MagicMock()
music_mod._ps.return_value = {"queue": [], "current": None}
bot.registry._modules["music"] = music_mod
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
patch.object(_mod, "_get_similar_artists", return_value=artists), \
patch.object(_mod, "_resolve_playlist",
return_value=[]):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("No playable tracks" in r for r in bot.replied)
def test_mb_builds_playlist(self):
"""MB fallback results build playlist in play mode."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar Tool")
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")]
music_mod = MagicMock()
music_mod._ps.return_value = {
"queue": [], "current": None, "task": None,
}
music_mod._fade_and_cancel = AsyncMock()
music_mod._ensure_loop = MagicMock()
bot.registry._modules["music"] = music_mod
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value="mbid-123"), \
patch("plugins._musicbrainz.mb_artist_tags",
return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks), \
patch.object(_mod, "_resolve_playlist",
return_value=fake_tracks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Playing 1 similar" in r for r in bot.replied)
def test_cross_bot_delegates_to_music_bot(self):
"""When merlin runs !similar, playback starts on derp (music bot)."""
# derp = music bot (has active music state)
derp = _FakeBot(api_key="test-key", name="derp")
derp._only_plugins = {"music", "voice"}
derp._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
"queue": [],
}
# merlin = calling bot (no music plugin)
merlin = _FakeBot(api_key="test-key", name="merlin")
shared_reg = _FakeRegistry()
shared_reg._bots = {"derp": derp, "merlin": merlin}
derp.registry = shared_reg
merlin.registry = shared_reg
artists = [{"name": "Deftones", "match": "0.8"}]
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
music_mod = MagicMock()
music_mod._ps.return_value = {
"queue": [], "current": None, "task": None,
}
music_mod._fade_and_cancel = AsyncMock()
music_mod._ensure_loop = MagicMock()
shared_reg._modules["music"] = music_mod
msg = _Msg(text="!similar Tool")
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
patch.object(_mod, "_get_similar_artists", return_value=artists), \
patch.object(_mod, "_resolve_playlist",
return_value=fake_tracks):
asyncio.run(_mod.cmd_similar(merlin, msg))
# Music ops must target derp, not merlin
music_mod._fade_and_cancel.assert_called_once_with(derp, duration=3.0)
music_mod._ensure_loop.assert_called_once_with(derp, fade_in=True)
music_mod._ps.assert_called_with(derp)
# Reply still goes through merlin (the bot the user talked to)
assert any("Playing 1 similar" in r for r in merlin.replied)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestCmdTags # TestCmdTags
@@ -609,6 +737,46 @@ class TestCmdTags:
assert any("Lateralus:" in r for r in bot.replied) assert any("Lateralus:" in r for r in bot.replied)
# ---------------------------------------------------------------------------
# TestMusicBot
# ---------------------------------------------------------------------------
class TestMusicBot:
def test_returns_self_when_active(self):
"""Returns calling bot when it has active music state."""
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
assert _mod._music_bot(bot) is bot
def test_returns_peer_with_active_state(self):
"""Returns peer bot that has music playing."""
derp = _FakeBot(name="derp")
derp._pstate["music"] = {"current": _FakeTrack(title="X"), "queue": []}
merlin = _FakeBot(name="merlin")
reg = _FakeRegistry()
reg._bots = {"derp": derp, "merlin": merlin}
derp.registry = reg
merlin.registry = reg
assert _mod._music_bot(merlin) is derp
def test_falls_back_to_only_plugins(self):
"""Returns bot with only_plugins containing 'music' when no active state."""
derp = _FakeBot(name="derp")
derp._only_plugins = {"music", "voice"}
merlin = _FakeBot(name="merlin")
reg = _FakeRegistry()
reg._bots = {"derp": derp, "merlin": merlin}
derp.registry = reg
merlin.registry = reg
assert _mod._music_bot(merlin) is derp
def test_returns_self_as_last_resort(self):
"""Returns calling bot when no peer has music state or filters."""
bot = _FakeBot()
assert _mod._music_bot(bot) is bot
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TestParseTitle # TestParseTitle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -4,7 +4,6 @@ import importlib.util
import json import json
import sys import sys
import time import time
from io import BytesIO
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
# -- Load module directly ---------------------------------------------------- # -- Load module directly ----------------------------------------------------
@@ -305,6 +304,7 @@ class TestMbFindSimilarRecordings:
"Tool", ["rock", "metal", "prog"], "Tool", ["rock", "metal", "prog"],
) )
call_args = mock_req.call_args call_args = mock_req.call_args
query = call_args[1]["query"] if "query" in (call_args[1] or {}) else call_args[0][1].get("query", "") args = call_args[1] or {}
query = args.get("query") or call_args[0][1].get("query", "")
# Verify the query contains both tag references # Verify the query contains both tag references
assert "rock" in query or "metal" in query assert "rock" in query or "metal" in query