Compare commits

...

28 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
user
ef18915807 docs: update help command docs for FlaskPaste detail pages
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
USAGE.md, CHEATSHEET.md, and TASKS.md updated to reflect enhanced
!help output that pastes detailed docstrings via FlaskPaste.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:04:36 +01:00
user
69976196cd feat: paste detailed help via FlaskPaste for !help command
!help <cmd> now pastes the command's docstring and appends the URL.
!help <plugin> pastes detail for all plugin commands.
!help (no args) pastes a full reference grouped by plugin.
Falls back gracefully when flaskpaste is not loaded or paste fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:51:55 +01:00
user
c851e82990 fix: let extra bots see peer music state for !similar/!tags
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
_current_meta now checks registry._bots for peer bot music state
when the current bot has nothing playing. Fixes merlin reporting
"Nothing playing" while derp is actively streaming.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:17:29 +01:00
user
ad12843e75 docs: add music discovery, autoplay, and Mumble admin sections
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: music discovery (!similar, !tags), autoplay discovery
  config, Mumble admin (!mu) command reference
- CHEATSHEET.md: music discovery and Mumble admin quick reference
- ROADMAP.md: mark v2.4.0 as done, add MB fallback + !mu + autoplay
- TODO.md: mark music discovery and performance items as done
- PROJECT.md: update plugin categories table
- TASKS.md: close open doc items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:04:03 +01:00
user
62a4191200 docs: update TASKS.md with MusicBrainz fallback sprint
Some checks failed
CI / gitleaks (push) Failing after 3s
CI / lint (push) Failing after 25s
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
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 22:00:02 +01:00
user
135a3791e2 feat: add MusicBrainz fallback to !similar and !tags commands
Some checks failed
CI / gitleaks (push) Failing after 2s
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
Remove early return on missing Last.fm API key. Both commands now
fall back to MusicBrainz (mb_search_artist -> mb_artist_tags ->
mb_find_similar_recordings) when no API key is configured or when
Last.fm returns empty results. Same pattern used by discover_similar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:56:39 +01:00
user
a87f75adf1 feat: add Mumble server admin plugin (!mu)
Admin-only command with subcommand dispatch for server management:
kick, ban, mute/unmute, deafen/undeafen, move, users, channels,
mkchan, rmchan, rename, desc. Auto-loads on merlin via except_plugins.
2026-02-23 21:44:38 +01:00
user
da9ed51c74 feat: auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
Every Nth autoplay pick (configurable via discover_ratio), query Last.fm
for similar tracks. When Last.fm has no key or returns nothing, fall back
to MusicBrainz tag-based recording search (no API key needed). Discovered
tracks are resolved via yt-dlp and deduplicated within the session. If
discovery fails, the kept-deck shuffle continues as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:19:41 +01:00
user
56f6b9822f fix: revert pymumble protocol version patch that broke audio
Reporting version 1.5.0 to mumble-server caused it to expect the newer
audio frame format (terminator bit on opus length varint). pymumble
1.6.1 does not implement this, so the server silently dropped every
UDPTunnel audio packet. Revert to native 1.2.4 -- audio works, only
side effect is a cosmetic ChannelListener warning.

Also remove --cprofile from docker-compose command.
2026-02-23 18:51:53 +01:00
user
09880624d5 fix: add bot name to stream_audio log lines
Multi-bot setups need to know which bot is streaming. Prefixes all
stream_audio log messages with [username] for clarity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:15:46 +01:00
user
3c475107e3 refactor: simplify audition to single-bot playback
derp now handles both listening and speaking, so audition no longer
needs cross-bot lookup or dual-play through merlin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:07:08 +01:00
user
b3006b02e2 feat: auto-register mumble bots on first connect
Self-register with the Mumble server on initial connection so both
derp and merlin get persistent identities (cert-bound user_id).
Skips registration on reconnect or if already registered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:32:36 +01:00
user
8b504364a9 fix: patch pymumble protocol version and harden mumble connections
- Update reported protocol version from 1.2.4 to 1.5.0 so modern
  Murmur servers treat PyMumble as a compatible client
- Fix OS string to report actual platform instead of "PyMumble 1.6.1"
  (was shown as [Invalid] by Murmur)
- Raise pymumble reconnect retry interval to 15s to prevent autoban
  when running multiple bots from the same IP
- Enable TCP keepalive on control socket (10s idle) to prevent NAT
  gateways from dropping long-lived connections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:28:57 +01:00
user
40c6bf8c53 feat: playlist import, show, and shuffle-on-load
Add !playlist import <name> <url> to resolve and save tracks from a URL
without queueing them. Add !playlist show <name> to display tracks in a
saved playlist via long_reply (auto-pastes on overflow). Add optional
'shuffle' keyword to !playlist load for randomized playback order.
2026-02-22 20:31:54 +01:00
user
a76d46b1de fix: graceful SIGTERM shutdown for IRC and Mumble bots
Send IRC QUIT before closing the socket so the server releases the nick
immediately. Wrap readline() in wait_for(timeout=2.0) so the _running
check triggers even when the server is quiet. Explicitly stop pymumble
in the signal handler to accelerate Mumble teardown. Together these
eliminate the 10s podman grace-period SIGKILL.
2026-02-22 20:31:49 +01:00
user
0ffddb8e41 fix: write cProfile to data/ volume for host access
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:02:14 +01:00
user
62b01c76f7 fix: reduce cProfile dump interval to 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:00:06 +01:00
user
e0db0ad567 fix: periodic cProfile dump every 60s (survives hard kills)
Replace cProfile.runctx with Profile() + daemon thread that dumps
stats to disk every 60 seconds. Final dump on clean exit. Profile
data no longer lost on container stop, OOM, or SIGKILL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:56:21 +01:00
user
c41035ceca test: add test_lastfm.py (50 cases)
Covers API helpers (_get_similar_artists, _get_top_tags,
_get_similar_tracks, _search_track), config resolution, metadata
extraction from track titles, match score formatting, and both
commands (!similar, !tags) including play mode delegation, fallback
from track to artist similarity, and current-track integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:52:16 +01:00
user
cd4124e07a fix: route alert YouTube/SearXNG through pooled urlopen
- YouTube InnerTube search: urllib.request.urlopen -> _urlopen (gets
  connection pooling + SOCKS5 proxy)
- SearXNG search: urllib.request.urlopen -> _urlopen(proxy=False)
  (local service, skip proxy, get pooling)
- Update 5 tests to patch _urlopen instead of urllib.request.urlopen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:39:38 +01:00
user
717bf59a05 feat: playlist save/load, queue durations, whisper bias, greet fix
- Move TTS greeting from mumble._play_greet to voice.on_connected
  (fires once on first connect, gated on _is_audio_ready)
- Add initial_prompt multipart field to Whisper STT for trigger word
  bias (auto-generated from trigger config, overridable)
- Enhanced !queue: elapsed/total on now-playing, per-track durations,
  footer with track count and total time
- New !playlist command: save/load/list/del named playlists via
  bot.state persistence (playlist:<name> keys)
- Fix duck floor test (1% -> 2% to match default change)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:23:03 +01:00
25 changed files with 3561 additions and 219 deletions

View File

@@ -28,10 +28,13 @@ CLI (argparse) -> Config (TOML) -> Bot (orchestrator)
| Category | Plugins | Purpose |
|----------|---------|---------|
| Core | core | Bot management, help, plugin lifecycle |
| OSINT | dns, crtsh | Reconnaissance and enumeration |
| OSINT | dns, crtsh, internetdb | Reconnaissance and enumeration |
| Red Team | revshell, encode, hash | Offensive tooling |
| OPSEC | defang | Safe IOC handling |
| Utility | cidr, example | Network tools, demo |
| Utility | cidr, rand, timer, remind | Network tools, scheduling |
| Music | music, lastfm | Mumble playback, discovery (Last.fm/MB) |
| Voice | voice, mumble_admin | STT/TTS, server admin |
| Subscriptions | rss, yt, twitch, alert | Feed monitoring, keyword alerts |
### Key Design Decisions

View File

@@ -179,11 +179,14 @@
- [x] Alias plugin (!alias add/del/list)
- [x] Container management tools (tools/build, start, stop, restart, nuke, logs, status)
## v2.4.0 -- Music Discovery + Performance
## v2.4.0 -- Music Discovery + Performance (done)
- [ ] Last.fm integration (artist.getSimilar, artist.getTopTags, track.getSimilar)
- [ ] `!similar` command (find similar artists, optionally queue via YouTube)
- [ ] `!tags` command (genre/style tags for current track)
- [x] Last.fm integration (artist.getSimilar, artist.getTopTags, track.getSimilar)
- [x] `!similar` command (find similar artists, optionally queue via YouTube)
- [x] `!tags` command (genre/style tags for current track)
- [x] MusicBrainz fallback for `!similar` and `!tags` (no API key required)
- [x] Auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
- [x] Mumble server admin plugin (`!mu` -- kick, ban, mute, move, channels)
- [x] Pause/unpause (`!pause` toggle, position tracking, stale re-download)
- [x] Autoplay continuous radio (random kept, silence-aware, cooldown between tracks)
- [x] Periodic resume persistence (10s interval, survives hard kills)

View File

@@ -1,6 +1,42 @@
# derp - Tasks
## Current Sprint -- Voice + Music UX (2026-02-22)
## 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 |
|-----|--------|------|
| P0 | [x] | `!help <cmd>` pastes docstring detail via FlaskPaste, appends URL |
| P0 | [x] | `!help <plugin>` pastes all plugin command details |
| 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] | Helper functions: `_build_cmd_detail(indent=)`, `_paste` |
| P1 | [x] | Tests: 7 new cases in test_core.py (11 total) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, TASKS.md) |
## Previous Sprint -- MusicBrainz Fallback (2026-02-23)
| Pri | Status | Task |
|-----|--------|------|
| P0 | [x] | `!similar` MusicBrainz fallback when no Last.fm API key or empty results |
| P0 | [x] | `!tags` MusicBrainz fallback when no Last.fm API key or empty results |
| P1 | [x] | `!similar play` works through MB fallback path |
| P1 | [x] | Tests: MB fallback for both commands (6 new cases, 70 total in test_lastfm.py) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md, ROADMAP.md, TODO.md, PROJECT.md) |
## Previous Sprint -- Voice + Music UX (2026-02-22)
| Pri | Status | Task |
|-----|--------|------|
@@ -9,8 +45,10 @@
| P0 | [x] | Instant packet-based ducking via pymumble sound callback (~20ms) |
| P0 | [x] | Duck floor raised to 2% (keep music audible during voice) |
| P0 | [x] | Strip leading punctuation from voice trigger remainder |
| P1 | [ ] | Queue display improvements (`!queue` shows position, duration, total time) |
| P1 | [ ] | Playlist save/load (`!playlist save <name>`, `!playlist load <name>`) |
| P0 | [x] | Fix greeting tests: move greet TTS to voice plugin `on_connected` |
| P0 | [x] | Whisper `initial_prompt` bias for trigger word recognition |
| P1 | [x] | Queue display improvements (`!queue` shows elapsed/duration, totals) |
| P1 | [x] | Playlist save/load/list/del (`!playlist save <name>`, etc.) |
| P2 | [ ] | Per-channel voice settings (different voice per Mumble channel) |
## Previous Sprint -- Performance: HTTP + Parsing (2026-02-22)
@@ -21,7 +59,7 @@
| P0 | [x] | `plugins/searx.py` -- route through `derp.http.urlopen(proxy=False)` |
| P1 | [x] | Connection pool: `preload_content=True` + `_PooledResponse` wrapper for connection reuse |
| P1 | [x] | Pool tuning: `num_pools=30, maxsize=8` (was 20/4) |
| P2 | [ ] | Audit remaining plugins for unnecessary proxy routing |
| P2 | [x] | Audit remaining plugins for unnecessary proxy routing |
## Previous Sprint -- Music Discovery via Last.fm (2026-02-22)
@@ -32,8 +70,8 @@
| P0 | [x] | `!similar play` -- queue a similar track via YouTube search |
| P1 | [x] | `!tags` command -- show genre/style tags for current or named track |
| P1 | [x] | Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var |
| P2 | [ ] | Tests: `test_lastfm.py` (API response mocking, command dispatch) |
| P2 | [ ] | Documentation update (USAGE.md, CHEATSHEET.md) |
| P2 | [x] | Tests: `test_lastfm.py` (50 cases: API helpers, metadata, commands) |
| P2 | [x] | Documentation update (USAGE.md, CHEATSHEET.md) |
## Previous Sprint -- v2.3.0 Mumble Voice + Multi-Bot (2026-02-22)
@@ -294,6 +332,9 @@
| 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 | 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-21 | v2.3.0 (pymumble rewrite, music playback, fades, seek, kept library) |
| 2026-02-17 | v1.2.3 (paste overflow with FlaskPaste integration) |

16
TODO.md
View File

@@ -132,9 +132,9 @@ is preserved in git history for reference.
## Performance
- [ ] Iterative `_extract_videos` in alert.py (51K recursive calls, 6.7s CPU)
- [ ] Bypass SOCKS5 for local services (FlaskPaste, SearXNG)
- [ ] Connection pool tuning (529 SOCKS connections per 25min session)
- [x] Iterative `_extract_videos` in alert.py (replaced 51K-deep recursion)
- [x] Bypass SOCKS5 for local services (SearXNG `proxy=False`)
- [x] Connection pool tuning (30 pools / 8 connections)
- [ ] Async HTTP client (aiohttp + aiohttp-socks) to avoid blocking executors
- [x] Connection pooling via urllib3 SOCKSProxyManager
- [x] Batch OG fetch via ThreadPoolExecutor
@@ -166,11 +166,11 @@ is preserved in git history for reference.
## Music Discovery
- [ ] Last.fm integration (API key, free tier)
- [ ] `!similar` command -- find similar artists/tracks via Last.fm
- [ ] `!tags` command -- show genre/style tags for current track
- [ ] Auto-queue similar tracks when autoplay has no kept tracks
- [ ] MusicBrainz fallback (no API key, 1 req/sec rate limit)
- [x] Last.fm integration (API key, free tier)
- [x] `!similar` command -- find similar artists/tracks via Last.fm
- [x] `!tags` command -- show genre/style tags for current track
- [x] Auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
- [x] MusicBrainz fallback for `!similar` and `!tags` (no API key required)
## Slack

View File

@@ -18,4 +18,4 @@ services:
- ./secrets:/app/secrets:ro,Z
environment:
- OPENROUTER_API_KEY
command: ["--verbose", "--cprofile"]
command: ["--verbose"]

View File

@@ -86,15 +86,20 @@ Profile data written on graceful shutdown when bot runs with `--cprofile`.
```
!ping # Pong
!help # List commands
!help <cmd> # Command help
!help <plugin> # Plugin description + commands
!help # List commands + paste full reference
!help <cmd> # Command help + paste docstring detail
!help <plugin> # Plugin info + paste command details
!version # Bot version
!uptime # Bot uptime
!echo <text> # Echo text back
!h # Shorthand (any unambiguous prefix works)
```
Detailed help is pasted to FlaskPaste and appended as a URL. Paste
layout uses a 3-level hierarchy: `[plugin]` at column 0, `!command`
at indent 4, docstring body at indent 8. Falls back gracefully if
FlaskPaste is not loaded.
## Permission Tiers
```
@@ -574,7 +579,7 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
!prev # Go back to previous track (fades out)
!seek 1:30 # Seek to position (also +30, -30)
!resume # Resume last stopped/skipped track
!queue # Show queue
!queue # Show queue (with durations + totals)
!queue <url> # Add to queue (alias for !play)
!np # Now playing
!volume # Show current volume
@@ -587,9 +592,13 @@ HTML stripped on receive, escaped on send. IRC-only commands are no-ops.
!duck # Show ducking status
!duck on # Enable voice ducking
!duck off # Disable voice ducking
!duck floor 5 # Set duck floor volume (0-100, default 1)
!duck floor 5 # Set duck floor volume (0-100, default 2)
!duck silence 20 # Set silence timeout seconds (default 15)
!duck restore 45 # Set restore ramp duration seconds (default 30)
!playlist save <name> # Save current + queued tracks as named playlist
!playlist load <name> # Append saved playlist to queue, start if idle
!playlist list # Show saved playlists with track counts
!playlist del <name> # Delete a saved playlist
```
Requires: `yt-dlp`, `ffmpeg`, `libopus` on the host.
@@ -601,6 +610,41 @@ Skip/stop/prev/seek fade out smoothly (~0.8s); volume ramps over ~1s.
Auto-resumes on reconnect if channel is silent (waits up to 60s for silence).
Mumble-only: `!play` replies with error on other adapters, others silently no-op.
## Music Discovery
```
!similar # Discover + play similar to current track
!similar <artist> # Discover + play similar to named artist
!similar list # Show similar (display only)
!similar list <artist># Show similar for named artist
!tags # Genre tags for current 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.
Config: `[lastfm] api_key` or `LASTFM_API_KEY` env var.
## Mumble Admin (admin)
```
!mu kick <user> [reason] # Kick user
!mu ban <user> [reason] # Ban user
!mu mute <user> # Server-mute
!mu unmute <user> # Remove server-mute
!mu deafen <user> # Server-deafen
!mu undeafen <user> # Remove server-deafen
!mu move <user> <channel> # Move user to channel
!mu users # List connected users
!mu channels # List channels
!mu mkchan <name> [parent] # Create channel
!mu rmchan <name> # Remove empty channel
!mu rename <old> <new> # Rename channel
!mu desc <channel> <text> # Set channel description
```
## Plugin Template
```python

View File

@@ -124,9 +124,9 @@ unchanged. The server name is derived from the hostname automatically.
| Command | Description |
|---------|-------------|
| `!ping` | Bot responds with "pong" |
| `!help` | List all available commands |
| `!help <cmd>` | Show help for a specific command |
| `!help <plugin>` | Show plugin description and its commands |
| `!help` | List all commands + paste full reference |
| `!help <cmd>` | Show help + paste detailed docstring |
| `!help <plugin>` | Show plugin description + paste command details |
| `!version` | Show bot version |
| `!uptime` | Show how long the bot has been running |
| `!echo <text>` | Echo back text (example plugin) |
@@ -206,6 +206,30 @@ unchanged. The server name is derived from the hostname automatically.
| `!cron <add\|del\|list>` | Scheduled command execution (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
Commands can be abbreviated to any unambiguous prefix:
@@ -1623,13 +1647,17 @@ and voice transmission.
!prev Go back to the previous track (fade-out)
!seek <offset> Seek to position (1:30, 90, +30, -30)
!resume Resume last stopped/skipped track from saved position
!queue Show queue
!queue Show queue (with durations + totals)
!queue <url> Add to queue (alias for !play)
!np Now playing
!volume [0-100] Get/set volume (persisted across restarts)
!keep Keep current track's audio file (with metadata)
!kept [rm <id>|clear|repair] List, remove, clear, or repair kept files
!testtone Play 3-second 440Hz test tone
!playlist save <name> Save current + queued tracks as named playlist
!playlist load <name> Append saved playlist to queue, start if idle
!playlist list Show saved playlists with track counts
!playlist del <name> Delete a saved playlist
```
- Queue holds up to 50 tracks
@@ -1712,7 +1740,7 @@ volume gradually restores to the user-set level in small steps.
!duck Show ducking status and settings
!duck on Enable voice ducking
!duck off Disable voice ducking
!duck floor <0-100> Set floor volume % (default: 1)
!duck floor <0-100> Set floor volume % (default: 2)
!duck silence <sec> Set silence timeout in seconds (default: 15)
!duck restore <sec> Set restore ramp duration in seconds (default: 30)
```
@@ -1756,6 +1784,56 @@ file (natural dedup).
missing (e.g. after a cleanup or volume mount issue)
- On cancel/error, files are not deleted (needed for `!resume`)
### Music Discovery
Find similar music and genre tags for artists. Uses Last.fm when an API
key is configured; falls back to MusicBrainz automatically (no key
required).
```
!similar Discover + play similar to current track
!similar <artist> Discover + play similar to named artist
!similar list Show similar (display only)
!similar list <artist> Show similar for named artist
!tags Genre tags for currently playing 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 no API key is set (or Last.fm returns empty), MusicBrainz is
used as a fallback (artist search -> tags -> similar recordings)
- Without the music plugin loaded, `!similar` falls back to display mode
- MusicBrainz rate limit: 1 request/second (handled automatically)
Configuration (optional):
```toml
[lastfm]
api_key = "" # Last.fm API key (or set LASTFM_API_KEY env var)
```
### Autoplay Discovery
During autoplay, the bot periodically discovers new tracks instead of
only playing from the kept library. Every Nth autoplay pick (configurable
via `discover_ratio`), it queries Last.fm or MusicBrainz for a track
similar to the last-played one. Discovered tracks are searched on YouTube
and queued automatically.
Configuration (optional):
```toml
[music]
autoplay = true # Enable autoplay (default: true)
autoplay_cooldown = 30 # Seconds between autoplay tracks (default: 30)
discover = true # Enable discovery during autoplay (default: true)
discover_ratio = 3 # Discover every Nth pick (default: 3)
```
### Extra Mumble Bots
Run additional bot identities on the same Mumble server. Each extra bot
@@ -1852,3 +1930,24 @@ Available voices:
To switch the active voice, set `piper_tts_voice` (e.g.
`fr_FR-siwis-medium`) and redeploy the TTS service.
### Mumble Server Admin (admin)
Manage Mumble users and channels via chat commands. All subcommands
require admin tier. Mumble-only (no-op on other adapters).
```
!mu kick <user> [reason] Kick user from server
!mu ban <user> [reason] Ban user from server
!mu mute <user> Server-mute user
!mu unmute <user> Remove server-mute
!mu deafen <user> Server-deafen user
!mu undeafen <user> Remove server-deafen
!mu move <user> <channel> Move user to channel
!mu users List connected users
!mu channels List server channels
!mu mkchan <name> [parent] Create channel (under parent or root)
!mu rmchan <name> Remove empty channel
!mu rename <old> <new> Rename channel
!mu desc <channel> <text> Set channel description
```

View File

@@ -2,6 +2,7 @@
1. pymumble: ssl.wrap_socket was removed in 3.13
2. opuslib: ctypes.util.find_library fails on musl-based distros
3. pymumble: close stale socket on reconnect
"""
import pathlib
@@ -65,5 +66,8 @@ new_init = """\
self.control_socket = None"""
assert old_init in src, "pymumble init_connection socket patch target not found"
p.write_text(src.replace(old_init, new_init))
src = src.replace(old_init, new_init)
print("pymumble reconnect socket patch applied")
p.write_text(src)

118
plugins/_musicbrainz.py Normal file
View File

@@ -0,0 +1,118 @@
"""MusicBrainz API helper for music discovery fallback.
Private module (underscore prefix) -- plugin loader skips it.
All functions are blocking; callers should run them in an executor.
"""
from __future__ import annotations
import json
import logging
import time
from urllib.request import Request
log = logging.getLogger(__name__)
_BASE = "https://musicbrainz.org/ws/2"
_UA = "derp-bot/2.0.0 (https://git.mymx.me/username/derp)"
# Rate limit: MusicBrainz requires max 1 request/second.
# We use 1.1s between calls to stay well within limits.
_RATE_INTERVAL = 1.1
_last_request: float = 0.0
def _mb_request(path: str, params: dict | None = None) -> dict:
"""Rate-limited GET to MusicBrainz API. Blocking."""
global _last_request
from derp.http import urlopen
elapsed = time.monotonic() - _last_request
if elapsed < _RATE_INTERVAL:
time.sleep(_RATE_INTERVAL - elapsed)
qs = "&".join(f"{k}={v}" for k, v in (params or {}).items())
url = f"{_BASE}/{path}?fmt=json&{qs}" if qs else f"{_BASE}/{path}?fmt=json"
req = Request(url, headers={"User-Agent": _UA})
try:
resp = urlopen(req, timeout=10, proxy=False)
_last_request = time.monotonic()
return json.loads(resp.read().decode())
except Exception:
_last_request = time.monotonic()
log.warning("musicbrainz: request failed: %s", path, exc_info=True)
return {}
def mb_search_artist(name: str) -> str | None:
"""Search for an artist by name, return MBID or None."""
from urllib.parse import quote
data = _mb_request("artist", {"query": quote(name), "limit": "1"})
artists = data.get("artists", [])
if not artists:
return None
# Require a reasonable score to avoid false matches
score = artists[0].get("score", 0)
if score < 50:
return None
return artists[0].get("id")
def mb_artist_tags(mbid: str) -> list[str]:
"""Fetch top 5 tags for an artist by MBID."""
data = _mb_request(f"artist/{mbid}", {"inc": "tags"})
tags = data.get("tags", [])
if not tags:
return []
# Sort by count descending, take top 5
sorted_tags = sorted(tags, key=lambda t: t.get("count", 0), reverse=True)
return [t["name"] for t in sorted_tags[:5] if t.get("name")]
def mb_find_similar_recordings(artist: str, tags: list[str],
limit: int = 10) -> list[dict]:
"""Find recordings by other artists sharing top tags.
Searches MusicBrainz for recordings tagged with the top 2 tags,
excluding the original artist. Returns [{"artist": str, "title": str}].
"""
from urllib.parse import quote
if not tags:
return []
# Use top 2 tags for the query
tag_query = " AND ".join(f'tag:"{t}"' for t in tags[:2])
query = f'({tag_query}) AND NOT artist:"{artist}"'
data = _mb_request("recording", {
"query": quote(query),
"limit": str(limit),
})
recordings = data.get("recordings", [])
if not recordings:
return []
seen = set()
results = []
for rec in recordings:
title = rec.get("title", "")
credits = rec.get("artist-credit", [])
if not credits or not title:
continue
rec_artist = credits[0].get("name", "") if credits else ""
if not rec_artist:
continue
# Skip the original artist (case-insensitive)
if rec_artist.lower() == artist.lower():
continue
# Deduplicate by artist+title
key = f"{rec_artist.lower()}:{title.lower()}"
if key in seen:
continue
seen.add(key)
results.append({"artist": rec_artist, "title": title})
return results

View File

@@ -436,7 +436,7 @@ def _search_youtube(keyword: str) -> list[dict]:
req = urllib.request.Request(_YT_SEARCH_URL, data=payload, method="POST")
req.add_header("Content-Type", "application/json")
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
resp = _urlopen(req, timeout=_FETCH_TIMEOUT)
raw = resp.read()
resp.close()
@@ -545,7 +545,7 @@ def _search_searx(keyword: str) -> list[dict]:
})
req = urllib.request.Request(f"{_SEARX_URL}?{params}", method="GET")
try:
resp = urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT)
resp = _urlopen(req, timeout=_FETCH_TIMEOUT, proxy=False)
raw = resp.read()
resp.close()
except Exception as exc:

View File

@@ -1,11 +1,42 @@
"""Core plugin: ping, help, version, plugin management."""
import asyncio
import textwrap
from collections import Counter
from derp import __version__
from derp.plugin import command
def _build_cmd_detail(handler, prefix: str, indent: int = 0) -> str:
"""Format command header + docstring at the given indent level.
Command name sits at *indent*, docstring body at *indent + 4*.
Returns just the header line when no docstring exists.
"""
pad = " " * indent
header = f"{pad}{prefix}{handler.name}"
if handler.help:
header += f" -- {handler.help}"
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:
"""Create a paste via FlaskPaste. Returns URL or None."""
fp = bot.registry._modules.get("flaskpaste")
if not fp:
return None
loop = asyncio.get_running_loop()
try:
return await loop.run_in_executor(None, fp.create_paste, bot, text)
except Exception:
return None
@command("ping", help="Check if the bot is alive")
async def cmd_ping(bot, message):
"""Respond with pong."""
@@ -27,7 +58,13 @@ async def cmd_help(bot, message):
handler = bot.registry.commands.get(name)
if handler and bot._plugin_allowed(handler.plugin, channel):
help_text = handler.help or "No help available."
await bot.reply(message, f"{bot.prefix}{name} -- {help_text}")
reply = f"{bot.prefix}{name} -- {help_text}"
if (handler.callback.__doc__ or "").strip():
detail = _build_cmd_detail(handler, bot.prefix)
url = await _paste(bot, detail)
if url:
reply += f" | {url}"
await bot.reply(message, reply)
return
# Check plugin
@@ -41,7 +78,24 @@ async def cmd_help(bot, message):
lines = [f"{name} -- {desc}" if desc else name]
if cmds:
lines.append(f"Commands: {', '.join(bot.prefix + c for c in cmds)}")
await bot.reply(message, " | ".join(lines))
reply = " | ".join(lines)
# Build detail: plugin header + indented commands
section_lines = [f"[{name}]"]
if desc:
section_lines.append(f" {desc}")
section_lines.append("")
has_detail = False
for cmd_name in cmds:
h = bot.registry.commands[cmd_name]
section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4))
section_lines.append("")
if (h.callback.__doc__ or "").strip():
has_detail = True
if has_detail:
url = await _paste(bot, "\n".join(section_lines).rstrip())
if url:
reply += f" | {url}"
await bot.reply(message, reply)
return
await bot.reply(message, f"Unknown command or plugin: {name}")
@@ -52,7 +106,31 @@ async def cmd_help(bot, message):
k for k, v in bot.registry.commands.items()
if bot._plugin_allowed(v.plugin, channel)
)
await bot.reply(message, ", ".join(names))
reply = ", ".join(names)
# Build full reference grouped by plugin
plugins: dict[str, list[str]] = {}
for cmd_name in names:
h = bot.registry.commands[cmd_name]
plugins.setdefault(h.plugin, []).append(cmd_name)
sections = []
for plugin_name in sorted(plugins):
mod = bot.registry._modules.get(plugin_name)
desc = (getattr(mod, "__doc__", "") or "").split("\n")[0].strip() if mod else ""
section_lines = [f"[{plugin_name}]"]
if desc:
section_lines.append(f" {desc}")
section_lines.append("")
for cmd_name in plugins[plugin_name]:
h = bot.registry.commands[cmd_name]
section_lines.append(_build_cmd_detail(h, bot.prefix, indent=4))
section_lines.append("")
sections.append("\n".join(section_lines).rstrip())
if sections:
url = await _paste(bot, "\n\n".join(sections))
if url:
reply += f" | {url}"
await bot.reply(message, reply)
@command("version", help="Show bot version")

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
@@ -91,29 +92,110 @@ def _search_track(api_key: str, query: str,
# -- Metadata extraction -----------------------------------------------------
def _current_meta(bot) -> tuple[str, str]:
"""Extract artist and title from the currently playing track.
def _parse_title(raw_title: str) -> tuple[str, str]:
"""Split a raw track title into (artist, title).
Returns (artist, title). Either or both may be empty.
Tries the music plugin's current track metadata, falling back to
splitting the title on common separators.
Tries common separators: `` - ``, `` -- ``, `` | ``, `` ~ ``.
Returns ``("", raw_title)`` if no separator is found.
"""
music_ps = bot._pstate.get("music", {})
current = music_ps.get("current")
if current is None:
return ("", "")
raw_title = current.title or ""
# Try common "Artist - Title" patterns
for sep in (" - ", " -- ", " | ", " ~ "):
if sep in raw_title:
parts = raw_title.split(sep, 1)
return (parts[0].strip(), parts[1].strip())
# No separator -- treat whole thing as a search query
return ("", raw_title)
def _music_bot(bot):
"""Return the bot instance that owns music playback.
Checks the calling bot first, then peer bots via the shared registry.
Returns the first bot with an active music state, or ``bot`` as fallback.
"""
candidates = [bot]
for peer in getattr(getattr(bot, "registry", None), "_bots", {}).values():
if peer is not bot:
candidates.append(peer)
for b in candidates:
music_ps = getattr(b, "_pstate", {}).get("music", {})
if music_ps.get("current") is not None or music_ps.get("queue"):
return b
# No active music state -- prefer a bot that allows the music plugin
for b in candidates:
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 ("", "")
# -- Discovery orchestrator --------------------------------------------------
async def discover_similar(bot, last_track_title: str) -> tuple[str, str] | None:
"""Find a similar track via Last.fm or MusicBrainz fallback.
Returns ``(artist, title)`` or ``None`` if nothing found.
"""
artist, title = _parse_title(last_track_title)
loop = asyncio.get_running_loop()
# -- Last.fm path --
api_key = _get_api_key(bot)
if api_key and artist:
try:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title, 20,
)
if similar:
pick = random.choice(similar)
pick_artist = pick.get("artist", {}).get("name", "")
pick_title = pick.get("name", "")
if pick_artist and pick_title:
return (pick_artist, pick_title)
except Exception:
log.warning("lastfm: discover via Last.fm failed", exc_info=True)
# -- MusicBrainz fallback --
if artist:
try:
from plugins._musicbrainz import (
mb_artist_tags,
mb_find_similar_recordings,
mb_search_artist,
)
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
if mbid:
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
if tags:
picks = await loop.run_in_executor(
None, mb_find_similar_recordings, artist, tags, 20,
)
if picks:
pick = random.choice(picks)
return (pick["artist"], pick["title"])
except Exception:
log.warning("lastfm: discover via MusicBrainz failed",
exc_info=True)
return None
# -- Formatting --------------------------------------------------------------
@@ -125,33 +207,141 @@ def _fmt_match(m: float | str) -> str:
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 ----------------------------------------------------------------
@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):
"""Find similar artists or tracks.
"""Discover and play similar music.
Usage:
!similar Similar to currently playing track
!similar <artist> Similar artists to named artist
!similar play Queue a random similar track
!similar play <artist> Queue a similar track for named artist
!similar Discover + play similar to current track
!similar <artist> Discover + play similar to named artist
!similar list Show similar (display only)
!similar list <artist> Show similar for named artist
"""
api_key = _get_api_key(bot)
if not api_key:
await bot.reply(message, "Last.fm API key not configured")
return
parts = message.text.split(None, 2)
# !similar play [artist]
play_mode = len(parts) >= 2 and parts[1].lower() == "play"
if play_mode:
# !similar list [artist]
list_mode = len(parts) >= 2 and parts[1].lower() == "list"
if list_mode:
query = parts[2].strip() if len(parts) > 2 else ""
else:
query = parts[1].strip() if len(parts) > 1 else ""
import asyncio
loop = asyncio.get_running_loop()
# Resolve artist from query or current track
@@ -164,71 +354,86 @@ async def cmd_similar(bot, message):
await bot.reply(message, "Nothing playing and no artist given")
return
# Try track-level similarity first if we have both artist + title
similar = []
if artist and title:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title,
)
# -- Last.fm path --
similar: list[dict] = []
similar_artists: list[dict] = []
if api_key:
# Try track-level similarity first if we have both artist + title
if artist and title:
similar = await loop.run_in_executor(
None, _get_similar_tracks, api_key, artist, title,
)
# Fall back to artist-level similarity
if not similar:
search_artist = artist or title
similar_artists = await loop.run_in_executor(
None, _get_similar_artists, api_key, search_artist,
)
# Fall back to artist-level similarity
if not similar:
# -- MusicBrainz fallback --
mb_results: list[dict] = []
if not similar and not similar_artists:
search_artist = artist or title
similar_artists = await loop.run_in_executor(
None, _get_similar_artists, api_key, search_artist,
)
if not similar_artists:
await bot.reply(message, f"No similar artists found for '{search_artist}'")
return
try:
from plugins._musicbrainz import (
mb_artist_tags,
mb_find_similar_recordings,
mb_search_artist,
)
mbid = await loop.run_in_executor(
None, mb_search_artist, search_artist,
)
if mbid:
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
if tags:
mb_results = await loop.run_in_executor(
None, mb_find_similar_recordings, search_artist,
tags, 20,
)
except Exception:
log.warning("lastfm: MusicBrainz fallback failed", exc_info=True)
if play_mode:
# Pick a random similar artist and search YouTube
pick = random.choice(similar_artists[:10])
pick_name = pick.get("name", "")
if not pick_name:
await bot.reply(message, "No playable result found")
return
# Inject a !play command with a YouTube search
message.text = f"!play {pick_name}"
music_mod = bot.registry._modules.get("music")
if music_mod:
await music_mod.cmd_play(bot, message)
return
# Display 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")
# Nothing found at all
if not similar and not similar_artists and not mb_results:
search_artist = artist or title
await bot.reply(message, f"No similar artists found for '{search_artist}'")
return
# Track-level 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)
# -- List mode (display only) --
if list_mode:
await _display_results(bot, message, similar, similar_artists,
mb_results, artist, title)
return
# Display similar tracks
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")
# -- Play mode (default): build playlist and transition --
search_artist = artist or title
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")
@@ -240,14 +445,10 @@ async def cmd_tags(bot, message):
!tags <artist> Tags for named artist
"""
api_key = _get_api_key(bot)
if not api_key:
await bot.reply(message, "Last.fm API key not configured")
return
parts = message.text.split(None, 1)
query = parts[1].strip() if len(parts) > 1 else ""
import asyncio
loop = asyncio.get_running_loop()
if query:
@@ -259,9 +460,28 @@ async def cmd_tags(bot, message):
await bot.reply(message, "Nothing playing and no artist given")
return
tags = await loop.run_in_executor(
None, _get_top_tags, api_key, artist,
)
# -- Last.fm path --
tags = []
if api_key:
tags = await loop.run_in_executor(
None, _get_top_tags, api_key, artist,
)
# -- MusicBrainz fallback --
if not tags:
try:
from plugins._musicbrainz import mb_artist_tags, mb_search_artist
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
if mbid:
mb_tags = await loop.run_in_executor(
None, mb_artist_tags, mbid,
)
if mb_tags:
await bot.reply(message, f"{artist}: {', '.join(mb_tags)}")
return
except Exception:
log.warning("lastfm: MusicBrainz tag fallback failed",
exc_info=True)
if not tags:
await bot.reply(message, f"No tags found for '{artist}'")

276
plugins/mumble_admin.py Normal file
View File

@@ -0,0 +1,276 @@
"""Plugin: Mumble server administration via chat commands."""
from __future__ import annotations
import logging
from derp.plugin import command
_log = logging.getLogger(__name__)
# -- Helpers -----------------------------------------------------------------
def _find_user(bot, name: str):
"""Case-insensitive user lookup by name. Returns pymumble User or None."""
mumble = getattr(bot, "_mumble", None)
if mumble is None:
return None
lower = name.lower()
for sid in list(mumble.users):
user = mumble.users[sid]
if user["name"].lower() == lower:
return user
return None
def _find_channel(bot, name: str):
"""Case-insensitive channel lookup by name. Returns pymumble Channel or None."""
mumble = getattr(bot, "_mumble", None)
if mumble is None:
return None
lower = name.lower()
for cid in list(mumble.channels):
chan = mumble.channels[cid]
if chan["name"].lower() == lower:
return chan
return None
def _channel_name(bot, channel_id: int) -> str:
"""Resolve a channel ID to its name, or return the ID as string."""
mumble = getattr(bot, "_mumble", None)
if mumble is None:
return str(channel_id)
chan = mumble.channels.get(channel_id)
if chan is None:
return str(channel_id)
return chan["name"]
# -- Sub-handlers ------------------------------------------------------------
async def _sub_kick(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu kick <user> [reason]")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
reason = " ".join(args[1:]) if len(args) > 1 else ""
user.kick(reason)
await bot.reply(message, f"Kicked {user['name']}")
async def _sub_ban(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu ban <user> [reason]")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
reason = " ".join(args[1:]) if len(args) > 1 else ""
user.ban(reason)
await bot.reply(message, f"Banned {user['name']}")
async def _sub_mute(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu mute <user>")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
user.mute()
await bot.reply(message, f"Muted {user['name']}")
async def _sub_unmute(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu unmute <user>")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
user.unmute()
await bot.reply(message, f"Unmuted {user['name']}")
async def _sub_deafen(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu deafen <user>")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
user.deafen()
await bot.reply(message, f"Deafened {user['name']}")
async def _sub_undeafen(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu undeafen <user>")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
user.undeafen()
await bot.reply(message, f"Undeafened {user['name']}")
async def _sub_move(bot, message, args: list[str]) -> None:
if len(args) < 2:
await bot.reply(message, "Usage: !mu move <user> <channel>")
return
user = _find_user(bot, args[0])
if user is None:
await bot.reply(message, f"User not found: {args[0]}")
return
chan = _find_channel(bot, " ".join(args[1:]))
if chan is None:
await bot.reply(message, f"Channel not found: {' '.join(args[1:])}")
return
user.move_in(chan["channel_id"])
await bot.reply(message, f"Moved {user['name']} to {chan['name']}")
async def _sub_users(bot, message, args: list[str]) -> None:
mumble = getattr(bot, "_mumble", None)
if mumble is None:
return
bots = getattr(bot.registry, "_bots", {})
lines: list[str] = []
for sid in sorted(mumble.users):
user = mumble.users[sid]
name = user["name"]
flags: list[str] = []
if name in bots:
flags.append("bot")
if user.get("mute") or user.get("self_mute"):
flags.append("muted")
if user.get("deaf") or user.get("self_deaf"):
flags.append("deaf")
chan = _channel_name(bot, user.get("channel_id", 0))
tag = f" [{', '.join(flags)}]" if flags else ""
lines.append(f" {name} in {chan}{tag}")
header = f"Online: {len(lines)} user(s)"
await bot.reply(message, header + "\n" + "\n".join(lines))
async def _sub_channels(bot, message, args: list[str]) -> None:
mumble = getattr(bot, "_mumble", None)
if mumble is None:
return
lines: list[str] = []
for cid in sorted(mumble.channels):
chan = mumble.channels[cid]
name = chan["name"]
# Count users in this channel
count = sum(
1 for sid in mumble.users
if mumble.users[sid].get("channel_id") == cid
)
lines.append(f" {name} ({count})")
await bot.reply(message, "Channels:\n" + "\n".join(lines))
async def _sub_mkchan(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu mkchan <name> [temp]")
return
mumble = getattr(bot, "_mumble", None)
if mumble is None:
return
name = args[0]
temp = len(args) > 1 and args[1].lower() in ("temp", "temporary", "true")
mumble.channels.new_channel(0, name, temporary=temp)
label = " (temporary)" if temp else ""
await bot.reply(message, f"Created channel: {name}{label}")
async def _sub_rmchan(bot, message, args: list[str]) -> None:
if not args:
await bot.reply(message, "Usage: !mu rmchan <channel>")
return
chan = _find_channel(bot, " ".join(args))
if chan is None:
await bot.reply(message, f"Channel not found: {' '.join(args)}")
return
name = chan["name"]
chan.remove()
await bot.reply(message, f"Removed channel: {name}")
async def _sub_rename(bot, message, args: list[str]) -> None:
if len(args) < 2:
await bot.reply(message, "Usage: !mu rename <channel> <new-name>")
return
chan = _find_channel(bot, args[0])
if chan is None:
await bot.reply(message, f"Channel not found: {args[0]}")
return
old = chan["name"]
chan.rename_channel(args[1])
await bot.reply(message, f"Renamed {old} to {args[1]}")
async def _sub_desc(bot, message, args: list[str]) -> None:
if len(args) < 2:
await bot.reply(message, "Usage: !mu desc <channel> <text>")
return
chan = _find_channel(bot, args[0])
if chan is None:
await bot.reply(message, f"Channel not found: {args[0]}")
return
text = " ".join(args[1:])
chan.set_channel_description(text)
await bot.reply(message, f"Set description for {chan['name']}")
# -- Dispatch table ----------------------------------------------------------
_SUBS: dict[str, object] = {
"kick": _sub_kick,
"ban": _sub_ban,
"mute": _sub_mute,
"unmute": _sub_unmute,
"deafen": _sub_deafen,
"undeafen": _sub_undeafen,
"move": _sub_move,
"users": _sub_users,
"channels": _sub_channels,
"mkchan": _sub_mkchan,
"rmchan": _sub_rmchan,
"rename": _sub_rename,
"desc": _sub_desc,
}
_USAGE = (
"Usage: !mu <action> [args]\n"
"Actions: kick, ban, mute, unmute, deafen, undeafen, move, "
"users, channels, mkchan, rmchan, rename, desc"
)
@command("mu", help="Mumble admin: !mu <action> [args]", tier="admin")
async def cmd_mu(bot, message):
"""Mumble server administration commands."""
parts = message.text.split()
if len(parts) < 2:
await bot.reply(message, _USAGE)
return
sub = parts[1].lower()
handler = _SUBS.get(sub)
if handler is None:
await bot.reply(message, _USAGE)
return
await handler(bot, message, parts[2:])

View File

@@ -58,6 +58,8 @@ def _ps(bot):
"history": [],
"autoplay": cfg.get("autoplay", True),
"autoplay_cooldown": cfg.get("autoplay_cooldown", 30),
"discover": cfg.get("discover", True),
"discover_ratio": cfg.get("discover_ratio", 3),
"announce": cfg.get("announce", False),
"paused": None,
"_watcher_task": None,
@@ -575,17 +577,64 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
seek_req = [None]
ps["seek_req"] = seek_req
_autoplay_pool: list[_Track] = [] # shuffled deck, refilled each cycle
_discover_seen: set[str] = set() # "artist:title" dedup within session
_autoplay_count: int = 0 # autoplay picks since loop start
try:
while ps["queue"] or ps.get("autoplay"):
# Autoplay: cooldown + silence wait, then pick next from shuffled deck
if not ps["queue"]:
if not _autoplay_pool:
kept = _load_kept_tracks(bot)
if not kept:
break
random.shuffle(kept)
_autoplay_pool = kept
log.info("music: autoplay shuffled %d kept tracks", len(kept))
_autoplay_count += 1
# -- Discovery attempt on every Nth autoplay pick --
discovered = False
ratio = ps.get("discover_ratio", 3)
if (ps.get("discover") and ratio > 0
and _autoplay_count % ratio == 0
and ps["history"]):
last = ps["history"][-1]
try:
lfm = bot.registry._modules.get("lastfm")
if lfm and hasattr(lfm, "discover_similar"):
pair = await lfm.discover_similar(bot, last.title)
if pair:
a, t = pair
key = f"{a.lower()}:{t.lower()}"
if key not in _discover_seen:
_discover_seen.add(key)
loop = asyncio.get_running_loop()
res = await loop.run_in_executor(
None, _resolve_tracks,
f"{a} {t}", 1,
)
if res:
discovered = True
pick = _Track(
url=res[0][0], title=res[0][1],
requester="discover",
)
log.info(
"music: discovered '%s' "
"similar to '%s'",
pick.title, last.title,
)
except Exception:
log.warning(
"music: discovery failed, using kept deck",
exc_info=True,
)
# -- Kept-deck fallback --
if not discovered:
if not _autoplay_pool:
kept = _load_kept_tracks(bot)
if not kept:
break
random.shuffle(kept)
_autoplay_pool = kept
log.info("music: autoplay shuffled %d kept tracks",
len(kept))
pick = _autoplay_pool.pop(0)
cooldown = ps.get("autoplay_cooldown", 30)
log.info("music: autoplay cooldown %ds before next track",
cooldown)
@@ -602,9 +651,8 @@ async def _play_loop(bot, *, seek: float = 0.0, fade_in: float | bool = True) ->
# Re-check: someone may have queued something or stopped
if ps["queue"]:
continue
pick = _autoplay_pool.pop(0)
ps["queue"].append(pick)
log.info("music: autoplay picked '%s' (%d remaining)",
log.info("music: autoplay queued '%s' (%d pool remaining)",
pick.title, len(_autoplay_pool))
track = ps["queue"].pop(0)
ps["current"] = track
@@ -1194,15 +1242,30 @@ async def cmd_queue(bot, message):
ps = _ps(bot)
lines = []
if ps["current"]:
track = ps["current"]
progress = ps.get("progress")
cur_seek = ps.get("cur_seek", 0.0)
elapsed = cur_seek + (progress[0] * 0.02 if progress else 0.0)
pos = _fmt_time(elapsed)
if track.duration > 0:
pos = f"{pos}/{_fmt_time(track.duration)}"
lines.append(
f"Now: {_truncate(ps['current'].title)}"
f" [{ps['current'].requester}]"
f"Now: {_truncate(track.title)}"
f" [{track.requester}] ({pos})"
)
if ps["queue"]:
total_dur = 0.0
for i, track in enumerate(ps["queue"], 1):
dur = f" ({_fmt_time(track.duration)})" if track.duration > 0 else ""
total_dur += track.duration
lines.append(
f" {i}. {_truncate(track.title)} [{track.requester}]"
f" {i}. {_truncate(track.title)} [{track.requester}]{dur}"
)
count = len(ps["queue"])
footer = f"Queue: {count} track{'s' if count != 1 else ''}"
if total_dur > 0:
footer += f", {_fmt_time(total_dur)} total"
lines.append(footer)
else:
if not ps["current"]:
lines.append("Queue empty")
@@ -1659,6 +1722,184 @@ async def _kept_repair(bot, message) -> None:
await bot.reply(message, msg)
@command("playlist", help="Music: !playlist save|load|list|del <name>")
async def cmd_playlist(bot, message):
"""Save, load, list, delete, import, or show named playlists.
Usage:
!playlist save <name> Save current + queued tracks as a playlist
!playlist load <name> [shuffle] Append saved playlist to queue
!playlist list Show saved playlists with track counts
!playlist del <name> Delete a saved playlist
!playlist import <name> <url> Import tracks from URL as a named playlist
!playlist show <name> Display tracks in a saved playlist
"""
if not _is_mumble(bot):
await bot.reply(message, "Mumble-only feature")
return
parts = message.text.split()
if len(parts) < 2:
await bot.reply(
message, "Usage: !playlist save|load|list|del|import|show <name>",
)
return
sub = parts[1].lower()
if sub == "save":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist save <name>")
return
name = parts[2].lower()
ps = _ps(bot)
entries = []
if ps["current"]:
t = ps["current"]
entries.append({"url": t.url, "title": t.title,
"requester": t.requester})
for t in ps["queue"]:
entries.append({"url": t.url, "title": t.title,
"requester": t.requester})
if not entries:
await bot.reply(message, "Nothing to save")
return
bot.state.set("music", f"playlist:{name}", json.dumps(entries))
await bot.reply(
message,
f"Saved playlist '{name}' ({len(entries)} track"
f"{'s' if len(entries) != 1 else ''})",
)
elif sub == "load":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist load <name> [shuffle]")
return
name = parts[2].lower()
shuffle = len(parts) >= 4 and parts[3].lower() == "shuffle"
raw = bot.state.get("music", f"playlist:{name}")
if not raw:
await bot.reply(message, f"No playlist named '{name}'")
return
try:
entries = json.loads(raw)
except (json.JSONDecodeError, TypeError):
await bot.reply(message, f"Corrupt playlist '{name}'")
return
ps = _ps(bot)
was_idle = ps["current"] is None
added = 0
for e in entries:
if len(ps["queue"]) >= _MAX_QUEUE:
break
ps["queue"].append(_Track(
url=e["url"], title=e.get("title", e["url"]),
requester=e.get("requester", "?"),
))
added += 1
if shuffle and ps["queue"]:
random.shuffle(ps["queue"])
suffix = " (shuffled)" if shuffle else ""
await bot.reply(
message,
f"Loaded '{name}': {added} track{'s' if added != 1 else ''}{suffix}",
)
if was_idle:
_ensure_loop(bot)
elif sub == "list":
names = []
for key in bot.state.keys("music"):
if not key.startswith("playlist:"):
continue
pname = key.split(":", 1)[1]
raw = bot.state.get("music", key)
count = 0
if raw:
try:
count = len(json.loads(raw))
except (json.JSONDecodeError, TypeError):
pass
names.append((pname, count))
if not names:
await bot.reply(message, "No saved playlists")
return
names.sort()
lines = [f"Playlists ({len(names)}):"]
for pname, count in names:
lines.append(
f" {pname} ({count} track{'s' if count != 1 else ''})",
)
for line in lines:
await bot.reply(message, line)
elif sub == "del":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist del <name>")
return
name = parts[2].lower()
raw = bot.state.get("music", f"playlist:{name}")
if not raw:
await bot.reply(message, f"No playlist named '{name}'")
return
bot.state.delete("music", f"playlist:{name}")
await bot.reply(message, f"Deleted playlist '{name}'")
elif sub == "import":
if len(parts) < 4:
await bot.reply(message, "Usage: !playlist import <name> <url>")
return
name = parts[2].lower()
url = parts[3]
await bot.reply(message, f"Importing '{name}' from URL...")
loop = asyncio.get_running_loop()
try:
resolved = await loop.run_in_executor(None, _resolve_tracks, url)
except Exception:
await bot.reply(message, "Failed to resolve URL")
return
if not resolved:
await bot.reply(message, "No tracks found")
return
requester = message.nick or "?"
entries = [{"url": u, "title": t, "requester": requester}
for u, t in resolved]
bot.state.set("music", f"playlist:{name}", json.dumps(entries))
await bot.reply(
message,
f"Imported playlist '{name}' ({len(entries)} track"
f"{'s' if len(entries) != 1 else ''})",
)
elif sub == "show":
if len(parts) < 3:
await bot.reply(message, "Usage: !playlist show <name>")
return
name = parts[2].lower()
raw = bot.state.get("music", f"playlist:{name}")
if not raw:
await bot.reply(message, f"No playlist named '{name}'")
return
try:
entries = json.loads(raw)
except (json.JSONDecodeError, TypeError):
await bot.reply(message, f"Corrupt playlist '{name}'")
return
if not entries:
await bot.reply(message, f"Playlist '{name}' is empty")
return
lines = [f"Playlist '{name}' ({len(entries)} tracks):"]
for i, e in enumerate(entries, 1):
title = _truncate(e.get("title", e["url"]))
lines.append(f" {i:>2}. {title}")
await bot.long_reply(message, lines, label=name)
else:
await bot.reply(
message, "Usage: !playlist save|load|list|del|import|show <name>",
)
# -- Plugin lifecycle --------------------------------------------------------

View File

@@ -47,9 +47,12 @@ _PIPER_URL = "http://192.168.129.9:5100/"
def _ps(bot):
"""Per-bot plugin runtime state."""
cfg = getattr(bot, "config", {}).get("voice", {})
trigger = cfg.get("trigger", "")
# Bias Whisper toward the trigger word unless explicitly configured
default_prompt = f"{trigger.capitalize()}, " if trigger else ""
return bot._pstate.setdefault("voice", {
"listen": False,
"trigger": cfg.get("trigger", ""),
"trigger": trigger,
"buffers": {}, # {username: bytearray}
"last_ts": {}, # {username: float monotonic}
"flush_task": None,
@@ -62,6 +65,7 @@ def _ps(bot):
"noise_scale": cfg.get("noise_scale", 0.667),
"noise_w": cfg.get("noise_w", 0.8),
"fx": cfg.get("fx", ""),
"initial_prompt": cfg.get("initial_prompt", default_prompt),
"_listener_registered": False,
})
@@ -170,8 +174,17 @@ def _transcribe(ps, pcm: bytes) -> str:
).encode() + wav_data + (
f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="response_format"\r\n\r\n'
f"json\r\n--{boundary}--\r\n"
f"json"
).encode()
# Bias Whisper toward the trigger word when configured
prompt = ps.get("initial_prompt", "")
if prompt:
body += (
f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="initial_prompt"\r\n\r\n'
f"{prompt}"
).encode()
body += f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(ps["whisper_url"], data=body, method="POST")
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
resp = _urlopen(req, timeout=30, proxy=False)
@@ -552,27 +565,13 @@ async def cmd_audition(bot, message):
f"{_deep},{_bass},{_echo_chamber}"),
]
# Find merlin (the listener bot) -- plays the audition samples
merlin = None
for peer in getattr(bot.registry, "_bots", {}).values():
if getattr(peer, "_receive_sound", False):
merlin = peer
break
await bot.reply(message, f"Auditioning {len(samples)} voice samples...")
loop = asyncio.get_running_loop()
from pathlib import Path
# Pre-generate derp's default voice (same phrase, no FX)
derp_wav = await loop.run_in_executor(
None, lambda: _fetch_tts_voice(piper_url, phrase),
)
for i, (label, voice, sid, fx) in enumerate(samples, 1):
announcer = merlin or bot
await announcer.send("0", f"[{i}/{len(samples)}] {label}")
await bot.send("0", f"[{i}/{len(samples)}] {label}")
await asyncio.sleep(1)
# Generate the audition sample (merlin's candidate voice)
sample_wav = await loop.run_in_executor(
None, lambda v=voice, s=sid, f=fx: _fetch_tts_voice(
piper_url, phrase, voice=v, speaker_id=s,
@@ -583,40 +582,36 @@ async def cmd_audition(bot, message):
await bot.send("0", " (failed)")
continue
try:
# Both bots speak simultaneously:
# merlin plays the audition sample, derp plays its default voice
merlin_done = asyncio.Event()
derp_done = asyncio.Event()
if merlin:
merlin_task = asyncio.create_task(
merlin.stream_audio(sample_wav, volume=1.0,
on_done=merlin_done))
derp_task = asyncio.create_task(
bot.stream_audio(derp_wav, volume=1.0,
on_done=derp_done))
await asyncio.gather(merlin_task, derp_task)
else:
await bot.stream_audio(sample_wav, volume=1.0,
on_done=merlin_done)
await merlin_done.wait()
done = asyncio.Event()
await bot.stream_audio(sample_wav, volume=1.0, on_done=done)
await done.wait()
finally:
Path(sample_wav).unlink(missing_ok=True)
await asyncio.sleep(2)
if derp_wav:
Path(derp_wav).unlink(missing_ok=True)
announcer = merlin or bot
await announcer.send("0", "Audition complete.")
await bot.send("0", "Audition complete.")
# -- Plugin lifecycle --------------------------------------------------------
async def on_connected(bot) -> None:
"""Re-register listener after reconnect."""
"""Re-register listener after reconnect; play TTS greeting on first connect."""
if not _is_mumble(bot):
return
ps = _ps(bot)
if ps["listen"] or ps["trigger"]:
_ensure_listener(bot)
_ensure_flush_task(bot)
# Greet via TTS on first connection only
greet = getattr(bot, "config", {}).get("mumble", {}).get("greet")
if greet and not ps.get("_greeted"):
ps["_greeted"] = True
ready = getattr(bot, "_is_audio_ready", None)
if ready:
for _ in range(20):
if ready():
break
await asyncio.sleep(0.5)
bot._spawn(_tts_play(bot, greet), name="voice-greet")

View File

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

View File

@@ -253,7 +253,10 @@ class Bot:
async def _loop(self) -> None:
"""Read and dispatch messages until disconnect."""
while self._running:
line = await self.conn.readline()
try:
line = await asyncio.wait_for(self.conn.readline(), timeout=2.0)
except asyncio.TimeoutError:
continue
if line is None:
log.warning("server closed connection")
return

View File

@@ -10,6 +10,7 @@ import sys
from derp import __version__
from derp.bot import Bot
from derp.config import build_server_configs, resolve_config
from derp.irc import format_msg
from derp.log import JsonFormatter
from derp.plugin import PluginRegistry
@@ -37,8 +38,8 @@ def build_parser() -> argparse.ArgumentParser:
"--cprofile",
metavar="PATH",
nargs="?",
const="derp.prof",
help="enable cProfile; dump stats to PATH [derp.prof]",
const="data/derp.prof",
help="enable cProfile; dump stats to PATH [data/derp.prof]",
)
p.add_argument(
"--tracemalloc",
@@ -72,12 +73,24 @@ def _run(bots: list) -> None:
def _shutdown(bots: list) -> None:
"""Signal handler: stop all bot loops so cProfile can flush."""
"""Signal handler: stop all bot loops and tear down connections."""
logging.getLogger("derp").info("SIGTERM received, shutting down")
loop = asyncio.get_running_loop()
for bot in bots:
bot._running = False
if hasattr(bot, "conn"):
asyncio.get_running_loop().create_task(bot.conn.close())
if hasattr(bot, "conn") and bot.conn.connected:
loop.create_task(_quit_and_close(bot))
elif hasattr(bot, "_mumble") and bot._mumble:
bot._mumble.stop()
async def _quit_and_close(bot) -> None:
"""Send IRC QUIT and close the connection."""
try:
await bot.conn.send(format_msg("QUIT", "shutting down"))
except Exception:
pass
await bot.conn.close()
def _dump_tracemalloc(log: logging.Logger, path: str, limit: int = 25) -> None:
@@ -193,10 +206,32 @@ def main(argv: list[str] | None = None) -> int:
if args.cprofile:
import cProfile
import threading
log.info("cProfile enabled, output: %s", args.cprofile)
cProfile.runctx("_run(bots)", globals(), {"bots": bots, "_run": _run}, args.cprofile)
log.info("profile saved to %s", args.cprofile)
prof = cProfile.Profile()
prof_path = args.cprofile
prof_interval = 10 # dump every 10 seconds
prof_stop = threading.Event()
def _periodic_dump():
while not prof_stop.wait(prof_interval):
try:
prof.dump_stats(prof_path)
except Exception:
pass
dumper = threading.Thread(target=_periodic_dump, daemon=True)
dumper.start()
log.info("cProfile enabled, output: %s (saves every %ds)",
prof_path, prof_interval)
prof.enable()
try:
_run(bots)
finally:
prof.disable()
prof_stop.set()
prof.dump_stats(prof_path)
log.info("profile saved to %s", prof_path)
else:
_run(bots)

View File

@@ -218,11 +218,27 @@ class MumbleBot:
self._on_sound_received,
)
self._mumble.set_receive_sound(self._receive_sound)
# Raise retry interval so 2+ bots on the same IP don't trip
# the server's autoban (default: 10 attempts / 120s).
import pymumble_py3.mumble as _pm
if getattr(_pm, "PYMUMBLE_CONNECTION_RETRY_INTERVAL", 0) < 15:
_pm.PYMUMBLE_CONNECTION_RETRY_INTERVAL = 15
self._mumble.start()
self._mumble.is_ready()
def _on_connected(self) -> None:
"""Callback from pymumble thread: connection established."""
# Enable TCP keepalive on the control socket to prevent NAT
# gateways from dropping the mapping during idle periods.
try:
import socket as _sock
raw = self._mumble.control_socket
raw.setsockopt(_sock.SOL_SOCKET, _sock.SO_KEEPALIVE, 1)
raw.setsockopt(_sock.IPPROTO_TCP, _sock.TCP_KEEPIDLE, 10)
raw.setsockopt(_sock.IPPROTO_TCP, _sock.TCP_KEEPINTVL, 5)
raw.setsockopt(_sock.IPPROTO_TCP, _sock.TCP_KEEPCNT, 3)
except Exception:
pass
self._connect_count += 1
kind = "reconnected" if self._connect_count > 1 else "connected"
session = getattr(self._mumble.users, "myself_session", "?")
@@ -238,6 +254,16 @@ class MumbleBot:
self._mumble.users.myself.deafen()
except Exception:
log.exception("mumble: failed to self-deafen on connect")
# Self-register on first connect so the server stores the cert
# and treats this bot as a known user (persistent identity).
if self._connect_count == 1:
try:
myself = self._mumble.users.myself
if not myself.get("user_id"):
myself.register()
log.info("mumble: self-registered %s", self._username)
except Exception:
log.debug("mumble: self-register skipped (already registered?)")
if self._loop:
asyncio.run_coroutine_threadsafe(
self._notify_plugins_connected(), self._loop,
@@ -268,19 +294,7 @@ class MumbleBot:
await self._play_greet()
async def _play_greet(self) -> None:
"""Speak the greeting via TTS on connect (voice only, no text)."""
greet = self.config.get("mumble", {}).get("greet")
if not greet:
return
voice_mod = self.registry._modules.get("voice")
tts_play = getattr(voice_mod, "_tts_play", None) if voice_mod else None
if tts_play is None:
return
for _ in range(20):
if self._is_audio_ready():
break
await asyncio.sleep(0.5)
self._spawn(tts_play(self, greet), name="voice-greet")
"""No-op: greeting is now handled by the voice plugin's on_connected."""
def _on_disconnected(self) -> None:
"""Callback from pymumble thread: connection lost."""
@@ -706,8 +720,8 @@ class MumbleBot:
pass
_get_vol = volume if callable(volume) else lambda: volume
log.info("stream_audio: starting pipeline for %s (vol=%.0f%%, seek=%.1fs)",
url, _get_vol() * 100, seek)
log.info("stream_audio: [%s] starting pipeline for %s (vol=%.0f%%, seek=%.1fs)",
self._username, url, _get_vol() * 100, seek)
def _build_cmd(seek_pos):
seek_flag = f" -ss {seek_pos:.3f}" if seek_pos > 0 else ""
@@ -803,15 +817,17 @@ class MumbleBot:
if not self._is_audio_ready():
# Disconnected -- keep reading ffmpeg at real-time pace
if _was_feeding:
log.warning("stream_audio: connection lost, "
"dropping frames at %d", frames)
log.warning("stream_audio: [%s] connection lost, "
"dropping frames at %d",
self._username, frames)
_was_feeding = False
await asyncio.sleep(0.02)
continue
if not _was_feeding:
log.info("stream_audio: connection restored, "
"resuming feed at frame %d", frames)
log.info("stream_audio: [%s] connection restored, "
"resuming feed at frame %d",
self._username, frames)
_was_feeding = True
# Seek: fade-out in progress
@@ -879,7 +895,8 @@ class MumbleBot:
continue
if frames == 1:
log.info("stream_audio: first frame fed to pymumble")
log.info("stream_audio: [%s] first frame fed to pymumble",
self._username)
# Keep buffer at most 1 second ahead
try:
@@ -896,7 +913,8 @@ class MumbleBot:
await asyncio.sleep(0.1)
except (TypeError, AttributeError):
pass
log.info("stream_audio: finished, %d frames", frames)
log.info("stream_audio: [%s] finished, %d frames",
self._username, frames)
except asyncio.CancelledError:
# Only clear the buffer if volume is still audible -- if a
# fade-out has already driven _cur_vol to ~0 the remaining
@@ -907,11 +925,12 @@ class MumbleBot:
self._mumble.sound_output.clear_buffer()
except Exception:
pass
log.info("stream_audio: cancelled at frame %d (vol=%.3f)",
frames, _cur_vol)
log.info("stream_audio: [%s] cancelled at frame %d (vol=%.3f)",
self._username, frames, _cur_vol)
raise
except Exception:
log.exception("stream_audio: error at frame %d", frames)
log.exception("stream_audio: [%s] error at frame %d",
self._username, frames)
raise
finally:
try:

View File

@@ -420,7 +420,7 @@ class TestExtractVideos:
def close(self):
pass
with patch("urllib.request.urlopen", return_value=FakeResp()):
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_youtube("test")
assert len(results) == 1
assert results[0]["id"] == "dup1"
@@ -438,7 +438,7 @@ class TestSearchYoutube:
def close(self):
pass
with patch("urllib.request.urlopen", return_value=FakeResp()):
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_youtube("test query")
assert len(results) == 2
assert results[0]["id"] == "abc123"
@@ -446,7 +446,7 @@ class TestSearchYoutube:
def test_http_error_propagates(self):
import pytest
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
with pytest.raises(ConnectionError):
_search_youtube("test")
@@ -1263,7 +1263,7 @@ class TestSearchSearx:
def close(self):
pass
with patch("urllib.request.urlopen", return_value=FakeResp()):
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_searx("test query")
# Same response served for all categories; deduped by URL
assert len(results) == 3
@@ -1281,13 +1281,13 @@ class TestSearchSearx:
def close(self):
pass
with patch("urllib.request.urlopen", return_value=FakeResp()):
with patch.object(_mod, "_urlopen", return_value=FakeResp()):
results = _search_searx("nothing")
assert results == []
def test_http_error_returns_empty(self):
"""SearXNG catches per-category errors; all failing returns empty."""
with patch("urllib.request.urlopen", side_effect=ConnectionError("fail")):
with patch.object(_mod, "_urlopen", side_effect=ConnectionError("fail")):
results = _search_searx("test")
assert results == []

View File

@@ -3,6 +3,8 @@
import asyncio
import importlib.util
import sys
import types
from dataclasses import dataclass
from unittest.mock import MagicMock
# -- Load plugin module directly ---------------------------------------------
@@ -16,9 +18,22 @@ _spec.loader.exec_module(_mod)
# -- Fakes -------------------------------------------------------------------
@dataclass
class _FakeHandler:
name: str
callback: object
help: str = ""
plugin: str = ""
admin: bool = False
tier: str = "user"
class _FakeRegistry:
def __init__(self):
self._bots: dict = {}
self.commands: dict = {}
self._modules: dict = {}
self.events: dict = {}
class _FakeBot:
@@ -26,10 +41,14 @@ class _FakeBot:
self.replied: list[str] = []
self.registry = _FakeRegistry()
self.nick = "derp"
self.prefix = "!"
self._receive_sound = False
if mumble:
self._mumble = MagicMock()
def _plugin_allowed(self, plugin: str, channel) -> bool:
return True
async def reply(self, message, text: str) -> None:
self.replied.append(text)
@@ -90,3 +109,180 @@ class TestDeafCommand:
msg = _Msg(text="!deaf")
asyncio.run(_mod.cmd_deaf(bot, msg))
bot._mumble.users.myself.deafen.assert_called_once()
# -- Help command tests ------------------------------------------------------
def _cmd_with_doc():
"""Manage widgets.
Usage:
!widget add <name>
!widget del <name>
Examples:
!widget add foo
"""
def _cmd_no_doc():
pass
def _make_fp_module(url="https://paste.example.com/abc/raw", capture=None):
"""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")
def _create(bot, text):
if capture is not None:
capture.append(text)
return url
mod.create_paste = _create
return mod
class TestHelpCommand:
def test_help_cmd_with_paste(self):
"""!help <cmd> with docstring pastes detail, appends URL."""
bot = _FakeBot()
handler = _FakeHandler(
name="widget", callback=_cmd_with_doc,
help="Manage widgets", plugin="widgets",
)
bot.registry.commands["widget"] = handler
bot.registry._modules["flaskpaste"] = _make_fp_module()
msg = _Msg(text="!help widget")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(bot.replied) == 1
assert "!widget -- Manage widgets" in bot.replied[0]
assert "https://paste.example.com/abc/raw" in bot.replied[0]
def test_help_cmd_no_docstring(self):
"""!help <cmd> without docstring skips paste."""
bot = _FakeBot()
handler = _FakeHandler(
name="noop", callback=_cmd_no_doc,
help="Does nothing", plugin="misc",
)
bot.registry.commands["noop"] = handler
bot.registry._modules["flaskpaste"] = _make_fp_module()
msg = _Msg(text="!help noop")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(bot.replied) == 1
assert "!noop -- Does nothing" in bot.replied[0]
assert "paste.example.com" not in bot.replied[0]
def test_help_plugin_with_paste(self):
"""!help <plugin> pastes detail for all plugin commands."""
bot = _FakeBot()
mod = types.ModuleType("widgets")
mod.__doc__ = "Widget management plugin."
bot.registry._modules["widgets"] = mod
bot.registry._modules["flaskpaste"] = _make_fp_module()
bot.registry.commands["widget"] = _FakeHandler(
name="widget", callback=_cmd_with_doc,
help="Manage widgets", plugin="widgets",
)
bot.registry.commands["wstat"] = _FakeHandler(
name="wstat", callback=_cmd_no_doc,
help="Widget stats", plugin="widgets",
)
msg = _Msg(text="!help widgets")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(bot.replied) == 1
reply = bot.replied[0]
assert "widgets -- Widget management plugin." in reply
assert "!widget, !wstat" in reply
# Only widget has a docstring, so paste should still happen
assert "https://paste.example.com/abc/raw" in reply
def test_help_list_with_paste(self):
"""!help (no args) pastes full reference."""
bot = _FakeBot()
bot.registry._modules["flaskpaste"] = _make_fp_module()
mod = types.ModuleType("core")
mod.__doc__ = "Core plugin."
bot.registry._modules["core"] = mod
bot.registry.commands["ping"] = _FakeHandler(
name="ping", callback=_cmd_with_doc,
help="Check alive", plugin="core",
)
bot.registry.commands["help"] = _FakeHandler(
name="help", callback=_cmd_no_doc,
help="Show help", plugin="core",
)
msg = _Msg(text="!help")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(bot.replied) == 1
assert "help, ping" in bot.replied[0]
assert "https://paste.example.com/abc/raw" in bot.replied[0]
def test_help_no_flaskpaste(self):
"""Without flaskpaste loaded, help still works (no URL)."""
bot = _FakeBot()
handler = _FakeHandler(
name="widget", callback=_cmd_with_doc,
help="Manage widgets", plugin="widgets",
)
bot.registry.commands["widget"] = handler
# No flaskpaste in _modules
msg = _Msg(text="!help widget")
asyncio.run(_mod.cmd_help(bot, msg))
assert len(bot.replied) == 1
assert "!widget -- Manage widgets" 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}"

916
tests/test_lastfm.py Normal file
View File

@@ -0,0 +1,916 @@
"""Tests for the Last.fm music discovery plugin."""
import asyncio
import importlib.util
import sys
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
# -- Load plugin module directly ---------------------------------------------
_spec = importlib.util.spec_from_file_location("lastfm", "plugins/lastfm.py")
_mod = importlib.util.module_from_spec(_spec)
sys.modules["lastfm"] = _mod
_spec.loader.exec_module(_mod)
# -- Fakes -------------------------------------------------------------------
class _FakeRegistry:
def __init__(self):
self._modules: dict = {}
self._bots: dict = {}
class _FakeBot:
def __init__(self, *, api_key: str = "test-key", name: str = "derp"):
self.replied: list[str] = []
self.config: dict = {"lastfm": {"api_key": api_key}} if api_key else {}
self._pstate: dict = {}
self._only_plugins: set[str] | None = None
self.registry = _FakeRegistry()
self._username = name
async def reply(self, message, text: str) -> None:
self.replied.append(text)
async def long_reply(self, message, lines: list[str], *,
label: str = "") -> None:
for line in lines:
self.replied.append(line)
class _Msg:
def __init__(self, text="!similar", nick="Alice", target="0",
is_channel=True):
self.text = text
self.nick = nick
self.target = target
self.is_channel = is_channel
self.prefix = nick
self.command = "PRIVMSG"
self.params = [target, text]
self.tags = {}
self.raw = {}
@dataclass(slots=True)
class _FakeTrack:
url: str = ""
title: str = ""
requester: str = ""
# -- API response fixtures ---------------------------------------------------
SIMILAR_ARTISTS_RESP = {
"similarartists": {
"artist": [
{"name": "Artist B", "match": "0.85"},
{"name": "Artist C", "match": "0.72"},
{"name": "Artist D", "match": "0.60"},
],
},
}
SIMILAR_TRACKS_RESP = {
"similartracks": {
"track": [
{"name": "Track X", "artist": {"name": "Artist B"}, "match": "0.9"},
{"name": "Track Y", "artist": {"name": "Artist C"}, "match": "0.7"},
],
},
}
TOP_TAGS_RESP = {
"toptags": {
"tag": [
{"name": "rock", "count": 100},
{"name": "alternative", "count": 80},
{"name": "indie", "count": 60},
],
},
}
TRACK_SEARCH_RESP = {
"results": {
"trackmatches": {
"track": [
{"name": "Found Track", "artist": "Found Artist"},
{"name": "Another", "artist": "Someone"},
],
},
},
}
# ---------------------------------------------------------------------------
# TestGetApiKey
# ---------------------------------------------------------------------------
class TestGetApiKey:
def test_from_config(self):
bot = _FakeBot(api_key="cfg-key")
assert _mod._get_api_key(bot) == "cfg-key"
def test_from_env(self):
bot = _FakeBot(api_key="")
with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}):
assert _mod._get_api_key(bot) == "env-key"
def test_env_takes_priority(self):
bot = _FakeBot(api_key="cfg-key")
with patch.dict("os.environ", {"LASTFM_API_KEY": "env-key"}):
assert _mod._get_api_key(bot) == "env-key"
def test_empty_when_unset(self):
bot = _FakeBot(api_key="")
with patch.dict("os.environ", {}, clear=True):
assert _mod._get_api_key(bot) == ""
# ---------------------------------------------------------------------------
# TestApiCall
# ---------------------------------------------------------------------------
class TestApiCall:
def test_parses_json(self):
resp = MagicMock()
resp.read.return_value = b'{"result": "ok"}'
with patch.object(_mod, "urlopen", create=True, return_value=resp):
# _api_call imports urlopen from derp.http at call time
with patch("derp.http.urlopen", return_value=resp):
data = _mod._api_call("key", "artist.getSimilar", artist="X")
assert data == {"result": "ok"}
def test_returns_empty_on_error(self):
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
data = _mod._api_call("key", "artist.getSimilar", artist="X")
assert data == {}
# ---------------------------------------------------------------------------
# TestGetSimilarArtists
# ---------------------------------------------------------------------------
class TestGetSimilarArtists:
def test_returns_list(self):
with patch.object(_mod, "_api_call", return_value=SIMILAR_ARTISTS_RESP):
result = _mod._get_similar_artists("key", "Artist A")
assert len(result) == 3
assert result[0]["name"] == "Artist B"
def test_single_dict_wrapped(self):
"""Single artist result (dict instead of list) gets wrapped."""
data = {"similarartists": {"artist": {"name": "Solo", "match": "1.0"}}}
with patch.object(_mod, "_api_call", return_value=data):
result = _mod._get_similar_artists("key", "X")
assert len(result) == 1
assert result[0]["name"] == "Solo"
def test_empty_response(self):
with patch.object(_mod, "_api_call", return_value={}):
result = _mod._get_similar_artists("key", "X")
assert result == []
def test_missing_key(self):
with patch.object(_mod, "_api_call", return_value={"error": 6}):
result = _mod._get_similar_artists("key", "X")
assert result == []
# ---------------------------------------------------------------------------
# TestGetTopTags
# ---------------------------------------------------------------------------
class TestGetTopTags:
def test_returns_list(self):
with patch.object(_mod, "_api_call", return_value=TOP_TAGS_RESP):
result = _mod._get_top_tags("key", "Artist A")
assert len(result) == 3
assert result[0]["name"] == "rock"
def test_single_dict_wrapped(self):
data = {"toptags": {"tag": {"name": "electronic", "count": 50}}}
with patch.object(_mod, "_api_call", return_value=data):
result = _mod._get_top_tags("key", "X")
assert len(result) == 1
def test_empty_response(self):
with patch.object(_mod, "_api_call", return_value={}):
result = _mod._get_top_tags("key", "X")
assert result == []
# ---------------------------------------------------------------------------
# TestGetSimilarTracks
# ---------------------------------------------------------------------------
class TestGetSimilarTracks:
def test_returns_list(self):
with patch.object(_mod, "_api_call", return_value=SIMILAR_TRACKS_RESP):
result = _mod._get_similar_tracks("key", "A", "T")
assert len(result) == 2
assert result[0]["name"] == "Track X"
def test_single_dict_wrapped(self):
data = {"similartracks": {"track": {"name": "Solo", "artist": {"name": "X"}}}}
with patch.object(_mod, "_api_call", return_value=data):
result = _mod._get_similar_tracks("key", "X", "T")
assert len(result) == 1
def test_empty_response(self):
with patch.object(_mod, "_api_call", return_value={}):
result = _mod._get_similar_tracks("key", "X", "T")
assert result == []
# ---------------------------------------------------------------------------
# TestSearchTrack
# ---------------------------------------------------------------------------
class TestSearchTrack:
def test_returns_results(self):
with patch.object(_mod, "_api_call", return_value=TRACK_SEARCH_RESP):
result = _mod._search_track("key", "test")
assert len(result) == 2
assert result[0]["name"] == "Found Track"
def test_single_dict_wrapped(self):
data = {"results": {"trackmatches": {
"track": {"name": "One", "artist": "X"},
}}}
with patch.object(_mod, "_api_call", return_value=data):
result = _mod._search_track("key", "test")
assert len(result) == 1
def test_empty_response(self):
with patch.object(_mod, "_api_call", return_value={}):
result = _mod._search_track("key", "test")
assert result == []
# ---------------------------------------------------------------------------
# TestCurrentMeta
# ---------------------------------------------------------------------------
class TestCurrentMeta:
def test_no_music_state(self):
bot = _FakeBot()
assert _mod._current_meta(bot) == ("", "")
def test_no_current_track(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": None}
assert _mod._current_meta(bot) == ("", "")
def test_dash_separator(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="Tool - Lateralus")}
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
def test_double_dash_separator(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="Tool -- Lateralus")}
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
def test_pipe_separator(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="Tool | Lateralus")}
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
def test_tilde_separator(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="Tool ~ Lateralus")}
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
def test_no_separator(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="Lateralus")}
assert _mod._current_meta(bot) == ("", "Lateralus")
def test_empty_title(self):
bot = _FakeBot()
bot._pstate["music"] = {"current": _FakeTrack(title="")}
assert _mod._current_meta(bot) == ("", "")
def test_strips_whitespace(self):
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title=" Tool - Lateralus "),
}
assert _mod._current_meta(bot) == ("Tool", "Lateralus")
def test_peer_bot_music_state(self):
"""Extra bot sees music state from peer bot via shared registry."""
music_bot = _FakeBot()
music_bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
}
extra_bot = _FakeBot()
# No music state on extra_bot
# Share registry with _bots index
shared_reg = _FakeRegistry()
shared_reg._bots = {"derp": music_bot, "merlin": extra_bot}
music_bot.registry = shared_reg
extra_bot.registry = shared_reg
assert _mod._current_meta(extra_bot) == ("Tool", "Lateralus")
def test_peer_bot_no_music(self):
"""Returns empty when no bot has music state."""
bot_a = _FakeBot()
bot_b = _FakeBot()
shared_reg = _FakeRegistry()
shared_reg._bots = {"a": bot_a, "b": bot_b}
bot_a.registry = shared_reg
bot_b.registry = shared_reg
assert _mod._current_meta(bot_a) == ("", "")
# ---------------------------------------------------------------------------
# TestFmtMatch
# ---------------------------------------------------------------------------
class TestFmtMatch:
def test_float_score(self):
assert _mod._fmt_match(0.85) == "85%"
def test_string_score(self):
assert _mod._fmt_match("0.72") == "72%"
def test_one(self):
assert _mod._fmt_match(1.0) == "100%"
def test_zero(self):
assert _mod._fmt_match(0.0) == "0%"
def test_invalid(self):
assert _mod._fmt_match("bad") == ""
def test_none(self):
assert _mod._fmt_match(None) == ""
# ---------------------------------------------------------------------------
# TestCmdSimilar
# ---------------------------------------------------------------------------
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:
def test_no_api_key_mb_list_fallback(self):
"""No API key + list mode falls back to MusicBrainz for results."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar list Tool")
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
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", "metal"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Similar to Tool" in r for r in bot.replied)
assert any("MB Artist" in r for r in bot.replied)
def test_no_api_key_mb_no_results(self):
"""No API key + MusicBrainz returns nothing shows 'no similar'."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!similar Tool")
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value=None):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("No similar artists" in r for r in bot.replied)
def test_no_artist_nothing_playing(self):
bot = _FakeBot()
msg = _Msg(text="!similar")
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Nothing playing" in r for r in bot.replied)
def test_list_artist_shows_similar(self):
"""!similar list <artist> shows similar artists (display only)."""
bot = _FakeBot()
msg = _Msg(text="!similar list Tool")
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists",
return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Similar to Tool" in r for r in bot.replied)
assert any("Artist B" in r for r in bot.replied)
def test_list_track_level(self):
"""!similar list with track results shows track similarity."""
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
}
msg = _Msg(text="!similar list")
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("Similar to Tool - Lateralus" in r for r in bot.replied)
assert any("Track X" in r for r in bot.replied)
def test_list_falls_back_to_artist(self):
"""!similar list falls back to artist similarity when no track results."""
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
}
msg = _Msg(text="!similar list")
artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"]
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 any("Similar to Tool" in r for r in bot.replied)
def test_no_similar_found(self):
bot = _FakeBot()
msg = _Msg(text="!similar Obscure Band")
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
with patch.object(_mod, "_get_similar_artists", return_value=[]):
asyncio.run(_mod.cmd_similar(bot, msg))
assert any("No similar artists" in r for r in bot.replied)
def test_list_match_score_displayed(self):
bot = _FakeBot()
msg = _Msg(text="!similar list Tool")
artists = [{"name": "Deftones", "match": "0.85"}]
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 any("85%" in r for r in bot.replied)
def test_list_current_track_no_separator(self):
"""Title without separator uses whole title as search artist."""
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Lateralus"),
}
msg = _Msg(text="!similar list")
artists = [{"name": "APC", "match": "0.7"}]
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 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
# ---------------------------------------------------------------------------
class TestCmdTags:
def test_no_api_key_mb_fallback(self):
"""No API key falls back to MusicBrainz for tags."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!tags Tool")
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", "progressive metal", "art rock"]):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("Tool:" in r for r in bot.replied)
assert any("rock" in r for r in bot.replied)
assert any("progressive metal" in r for r in bot.replied)
def test_no_api_key_mb_no_results(self):
"""No API key + MusicBrainz returns nothing shows 'no tags'."""
bot = _FakeBot(api_key="")
msg = _Msg(text="!tags Obscure")
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist",
return_value=None):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("No tags found" in r for r in bot.replied)
def test_no_artist_nothing_playing(self):
bot = _FakeBot()
msg = _Msg(text="!tags")
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("Nothing playing" in r for r in bot.replied)
def test_shows_tags(self):
bot = _FakeBot()
msg = _Msg(text="!tags Tool")
tags = TOP_TAGS_RESP["toptags"]["tag"]
with patch.object(_mod, "_get_top_tags", return_value=tags):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("rock" in r for r in bot.replied)
assert any("alternative" in r for r in bot.replied)
assert any("Tool:" in r for r in bot.replied)
def test_no_tags_found(self):
bot = _FakeBot()
msg = _Msg(text="!tags Obscure")
with patch.object(_mod, "_get_top_tags", return_value=[]):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("No tags found" in r for r in bot.replied)
def test_from_current_track(self):
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Tool - Lateralus"),
}
msg = _Msg(text="!tags")
tags = [{"name": "prog metal", "count": 100}]
with patch.object(_mod, "_get_top_tags", return_value=tags):
asyncio.run(_mod.cmd_tags(bot, msg))
assert any("Tool:" in r for r in bot.replied)
assert any("prog metal" in r for r in bot.replied)
def test_from_current_no_separator(self):
"""Uses full title as artist when no separator."""
bot = _FakeBot()
bot._pstate["music"] = {
"current": _FakeTrack(title="Lateralus"),
}
msg = _Msg(text="!tags")
tags = [{"name": "rock", "count": 50}]
with patch.object(_mod, "_get_top_tags", return_value=tags):
asyncio.run(_mod.cmd_tags(bot, msg))
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
# ---------------------------------------------------------------------------
class TestParseTitle:
def test_dash_separator(self):
assert _mod._parse_title("Tool - Lateralus") == ("Tool", "Lateralus")
def test_double_dash(self):
assert _mod._parse_title("Tool -- Lateralus") == ("Tool", "Lateralus")
def test_pipe_separator(self):
assert _mod._parse_title("Tool | Lateralus") == ("Tool", "Lateralus")
def test_tilde_separator(self):
assert _mod._parse_title("Tool ~ Lateralus") == ("Tool", "Lateralus")
def test_no_separator(self):
assert _mod._parse_title("Lateralus") == ("", "Lateralus")
def test_empty_string(self):
assert _mod._parse_title("") == ("", "")
def test_strips_whitespace(self):
assert _mod._parse_title(" Tool - Lateralus ") == ("Tool", "Lateralus")
def test_first_separator_wins(self):
"""Only the first matching separator is used."""
assert _mod._parse_title("A - B - C") == ("A", "B - C")
def test_dash_priority_over_pipe(self):
"""Dash separator is tried before pipe."""
assert _mod._parse_title("A - B | C") == ("A", "B | C")
# ---------------------------------------------------------------------------
# TestDiscoverSimilar
# ---------------------------------------------------------------------------
class TestDiscoverSimilar:
def test_lastfm_path(self):
"""Returns Last.fm result when API key + results available."""
bot = _FakeBot(api_key="test-key")
tracks = [{"name": "Found", "artist": {"name": "Band"}, "match": "0.9"}]
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result == ("Band", "Found")
def test_lastfm_empty_falls_to_musicbrainz(self):
"""Falls back to MusicBrainz when Last.fm returns nothing."""
bot = _FakeBot(api_key="test-key")
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result == ("MB Artist", "MB Song")
def test_no_api_key_uses_musicbrainz(self):
"""Skips Last.fm when no API key, goes straight to MusicBrainz."""
bot = _FakeBot(api_key="")
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
with patch.dict("os.environ", {}, clear=True), \
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result == ("MB Band", "MB Track")
def test_both_fail_returns_none(self):
"""Returns None when both Last.fm and MusicBrainz fail."""
bot = _FakeBot(api_key="test-key")
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
patch("plugins._musicbrainz.mb_search_artist", return_value=None):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result is None
def test_no_artist_returns_none(self):
"""Returns None when title has no artist component."""
bot = _FakeBot(api_key="test-key")
result = asyncio.run(
_mod.discover_similar(bot, "Lateralus"),
)
assert result is None
def test_musicbrainz_import_error_handled(self):
"""Gracefully handles import error for _musicbrainz module."""
bot = _FakeBot(api_key="test-key")
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
patch.dict("sys.modules", {"plugins._musicbrainz": None}):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result is None
def test_lastfm_exception_falls_to_musicbrainz(self):
"""Last.fm exception triggers MusicBrainz fallback."""
bot = _FakeBot(api_key="test-key")
mb_picks = [{"artist": "Fallback", "title": "Song"}]
with patch.object(_mod, "_get_similar_tracks",
side_effect=Exception("API down")), \
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result == ("Fallback", "Song")
def test_lastfm_pick_missing_name_falls_to_musicbrainz(self):
"""Falls to MB when Last.fm result has empty artist/title."""
bot = _FakeBot(api_key="test-key")
tracks = [{"name": "", "artist": {"name": ""}, "match": "0.9"}]
mb_picks = [{"artist": "MB", "title": "Track"}]
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
patch("plugins._musicbrainz.mb_search_artist", return_value="mbid"), \
patch("plugins._musicbrainz.mb_artist_tags", return_value=["rock"]), \
patch("plugins._musicbrainz.mb_find_similar_recordings",
return_value=mb_picks):
result = asyncio.run(
_mod.discover_similar(bot, "Tool - Lateralus"),
)
assert result == ("MB", "Track")

498
tests/test_mumble_admin.py Normal file
View File

@@ -0,0 +1,498 @@
"""Tests for the mumble_admin plugin."""
import asyncio
import importlib.util
from dataclasses import dataclass, field
from unittest.mock import MagicMock
# -- Load plugin module directly ---------------------------------------------
_spec = importlib.util.spec_from_file_location(
"mumble_admin", "plugins/mumble_admin.py",
)
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)
cmd_mu = _mod.cmd_mu
_find_user = _mod._find_user
_find_channel = _mod._find_channel
_channel_name = _mod._channel_name
# -- Fakes -------------------------------------------------------------------
@dataclass
class _FakeMessage:
text: str = ""
nick: str = "admin"
prefix: str = "admin"
target: str = "0"
is_channel: bool = True
params: list[str] = field(default_factory=list)
class _FakeRegistry:
_bots: dict = field(default_factory=dict)
def __init__(self):
self._bots = {}
class _FakeBot:
def __init__(self, users=None, channels=None):
self.registry = _FakeRegistry()
self._mumble = MagicMock()
if users is not None:
self._mumble.users = users
else:
self._mumble.users = {}
if channels is not None:
self._mumble.channels = channels
self._replies: list[str] = []
async def reply(self, message, text):
self._replies.append(text)
async def send(self, target, text):
self._replies.append(text)
def _make_user(name, channel_id=0, mute=False, deaf=False,
self_mute=False, self_deaf=False):
"""Create a fake pymumble user (dict with methods)."""
u = MagicMock()
u.__getitem__ = lambda s, k: {
"name": name,
"channel_id": channel_id,
"mute": mute,
"deaf": deaf,
"self_mute": self_mute,
"self_deaf": self_deaf,
}[k]
u.get = lambda k, d=None: {
"name": name,
"channel_id": channel_id,
"mute": mute,
"deaf": deaf,
"self_mute": self_mute,
"self_deaf": self_deaf,
}.get(k, d)
return u
def _make_channel(name, channel_id=0, parent=0):
"""Create a fake pymumble channel (dict with methods)."""
c = MagicMock()
c.__getitem__ = lambda s, k: {
"name": name,
"channel_id": channel_id,
"parent": parent,
}[k]
c.get = lambda k, d=None: {
"name": name,
"channel_id": channel_id,
"parent": parent,
}.get(k, d)
return c
# -- TestFindUser ------------------------------------------------------------
class TestFindUser:
def test_case_insensitive(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice})
assert _find_user(bot, "alice") is alice
assert _find_user(bot, "ALICE") is alice
assert _find_user(bot, "Alice") is alice
def test_not_found(self):
bot = _FakeBot(users={1: _make_user("Alice")})
assert _find_user(bot, "Bob") is None
def test_no_mumble(self):
bot = _FakeBot()
bot._mumble = None
assert _find_user(bot, "anyone") is None
# -- TestFindChannel ---------------------------------------------------------
class TestFindChannel:
def test_case_insensitive(self):
lobby = _make_channel("Lobby", channel_id=0)
bot = _FakeBot(channels={0: lobby})
assert _find_channel(bot, "lobby") is lobby
assert _find_channel(bot, "LOBBY") is lobby
def test_not_found(self):
bot = _FakeBot(channels={0: _make_channel("Lobby")})
assert _find_channel(bot, "AFK") is None
def test_no_mumble(self):
bot = _FakeBot()
bot._mumble = None
assert _find_channel(bot, "any") is None
# -- TestChannelName ---------------------------------------------------------
class TestChannelName:
def test_resolves(self):
lobby = _make_channel("Lobby", channel_id=0)
bot = _FakeBot(channels={0: lobby})
assert _channel_name(bot, 0) == "Lobby"
def test_missing_returns_id(self):
bot = _FakeBot(channels={})
assert _channel_name(bot, 42) == "42"
def test_no_mumble(self):
bot = _FakeBot()
bot._mumble = None
assert _channel_name(bot, 5) == "5"
# -- TestDispatch ------------------------------------------------------------
class TestDispatch:
def test_no_args_shows_usage(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu")
asyncio.run(cmd_mu(bot, msg))
assert len(bot._replies) == 1
assert "Usage" in bot._replies[0]
def test_unknown_sub_shows_usage(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu bogus")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]
def test_valid_sub_routes(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu kick Alice")
asyncio.run(cmd_mu(bot, msg))
alice.kick.assert_called_once_with("")
assert "Kicked" in bot._replies[0]
# -- TestKick ----------------------------------------------------------------
class TestKick:
def test_kick_user(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu kick Alice")
asyncio.run(cmd_mu(bot, msg))
alice.kick.assert_called_once_with("")
assert "Kicked Alice" in bot._replies[0]
def test_kick_with_reason(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu kick Alice being rude")
asyncio.run(cmd_mu(bot, msg))
alice.kick.assert_called_once_with("being rude")
def test_kick_user_not_found(self):
bot = _FakeBot(users={})
msg = _FakeMessage(text="!mu kick Ghost")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
def test_kick_no_args(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu kick")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]
# -- TestBan -----------------------------------------------------------------
class TestBan:
def test_ban_user(self):
bob = _make_user("Bob")
bot = _FakeBot(users={1: bob})
msg = _FakeMessage(text="!mu ban Bob")
asyncio.run(cmd_mu(bot, msg))
bob.ban.assert_called_once_with("")
assert "Banned Bob" in bot._replies[0]
def test_ban_with_reason(self):
bob = _make_user("Bob")
bot = _FakeBot(users={1: bob})
msg = _FakeMessage(text="!mu ban Bob spamming")
asyncio.run(cmd_mu(bot, msg))
bob.ban.assert_called_once_with("spamming")
def test_ban_user_not_found(self):
bot = _FakeBot(users={})
msg = _FakeMessage(text="!mu ban Ghost")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
# -- TestMuteUnmute ----------------------------------------------------------
class TestMuteUnmute:
def test_mute(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu mute Alice")
asyncio.run(cmd_mu(bot, msg))
alice.mute.assert_called_once()
assert "Muted" in bot._replies[0]
def test_unmute(self):
alice = _make_user("Alice", mute=True)
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu unmute Alice")
asyncio.run(cmd_mu(bot, msg))
alice.unmute.assert_called_once()
assert "Unmuted" in bot._replies[0]
def test_mute_not_found(self):
bot = _FakeBot(users={})
msg = _FakeMessage(text="!mu mute Nobody")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
def test_unmute_not_found(self):
bot = _FakeBot(users={})
msg = _FakeMessage(text="!mu unmute Nobody")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
# -- TestDeafenUndeafen ------------------------------------------------------
class TestDeafenUndeafen:
def test_deafen(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu deafen Alice")
asyncio.run(cmd_mu(bot, msg))
alice.deafen.assert_called_once()
assert "Deafened" in bot._replies[0]
def test_undeafen(self):
alice = _make_user("Alice", deaf=True)
bot = _FakeBot(users={1: alice})
msg = _FakeMessage(text="!mu undeafen Alice")
asyncio.run(cmd_mu(bot, msg))
alice.undeafen.assert_called_once()
assert "Undeafened" in bot._replies[0]
def test_deafen_not_found(self):
bot = _FakeBot(users={})
msg = _FakeMessage(text="!mu deafen Nobody")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
def test_undeafen_not_found(self):
bot = _FakeBot(users={})
msg = _FakeMessage(text="!mu undeafen Nobody")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
# -- TestMove ----------------------------------------------------------------
class TestMove:
def test_move_user(self):
alice = _make_user("Alice", channel_id=0)
afk = _make_channel("AFK", channel_id=5)
bot = _FakeBot(users={1: alice}, channels={0: _make_channel("Root"), 5: afk})
msg = _FakeMessage(text="!mu move Alice AFK")
asyncio.run(cmd_mu(bot, msg))
alice.move_in.assert_called_once_with(5)
assert "Moved Alice to AFK" in bot._replies[0]
def test_move_user_not_found(self):
bot = _FakeBot(users={}, channels={5: _make_channel("AFK", channel_id=5)})
msg = _FakeMessage(text="!mu move Ghost AFK")
asyncio.run(cmd_mu(bot, msg))
assert "user not found" in bot._replies[0].lower()
def test_move_channel_not_found(self):
alice = _make_user("Alice")
bot = _FakeBot(users={1: alice}, channels={})
msg = _FakeMessage(text="!mu move Alice Nowhere")
asyncio.run(cmd_mu(bot, msg))
assert "channel not found" in bot._replies[0].lower()
def test_move_missing_args(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu move Alice")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]
# -- TestUsers ---------------------------------------------------------------
class TestUsers:
def test_list_users(self):
alice = _make_user("Alice", channel_id=0)
bob = _make_user("Bob", channel_id=0, self_mute=True)
lobby = _make_channel("Lobby", channel_id=0)
bot = _FakeBot(users={1: alice, 2: bob}, channels={0: lobby})
msg = _FakeMessage(text="!mu users")
asyncio.run(cmd_mu(bot, msg))
reply = bot._replies[0]
assert "2 user(s)" in reply
assert "Alice" in reply
assert "Bob" in reply
assert "muted" in reply
def test_list_with_bots(self):
alice = _make_user("Alice", channel_id=0)
derp = _make_user("derp", channel_id=0)
lobby = _make_channel("Lobby", channel_id=0)
bot = _FakeBot(users={1: alice, 2: derp}, channels={0: lobby})
bot.registry._bots = {"derp": MagicMock()}
msg = _FakeMessage(text="!mu users")
asyncio.run(cmd_mu(bot, msg))
reply = bot._replies[0]
assert "bot" in reply
assert "2 user(s)" in reply
def test_deaf_flag(self):
alice = _make_user("Alice", channel_id=0, self_deaf=True)
lobby = _make_channel("Lobby", channel_id=0)
bot = _FakeBot(users={1: alice}, channels={0: lobby})
msg = _FakeMessage(text="!mu users")
asyncio.run(cmd_mu(bot, msg))
assert "deaf" in bot._replies[0]
# -- TestChannels ------------------------------------------------------------
class TestChannels:
def test_list_channels(self):
lobby = _make_channel("Lobby", channel_id=0)
afk = _make_channel("AFK", channel_id=1)
alice = _make_user("Alice", channel_id=0)
bot = _FakeBot(users={1: alice}, channels={0: lobby, 1: afk})
msg = _FakeMessage(text="!mu channels")
asyncio.run(cmd_mu(bot, msg))
reply = bot._replies[0]
assert "Lobby (1)" in reply
assert "AFK (0)" in reply
# -- TestMkchan --------------------------------------------------------------
class TestMkchan:
def test_create_channel(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu mkchan Gaming")
asyncio.run(cmd_mu(bot, msg))
bot._mumble.channels.new_channel.assert_called_once_with(
0, "Gaming", temporary=False,
)
assert "Created" in bot._replies[0]
def test_create_temp_channel(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu mkchan Gaming temp")
asyncio.run(cmd_mu(bot, msg))
bot._mumble.channels.new_channel.assert_called_once_with(
0, "Gaming", temporary=True,
)
assert "temporary" in bot._replies[0]
def test_missing_name(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu mkchan")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]
# -- TestRmchan --------------------------------------------------------------
class TestRmchan:
def test_remove_channel(self):
afk = _make_channel("AFK", channel_id=5)
bot = _FakeBot(channels={5: afk})
msg = _FakeMessage(text="!mu rmchan AFK")
asyncio.run(cmd_mu(bot, msg))
afk.remove.assert_called_once()
assert "Removed" in bot._replies[0]
def test_channel_not_found(self):
bot = _FakeBot(channels={})
msg = _FakeMessage(text="!mu rmchan Nowhere")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
def test_missing_args(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu rmchan")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]
# -- TestRename --------------------------------------------------------------
class TestRename:
def test_rename_channel(self):
afk = _make_channel("AFK", channel_id=5)
bot = _FakeBot(channels={5: afk})
msg = _FakeMessage(text="!mu rename AFK Chill")
asyncio.run(cmd_mu(bot, msg))
afk.rename_channel.assert_called_once_with("Chill")
assert "Renamed" in bot._replies[0]
def test_channel_not_found(self):
bot = _FakeBot(channels={})
msg = _FakeMessage(text="!mu rename Nowhere New")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
def test_missing_args(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu rename AFK")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]
# -- TestDesc ----------------------------------------------------------------
class TestDesc:
def test_set_description(self):
afk = _make_channel("AFK", channel_id=5)
bot = _FakeBot(channels={5: afk})
msg = _FakeMessage(text="!mu desc AFK Away from keyboard")
asyncio.run(cmd_mu(bot, msg))
afk.set_channel_description.assert_called_once_with("Away from keyboard")
assert "description" in bot._replies[0].lower()
def test_channel_not_found(self):
bot = _FakeBot(channels={})
msg = _FakeMessage(text="!mu desc Nowhere some text")
asyncio.run(cmd_mu(bot, msg))
assert "not found" in bot._replies[0].lower()
def test_missing_args(self):
bot = _FakeBot()
msg = _FakeMessage(text="!mu desc AFK")
asyncio.run(cmd_mu(bot, msg))
assert "Usage" in bot._replies[0]

View File

@@ -956,7 +956,7 @@ class TestDuckCommand:
msg = _Msg(text="!duck")
asyncio.run(_mod.cmd_duck(bot, msg))
assert any("Duck:" in r for r in bot.replied)
assert any("floor=1%" in r for r in bot.replied)
assert any("floor=2%" in r for r in bot.replied)
assert any("restore=30s" in r for r in bot.replied)
def test_toggle_on(self):
@@ -2362,3 +2362,245 @@ class TestKeptRepair:
stored = json.loads(raw)
assert stored["filename"] == "song.webm"
assert (music_dir / "song.webm").is_file()
# ---------------------------------------------------------------------------
# TestAutoplayDiscovery
# ---------------------------------------------------------------------------
class TestAutoplayDiscovery:
"""Tests for the discovery integration in _play_loop autoplay."""
def test_config_defaults(self):
"""Default discover/discover_ratio values are set."""
bot = _FakeBot()
ps = _mod._ps(bot)
assert ps["discover"] is True
assert ps["discover_ratio"] == 3
def test_config_from_toml(self):
"""Config values are read from bot config."""
bot = _FakeBot()
bot.config = {"music": {"discover": False, "discover_ratio": 5}}
# Reset pstate so _ps re-reads config
bot._pstate.clear()
ps = _mod._ps(bot)
assert ps["discover"] is False
assert ps["discover_ratio"] == 5
def test_discovery_triggers_on_ratio(self, tmp_path):
"""Discovery is attempted when autoplay_count is a multiple of ratio."""
bot = _FakeBot()
ps = _mod._ps(bot)
ps["autoplay"] = True
ps["discover"] = True
ps["discover_ratio"] = 1 # trigger every pick
ps["autoplay_cooldown"] = 0
ps["duck_silence"] = 0
# Seed history so discovery has something to reference
ps["history"] = [
_mod._Track(url="x", title="Tool - Lateralus", requester="a"),
]
# Set up kept tracks for fallback pool
music_dir = tmp_path / "music"
music_dir.mkdir()
(music_dir / "a.opus").write_bytes(b"audio")
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/a", "title": "Kept Track",
"filename": "a.opus", "id": 1,
}))
discover_called = []
async def fake_discover(b, title):
discover_called.append(title)
return ("Deftones", "Change")
lastfm_mod = MagicMock()
lastfm_mod.discover_similar = fake_discover
bot.registry._modules = {"lastfm": lastfm_mod}
resolved = [("https://youtube.com/watch?v=x", "Deftones - Change")]
async def _run():
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
patch.object(_mod, "_resolve_tracks", return_value=resolved), \
patch.object(_mod, "_download_track", return_value=None):
task = asyncio.create_task(
_mod._play_loop(bot, seek=0.0, fade_in=False),
)
# Let it pick a track, then cancel
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_run())
assert len(discover_called) >= 1
assert discover_called[0] == "Tool - Lateralus"
def test_discovery_disabled(self, tmp_path):
"""Discovery is skipped when discover=False."""
bot = _FakeBot()
ps = _mod._ps(bot)
ps["autoplay"] = True
ps["discover"] = False
ps["discover_ratio"] = 1
ps["autoplay_cooldown"] = 0
ps["duck_silence"] = 0
ps["history"] = [
_mod._Track(url="x", title="Tool - Lateralus", requester="a"),
]
music_dir = tmp_path / "music"
music_dir.mkdir()
(music_dir / "a.opus").write_bytes(b"audio")
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/a", "title": "Kept Track",
"filename": "a.opus", "id": 1,
}))
discover_called = []
async def fake_discover(b, title):
discover_called.append(title)
return ("X", "Y")
lastfm_mod = MagicMock()
lastfm_mod.discover_similar = fake_discover
bot.registry._modules = {"lastfm": lastfm_mod}
async def _run():
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
patch.object(_mod, "_download_track", return_value=None):
task = asyncio.create_task(
_mod._play_loop(bot, seek=0.0, fade_in=False),
)
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_run())
assert discover_called == []
def test_discovery_dedup(self):
"""Same discovered track is not resolved twice (dedup by seen set)."""
# Unit-test the dedup logic directly: simulate the set-based
# deduplication that _play_loop uses with _discover_seen.
_discover_seen: set[str] = set()
def _would_resolve(artist: str, title: str) -> bool:
key = f"{artist.lower()}:{title.lower()}"
if key in _discover_seen:
return False
_discover_seen.add(key)
return True
assert _would_resolve("Deftones", "Change") is True
assert _would_resolve("Deftones", "Change") is False
assert _would_resolve("deftones", "change") is False
assert _would_resolve("Tool", "Sober") is True
def test_discovery_fallback_to_kept(self, tmp_path):
"""Falls back to kept deck when discovery returns None."""
bot = _FakeBot()
ps = _mod._ps(bot)
ps["autoplay"] = True
ps["discover"] = True
ps["discover_ratio"] = 1
ps["autoplay_cooldown"] = 0
ps["duck_silence"] = 0
ps["history"] = [
_mod._Track(url="x", title="Tool - Lateralus", requester="a"),
]
music_dir = tmp_path / "music"
music_dir.mkdir()
(music_dir / "a.opus").write_bytes(b"audio")
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/a", "title": "Kept Track",
"filename": "a.opus", "id": 1,
}))
async def fake_discover(b, title):
return None
lastfm_mod = MagicMock()
lastfm_mod.discover_similar = fake_discover
bot.registry._modules = {"lastfm": lastfm_mod}
queued_titles = []
async def _run():
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
patch.object(_mod, "_download_track", return_value=None):
task = asyncio.create_task(
_mod._play_loop(bot, seek=0.0, fade_in=False),
)
await asyncio.sleep(0.5)
# Check what was queued -- should be kept track, not discovered
if ps.get("current"):
queued_titles.append(ps["current"].title)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_run())
# The kept track should have been used as fallback
if queued_titles:
assert queued_titles[0] == "Kept Track"
def test_no_history_skips_discovery(self, tmp_path):
"""Discovery is skipped when history is empty."""
bot = _FakeBot()
ps = _mod._ps(bot)
ps["autoplay"] = True
ps["discover"] = True
ps["discover_ratio"] = 1
ps["autoplay_cooldown"] = 0
ps["duck_silence"] = 0
ps["history"] = []
music_dir = tmp_path / "music"
music_dir.mkdir()
(music_dir / "a.opus").write_bytes(b"audio")
bot.state.set("music", "keep:1", json.dumps({
"url": "https://example.com/a", "title": "Kept Track",
"filename": "a.opus", "id": 1,
}))
discover_called = []
async def fake_discover(b, title):
discover_called.append(title)
return ("X", "Y")
lastfm_mod = MagicMock()
lastfm_mod.discover_similar = fake_discover
bot.registry._modules = {"lastfm": lastfm_mod}
async def _run():
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
patch.object(_mod, "_download_track", return_value=None):
task = asyncio.create_task(
_mod._play_loop(bot, seek=0.0, fade_in=False),
)
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
asyncio.run(_run())
assert discover_called == []

310
tests/test_musicbrainz.py Normal file
View File

@@ -0,0 +1,310 @@
"""Tests for the MusicBrainz API helper module."""
import importlib.util
import json
import sys
import time
from unittest.mock import MagicMock, patch
# -- Load module directly ----------------------------------------------------
_spec = importlib.util.spec_from_file_location(
"_musicbrainz", "plugins/_musicbrainz.py",
)
_mod = importlib.util.module_from_spec(_spec)
sys.modules["_musicbrainz"] = _mod
_spec.loader.exec_module(_mod)
# -- Helpers -----------------------------------------------------------------
def _make_resp(data: dict) -> MagicMock:
"""Create a fake HTTP response with JSON body."""
resp = MagicMock()
resp.read.return_value = json.dumps(data).encode()
return resp
# ---------------------------------------------------------------------------
# TestMbRequest
# ---------------------------------------------------------------------------
class TestMbRequest:
def setup_method(self):
_mod._last_request = 0.0
def test_returns_parsed_json(self):
resp = _make_resp({"status": "ok"})
with patch("derp.http.urlopen", return_value=resp):
result = _mod._mb_request("artist", {"query": "Tool"})
assert result == {"status": "ok"}
def test_rate_delay_enforced(self):
"""Second call within rate interval triggers sleep."""
_mod._last_request = time.monotonic()
resp = _make_resp({})
slept = []
with patch("derp.http.urlopen", return_value=resp), \
patch.object(_mod.time, "sleep", side_effect=slept.append), \
patch.object(_mod.time, "monotonic", return_value=_mod._last_request + 0.2):
_mod._mb_request("artist", {"query": "X"})
assert len(slept) == 1
assert slept[0] > 0
def test_no_delay_when_interval_elapsed(self):
"""No sleep when enough time has passed since last request."""
_mod._last_request = time.monotonic() - 5.0
resp = _make_resp({})
with patch("derp.http.urlopen", return_value=resp), \
patch.object(_mod.time, "sleep") as mock_sleep:
_mod._mb_request("artist", {"query": "X"})
mock_sleep.assert_not_called()
def test_returns_empty_on_error(self):
with patch("derp.http.urlopen", side_effect=ConnectionError("fail")):
result = _mod._mb_request("artist", {"query": "X"})
assert result == {}
def test_updates_last_request_on_success(self):
_mod._last_request = 0.0
resp = _make_resp({})
with patch("derp.http.urlopen", return_value=resp):
_mod._mb_request("test")
assert _mod._last_request > 0
def test_updates_last_request_on_error(self):
_mod._last_request = 0.0
with patch("derp.http.urlopen", side_effect=Exception("boom")):
_mod._mb_request("test")
assert _mod._last_request > 0
def test_none_params(self):
"""Handles None params without error."""
resp = _make_resp({"ok": True})
with patch("derp.http.urlopen", return_value=resp):
result = _mod._mb_request("test", None)
assert result == {"ok": True}
# ---------------------------------------------------------------------------
# TestMbSearchArtist
# ---------------------------------------------------------------------------
class TestMbSearchArtist:
def setup_method(self):
_mod._last_request = 0.0
def test_returns_mbid(self):
data = {"artists": [{"id": "abc-123", "name": "Tool", "score": 100}]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_search_artist("Tool")
assert result == "abc-123"
def test_returns_none_no_results(self):
with patch.object(_mod, "_mb_request", return_value={"artists": []}):
assert _mod.mb_search_artist("Unknown") is None
def test_returns_none_on_empty_response(self):
with patch.object(_mod, "_mb_request", return_value={}):
assert _mod.mb_search_artist("X") is None
def test_returns_none_low_score(self):
"""Rejects matches with score below 50."""
data = {"artists": [{"id": "low", "name": "Mismatch", "score": 30}]}
with patch.object(_mod, "_mb_request", return_value=data):
assert _mod.mb_search_artist("Tool") is None
def test_returns_none_on_error(self):
with patch.object(_mod, "_mb_request", return_value={}):
assert _mod.mb_search_artist("Error") is None
def test_accepts_high_score(self):
data = {"artists": [{"id": "abc", "name": "Tool", "score": 85}]}
with patch.object(_mod, "_mb_request", return_value=data):
assert _mod.mb_search_artist("Tool") == "abc"
# ---------------------------------------------------------------------------
# TestMbArtistTags
# ---------------------------------------------------------------------------
class TestMbArtistTags:
def setup_method(self):
_mod._last_request = 0.0
def test_returns_sorted_top_5(self):
data = {"tags": [
{"name": "rock", "count": 50},
{"name": "metal", "count": 100},
{"name": "prog", "count": 80},
{"name": "alternative", "count": 60},
{"name": "hard rock", "count": 40},
{"name": "grunge", "count": 30},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_artist_tags("mbid-123")
assert len(result) == 5
assert result[0] == "metal"
assert result[1] == "prog"
assert result[2] == "alternative"
assert result[3] == "rock"
assert result[4] == "hard rock"
def test_empty_tags(self):
with patch.object(_mod, "_mb_request", return_value={"tags": []}):
assert _mod.mb_artist_tags("mbid") == []
def test_no_tags_key(self):
with patch.object(_mod, "_mb_request", return_value={}):
assert _mod.mb_artist_tags("mbid") == []
def test_skips_nameless_tags(self):
data = {"tags": [
{"name": "rock", "count": 50},
{"count": 100}, # no name
{"name": "", "count": 80}, # empty name
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_artist_tags("mbid")
assert result == ["rock"]
def test_fewer_than_5_tags(self):
data = {"tags": [{"name": "jazz", "count": 10}]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_artist_tags("mbid")
assert result == ["jazz"]
# ---------------------------------------------------------------------------
# TestMbFindSimilarRecordings
# ---------------------------------------------------------------------------
class TestMbFindSimilarRecordings:
def setup_method(self):
_mod._last_request = 0.0
def test_returns_dicts(self):
data = {"recordings": [
{
"title": "Song A",
"artist-credit": [{"name": "Other Artist"}],
},
{
"title": "Song B",
"artist-credit": [{"name": "Another Band"}],
},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_find_similar_recordings(
"Tool", ["rock", "metal"],
)
assert len(result) == 2
assert result[0] == {"artist": "Other Artist", "title": "Song A"}
assert result[1] == {"artist": "Another Band", "title": "Song B"}
def test_excludes_original_artist(self):
data = {"recordings": [
{
"title": "Own Song",
"artist-credit": [{"name": "Tool"}],
},
{
"title": "Other Song",
"artist-credit": [{"name": "Deftones"}],
},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_find_similar_recordings(
"Tool", ["rock"],
)
assert len(result) == 1
assert result[0]["artist"] == "Deftones"
def test_excludes_original_artist_case_insensitive(self):
data = {"recordings": [
{
"title": "Song",
"artist-credit": [{"name": "TOOL"}],
},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_find_similar_recordings(
"Tool", ["rock"],
)
assert result == []
def test_deduplicates(self):
data = {"recordings": [
{
"title": "Song A",
"artist-credit": [{"name": "Band X"}],
},
{
"title": "Song A",
"artist-credit": [{"name": "Band X"}],
},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_find_similar_recordings(
"Other", ["rock"],
)
assert len(result) == 1
def test_empty_tags(self):
result = _mod.mb_find_similar_recordings("Tool", [])
assert result == []
def test_no_recordings(self):
with patch.object(_mod, "_mb_request", return_value={"recordings": []}):
result = _mod.mb_find_similar_recordings(
"Tool", ["rock"],
)
assert result == []
def test_empty_response(self):
with patch.object(_mod, "_mb_request", return_value={}):
result = _mod.mb_find_similar_recordings(
"Tool", ["rock"],
)
assert result == []
def test_skips_missing_title(self):
data = {"recordings": [
{
"title": "",
"artist-credit": [{"name": "Band"}],
},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_find_similar_recordings(
"Other", ["rock"],
)
assert result == []
def test_skips_missing_artist_credit(self):
data = {"recordings": [
{"title": "Song", "artist-credit": []},
{"title": "Song2"},
]}
with patch.object(_mod, "_mb_request", return_value=data):
result = _mod.mb_find_similar_recordings(
"Other", ["rock"],
)
assert result == []
def test_uses_top_two_tags(self):
"""Query should use at most 2 tags."""
with patch.object(_mod, "_mb_request", return_value={}) as mock_req:
_mod.mb_find_similar_recordings(
"Tool", ["rock", "metal", "prog"],
)
call_args = mock_req.call_args
args = call_args[1] or {}
query = args.get("query") or call_args[0][1].get("query", "")
# Verify the query contains both tag references
assert "rock" in query or "metal" in query