diff --git a/README.md b/README.md
index fd287e6..0a76b9f 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,8 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound
- ๐ **Queue system** โ Full queue management with shuffle, loop, and history
- ๐๏ธ **Audio effects** โ Bass boost, nightcore, vaporwave, 8D audio, echo, and more
- ๐ค **Live lyrics** โ One message that follows the song line by line, karaoke-style
+- ๐๏ธ **Buttons** โ Pause, skip, previous, stop, loop, shuffle, queue and lyrics right under Now Playing
+- โก **Slash commands** โ Every command also works as `/command`, with search suggestions in `/play`
- ๐ **Volume control** โ Per-server volume adjustment
- ๐ **Loop modes** โ Loop a single track or the entire queue
- โถ๏ธ **Autoplay** โ Automatically queue related tracks when the queue ends
@@ -20,6 +22,14 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound
## ๐ Commands
+Every command below also works as a slash command: `/play`, `/skip`, `/effect`โฆ
+Aliases (`!p`, `!q`, `!bass`โฆ) are `!`-only.
+
+Each **Now Playing** message carries buttons for anyone in the bot's voice
+channel: โฎ โฏ โญ โน on the first row, ๐ (cycle loop) ๐ (shuffle) ๐ (queue,
+shown only to you) ๐ค (live lyrics) on the second. When the next song starts,
+the previous message's buttons are disabled.
+
### โถ๏ธ Playback
| Command | Description |
@@ -58,7 +68,7 @@ A fully-featured Discord music bot built with Python. Supports YouTube and Sound
| `!8d` | Apply 8D audio (use headphones!) |
| `!karaoke` | Remove center vocals |
| `!reset` | Remove all audio effects |
-| `!effect` | Show which effect is currently active |
+| `!effect [name]` | Show the active effect, or apply one by name (`/effect` lists them) |
| `!effects` | List all available effects |
### ๐ค Extras
diff --git a/cogs/admin.py b/cogs/admin.py
new file mode 100644
index 0000000..4c92b46
--- /dev/null
+++ b/cogs/admin.py
@@ -0,0 +1,28 @@
+import logging
+
+from discord.ext import commands
+
+from utils.context import GuildContext
+from utils.embeds import success_embed
+
+log = logging.getLogger("loopify.admin")
+
+
+class Admin(commands.Cog, name="Admin"):
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ # Not run at startup: Discord rate-limits syncing, and the bot restarts on
+ # its own (systemd, network loss). Run it once after a deploy that adds,
+ # removes or changes a command.
+ @commands.command(hidden=True)
+ @commands.is_owner()
+ async def sync(self, ctx: GuildContext) -> None:
+ """Register the slash commands with Discord."""
+ synced = await self.bot.tree.sync()
+ log.info("Synced %d slash commands", len(synced))
+ await ctx.send(embed=success_embed(f"Synced **{len(synced)}** slash commands."))
+
+
+async def setup(bot: commands.Bot) -> None:
+ await bot.add_cog(Admin(bot))
diff --git a/cogs/effects.py b/cogs/effects.py
index e67c396..702cf61 100644
--- a/cogs/effects.py
+++ b/cogs/effects.py
@@ -1,12 +1,14 @@
import time
from dataclasses import dataclass
+from typing import Optional
import discord
+from discord import app_commands
from discord.ext import commands
from config import COMMAND_PREFIX
-from utils.checks import same_voice_channel
-from utils.context import GuildContext
+from utils.checks import in_same_voice_channel, same_voice_channel
+from utils.context import GuildContext, hybrid_command
from utils.embeds import BLURPLE, error_embed, success_embed
from utils.player import players
@@ -75,13 +77,13 @@ async def _switch_to(self, ctx: GuildContext, name: str | None, filter_str: str,
if self._throttled(ctx):
await ctx.send(embed=error_embed(
f"Easy โ wait {_EFFECT_COOLDOWN:.0f}s between effect changes."
- ))
+ ), ephemeral=True)
return
player = players.get(ctx.guild.id)
if player and player.apply_effect(name, filter_str, rate):
await ctx.send(embed=success_embed(label))
else:
- await ctx.send(embed=error_embed("Nothing is playing."))
+ await ctx.send(embed=error_embed("Nothing is playing."), ephemeral=True)
@commands.command(name=_EFFECT_NAMES[0], aliases=_EFFECT_NAMES[1:],
help=f"Apply an audio effect. Use {COMMAND_PREFIX}effects to see "
@@ -93,27 +95,45 @@ async def apply_effect(self, ctx: GuildContext) -> None:
effect = EFFECTS[name]
await self._switch_to(ctx, name, effect.filter, effect.label, effect.rate)
- @commands.command(name="reset", aliases=["fxreset", "noeffect"])
+ @hybrid_command(name="reset", aliases=["fxreset", "noeffect"])
@same_voice_channel()
async def reset_effect(self, ctx: GuildContext) -> None:
"""Remove all audio effects."""
await self._switch_to(ctx, None, "", "Audio effects removed โ
")
- @commands.command(name="effect")
- async def current_effect(self, ctx: GuildContext) -> None:
- """Show the active audio effect."""
- player = players.get(ctx.guild.id)
- name = (player.effect_name if player else None) or "none"
- await ctx.send(embed=success_embed(f"Current effect: **{name}**"))
+ @hybrid_command(name="effect")
+ @app_commands.describe(name="The effect to apply; leave it out to see the active one")
+ @app_commands.choices(name=[app_commands.Choice(name=n, value=n) for n in EFFECTS])
+ async def effect(self, ctx: GuildContext, name: Optional[str] = None) -> None:
+ """Show the active audio effect, or apply one by name."""
+ if name is None:
+ player = players.get(ctx.guild.id)
+ active = (player.effect_name if player else None) or "none"
+ await ctx.send(embed=success_embed(f"Current effect: **{active}**"))
+ return
+ name = name.lower()
+ chosen = EFFECTS.get(name)
+ if chosen is None:
+ await ctx.send(embed=error_embed(
+ f"Unknown effect `{name}`. See `{ctx.clean_prefix}effects`."), ephemeral=True)
+ return
+ if await in_same_voice_channel(ctx):
+ await self._switch_to(ctx, name, chosen.filter, chosen.label, chosen.rate)
- @commands.command(name="effects")
+ @hybrid_command(name="effects")
async def list_effects(self, ctx: GuildContext) -> None:
"""List all available audio effects."""
prefix = ctx.clean_prefix
+ if ctx.interaction is None:
+ names = ", ".join(f"`{prefix}{name}`" for name in EFFECTS)
+ else:
+ # The per-effect shortcuts are aliases, and aliases do not exist
+ # under `/`; there every effect goes through /effect.
+ names = ("Pick one with `/effect`: "
+ + ", ".join(f"`{name}`" for name in EFFECTS))
await ctx.send(embed=discord.Embed(
title="๐๏ธ Available Effects",
- description=", ".join(f"`{prefix}{name}`" for name in EFFECTS)
- + f"\n\nUse `{prefix}reset` to remove all effects.",
+ description=f"{names}\n\nUse `{prefix}reset` to remove all effects.",
color=BLURPLE,
))
diff --git a/cogs/lyrics.py b/cogs/lyrics.py
index e04b917..8c0784e 100644
--- a/cogs/lyrics.py
+++ b/cogs/lyrics.py
@@ -14,9 +14,10 @@
# Genius fallback then built a Cog instead of a result and raised TypeError,
# which the command reported as nothing at all.
from services.synced_lyrics import Line, index_at, search_terms
-from utils.context import GuildContext
+from utils import now_playing_view
+from utils.context import GuildContext, hybrid_command
from utils.embeds import (error_embed, info_embed, lyrics_embed, lyrics_pages,
- synced_lyrics_embed)
+ success_embed, synced_lyrics_embed)
from utils.player import MusicPlayer, players
LyricsResult = synced_lyrics.Lyrics
@@ -178,8 +179,10 @@ def __init__(self, bot: commands.Bot) -> None:
async def cog_load(self) -> None:
# One session for the cog. Building one per request is what leaked in #34.
self._session = aiohttp.ClientSession()
+ now_playing_view.set_lyrics_provider(self.show_current)
async def cog_unload(self) -> None:
+ now_playing_view.set_lyrics_provider(None)
for guild_id in list(self._following):
self.stop_following(guild_id)
if self._session is not None:
@@ -241,21 +244,22 @@ def stop_following(self, guild_id: int) -> None:
if not task.done():
task.cancel()
- async def _follow(self, ctx: GuildContext, player: MusicPlayer,
+ async def _follow(self, dest: discord.abc.Messageable, player: MusicPlayer,
lyrics: LyricsResult) -> None:
"""Post the live message and start keeping it up to date."""
- self.stop_following(ctx.guild.id) # one per guild; the newest wins
+ guild_id = player.guild.id
+ self.stop_following(guild_id) # one per guild; the newest wins
index = index_at(lyrics.lines, player.position)
- message = await ctx.send(
+ message = await dest.send(
embed=synced_lyrics_embed(
lyrics.title, lyrics.artist, lyrics.lines, index,
player.position,
player.current.get("duration") if player.current else None),
- view=FollowControls(lambda: self.stop_following(ctx.guild.id)),
+ view=FollowControls(lambda: self.stop_following(guild_id)),
)
follower = LyricsFollower(message, player, self._for_track)
- task = self.bot.loop.create_task(self._run_follower(ctx.guild.id, follower))
- self._following[ctx.guild.id] = (follower, task)
+ task = self.bot.loop.create_task(self._run_follower(guild_id, follower))
+ self._following[guild_id] = (follower, task)
async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None:
try:
@@ -267,13 +271,51 @@ async def _run_follower(self, guild_id: int, follower: LyricsFollower) -> None:
finally:
self._following.pop(guild_id, None)
+ # -- Showing what was found ---------------------------------------
+
+ async def _present(self, dest: discord.abc.Messageable, found: LyricsResult,
+ player: Optional[MusicPlayer]) -> None:
+ """
+ Post lyrics that were found. ``player`` is given when they are the
+ playing track's, and only then can they be followed: a lyric needs a
+ clock, and a search result has none.
+ """
+ if found.instrumental:
+ await dest.send(embed=info_embed(
+ "\U0001f3b5 Instrumental",
+ f"**{found.title}** has no lyrics to show."
+ ))
+ return
+ if found.synced and player and player.current:
+ return await self._follow(dest, player, found)
+
+ note = "not synced - showing the full lyrics" if player else ""
+ view = LyricsPages(found.title, found.artist, found.plain, note)
+ if view.total > 1:
+ await dest.send(embed=view.embed(), view=view)
+ else:
+ await dest.send(embed=view.embed())
+
+ async def show_current(self, dest: discord.abc.Messageable,
+ player: MusicPlayer) -> bool:
+ """Post the playing track's lyrics. False when there are none to post."""
+ if not player.current:
+ return False
+ found = await self._for_track(player.current)
+ if found is None:
+ return False
+ await self._present(dest, found, player)
+ return True
+
# -- The command ---------------------------------------------------
- @commands.command(aliases=["ly"])
+ @hybrid_command(aliases=["ly"])
async def lyrics(self, ctx: GuildContext, *,
query: Optional[str] = None) -> None:
"""Follow the lyrics of the current song, or look a song up."""
- async with ctx.typing():
+ # Ephemeral under `/`: the lyrics themselves go to the channel (below),
+ # so all this interaction ever shows is an error or an acknowledgement.
+ async with ctx.typing(ephemeral=True):
player = players.get(ctx.guild.id)
if query:
title = self._split_query(query)[0]
@@ -285,32 +327,23 @@ async def lyrics(self, ctx: GuildContext, *,
await ctx.send(embed=error_embed(
f"Nothing is playing. Provide a song name: "
f"`{ctx.clean_prefix}lyrics
`"
- ))
+ ), ephemeral=True)
return
if found is None:
await ctx.send(embed=error_embed(
f"Couldn't find lyrics for **{title}**."
- ))
+ ), ephemeral=True)
return
- if found.instrumental:
- await ctx.send(embed=info_embed(
- "\U0001f3b5 Instrumental",
- f"**{found.title}** has no lyrics to show."
- ))
+ if ctx.interaction is None:
+ await self._present(ctx, found, None if query else player)
return
-
- # Only the playing track can be followed: a lyric needs a clock, and
- # a search result has none.
- if found.synced and not query and player and player.current:
- return await self._follow(ctx, player, found)
-
- note = "" if query else "not synced - showing the full lyrics"
- view = LyricsPages(found.title, found.artist, found.plain, note)
- if view.total > 1:
- await ctx.send(embed=view.embed(), view=view)
- else:
- await ctx.send(embed=view.embed())
+ # A reply to an interaction can only be edited with its token, which
+ # expires after 15 minutes; the live message is edited for as long
+ # as the music plays. Posted to the channel, it is the bot's own.
+ await self._present(ctx.channel, found, None if query else player)
+ await ctx.send(embed=success_embed("๐ค Lyrics posted."),
+ ephemeral=True)
async def setup(bot: commands.Bot) -> None:
diff --git a/cogs/music.py b/cogs/music.py
index bd60e47..a6b755c 100644
--- a/cogs/music.py
+++ b/cogs/music.py
@@ -2,13 +2,16 @@
import logging
from typing import Optional, cast
+import aiohttp
import discord
+from discord import app_commands
from discord.ext import commands
-from services import media
+from services import media, suggestions
from services.media import Track
+from utils import controls
from utils.player import players, MusicPlayer, MAX_QUEUE
-from utils.context import GuildContext
+from utils.context import GuildContext, hybrid_command
from utils.embeds import (added_embed, error_embed, now_playing_embed,
queue_embed, success_embed)
from utils.checks import user_in_voice, same_voice_channel
@@ -35,9 +38,23 @@ def _is_playlist_url(query: str) -> bool:
return "/sets/" in q or "/album/" in q # SoundCloud set / Bandcamp album
+async def _reply(ctx: GuildContext, outcome: controls.Outcome) -> None:
+ """Report an action's outcome; under `/`, a failure only to whoever asked."""
+ embed = success_embed(outcome.message) if outcome.ok else error_embed(outcome.message)
+ await ctx.send(embed=embed, ephemeral=not outcome.ok)
+
+
class Music(commands.Cog, name="๐ต Music & Queue"):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
+ self._session: Optional[aiohttp.ClientSession] = None
+
+ async def cog_load(self) -> None:
+ self._session = aiohttp.ClientSession()
+
+ async def cog_unload(self) -> None:
+ if self._session is not None:
+ await self._session.close()
# โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@@ -75,7 +92,8 @@ def _player(self, ctx: GuildContext) -> MusicPlayer:
# โโ Playback commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- @commands.command(aliases=["p"])
+ @hybrid_command(aliases=["p"])
+ @app_commands.describe(query="A song to search for, or a link")
@commands.cooldown(rate=3, per=5.0, type=commands.BucketType.user)
@commands.max_concurrency(1, per=commands.BucketType.user, wait=False)
@user_in_voice()
@@ -84,6 +102,10 @@ async def play(self, ctx: GuildContext, *, query: str) -> None:
Tip: prefix a search with `sc:` to search SoundCloud, e.g. `!play sc: lofi`.
"""
+ # Joining voice and asking yt-dlp can take far longer than the three
+ # seconds Discord gives a slash command to answer. Once only: unlike
+ # ctx.typing(), ctx.defer() fails on an interaction already answered.
+ await ctx.defer()
query = query.strip()
if len(query) > MAX_QUERY_LEN:
await ctx.send(embed=error_embed("That query is too long."))
@@ -110,6 +132,14 @@ async def play(self, ctx: GuildContext, *, query: str) -> None:
return
await self._enqueue(ctx, player, [track], None)
+ @play.autocomplete("query")
+ async def _suggest(self, _interaction: discord.Interaction,
+ current: str) -> list[app_commands.Choice[str]]:
+ if self._session is None:
+ return []
+ terms = await suggestions.fetch(self._session, current)
+ return [app_commands.Choice(name=t, value=t) for t in terms]
+
async def _enqueue(self, ctx: GuildContext, player: MusicPlayer,
tracks: list[Track], batch_label: Optional[str]) -> None:
"""Add one or many tracks and report to the channel."""
@@ -124,6 +154,11 @@ async def _enqueue(self, ctx: GuildContext, player: MusicPlayer,
return
if not was_idle:
await ctx.send(embed=added_embed(tracks[0]))
+ elif ctx.interaction is not None:
+ # Now Playing goes to the channel, not to this interaction, which
+ # would otherwise be left "thinking..." forever.
+ await ctx.send(embed=success_embed(
+ f"โถ๏ธ Starting **{tracks[0]['title']}**"))
else:
added = player.add_many(tracks)
if added == 0:
@@ -136,62 +171,44 @@ async def _enqueue(self, ctx: GuildContext, player: MusicPlayer,
f"Added **{added} tracks** from {batch_label} to the queue.{skipped}"
))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def pause(self, ctx: GuildContext) -> None:
"""Pause the current track."""
- # Routed through the player so it can stop its playback clock; that
- # clock is what lets an effect change resume in the right place.
- player = players.get(ctx.guild.id)
- if player and player.pause():
- await ctx.send(embed=success_embed("Paused โธ"))
- else:
- await ctx.send(embed=error_embed("Nothing is playing right now."))
+ await _reply(ctx, controls.pause(players.get(ctx.guild.id)))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def resume(self, ctx: GuildContext) -> None:
"""Resume a paused track."""
- player = players.get(ctx.guild.id)
- if player and player.resume():
- await ctx.send(embed=success_embed("Resumed โถ๏ธ"))
- else:
- await ctx.send(embed=error_embed("Nothing is paused."))
+ await _reply(ctx, controls.resume(players.get(ctx.guild.id)))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def skip(self, ctx: GuildContext) -> None:
"""Skip the current track."""
- player = players.get(ctx.guild.id)
- if player and player.skip():
- await ctx.send(embed=success_embed("Skipped โญ"))
- else:
- await ctx.send(embed=error_embed("Nothing is playing."))
+ await _reply(ctx, controls.skip(players.get(ctx.guild.id)))
- @commands.command(aliases=["prev"])
+ @hybrid_command(aliases=["prev"])
@same_voice_channel()
async def previous(self, ctx: GuildContext) -> None:
"""Go back to the previous track."""
- player = players.get(ctx.guild.id)
- if player and player.go_previous():
- await ctx.send(embed=success_embed("Playing previous track โฎ"))
- else:
- await ctx.send(embed=error_embed("No previous track in history."))
+ await _reply(ctx, controls.previous(players.get(ctx.guild.id)))
- @commands.command(aliases=["dc", "leave"])
+ @hybrid_command(aliases=["dc", "leave"])
@same_voice_channel()
async def stop(self, ctx: GuildContext) -> None:
"""Stop music and disconnect the bot."""
player = players.get(ctx.guild.id)
if player:
- player.destroy()
+ controls.stop(player)
elif ctx.voice_client:
await ctx.voice_client.disconnect(force=True)
- await ctx.send(embed=success_embed("Disconnected and cleared the queue."))
+ await ctx.send(embed=success_embed(controls.STOPPED))
# โโ Queue commands โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- @commands.command(aliases=["q"])
+ @hybrid_command(aliases=["q"])
async def queue(self, ctx: GuildContext, page: int = 1) -> None:
"""Show the current queue."""
player = players.get(ctx.guild.id)
@@ -200,7 +217,7 @@ async def queue(self, ctx: GuildContext, page: int = 1) -> None:
return
await ctx.send(embed=queue_embed(player.to_list(), player.current, page=page))
- @commands.command(aliases=["np", "current"])
+ @hybrid_command(aliases=["np", "current"])
async def nowplaying(self, ctx: GuildContext) -> None:
"""Show the currently playing track."""
player = players.get(ctx.guild.id)
@@ -209,13 +226,11 @@ async def nowplaying(self, ctx: GuildContext) -> None:
return
await ctx.send(embed=now_playing_embed(player.current, ctx.author, loop_mode=player.loop_mode))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
- async def volume(self, ctx: GuildContext, vol: int) -> None:
+ async def volume(self, ctx: GuildContext,
+ vol: commands.Range[int, 0, 100]) -> None:
"""Set volume (0โ100)."""
- if not 0 <= vol <= 100:
- await ctx.send(embed=error_embed("Volume must be between 0 and 100."))
- return
player = players.get(ctx.guild.id)
if not player:
await ctx.send(embed=error_embed("Nothing is playing."))
@@ -223,34 +238,29 @@ async def volume(self, ctx: GuildContext, vol: int) -> None:
player.set_volume(vol / 100)
await ctx.send(embed=success_embed(f"Volume set to **{vol}%** ๐"))
- @commands.command()
+ @hybrid_command()
+ # Choices rather than a Literal annotation: the Literal converter is
+ # case-sensitive, and `!loop Track` has always worked.
+ @app_commands.choices(mode=[app_commands.Choice(name=m, value=m)
+ for m in ("track", "queue", "off")])
@same_voice_channel()
async def loop(self, ctx: GuildContext, mode: str = "track") -> None:
"""Set loop mode: track | queue | off"""
mode = mode.lower()
- if mode not in ("track", "queue", "off"):
- await ctx.send(embed=error_embed("Loop mode must be `track`, `queue`, or `off`."))
+ if mode not in controls.LOOP_ICONS:
+ await ctx.send(embed=error_embed("Loop mode must be `track`, `queue`, or `off`."),
+ ephemeral=True)
return
- player = players.get(ctx.guild.id)
- if not player:
- await ctx.send(embed=error_embed("Nothing is playing."))
- return
- player.loop_mode = mode
- icons = {"track": "๐", "queue": "๐", "off": "โก๏ธ"}
- await ctx.send(embed=success_embed(f"Loop mode set to **{mode}** {icons[mode]}"))
+ await _reply(ctx, controls.set_loop(players.get(ctx.guild.id),
+ cast(controls.LoopMode, mode)))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def shuffle(self, ctx: GuildContext) -> None:
"""Shuffle the queue."""
- player = players.get(ctx.guild.id)
- if not player or player.is_empty:
- await ctx.send(embed=error_embed("Queue is empty."))
- return
- player.shuffle()
- await ctx.send(embed=success_embed("Queue shuffled ๐"))
+ await _reply(ctx, controls.shuffle(players.get(ctx.guild.id)))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def remove(self, ctx: GuildContext, index: int) -> None:
"""Remove a track from the queue by its position."""
@@ -261,7 +271,7 @@ async def remove(self, ctx: GuildContext, index: int) -> None:
return
await ctx.send(embed=success_embed(f"Removed **{track['title']}** from the queue."))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def move(self, ctx: GuildContext, from_pos: int, to_pos: int) -> None:
"""Move a track to another position in the queue."""
@@ -271,7 +281,7 @@ async def move(self, ctx: GuildContext, from_pos: int, to_pos: int) -> None:
else:
await ctx.send(embed=error_embed("Invalid positions."))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def clear(self, ctx: GuildContext) -> None:
"""Clear the queue (keeps the current track playing)."""
@@ -280,7 +290,7 @@ async def clear(self, ctx: GuildContext) -> None:
player.clear()
await ctx.send(embed=success_embed("Queue cleared ๐๏ธ"))
- @commands.command()
+ @hybrid_command()
@same_voice_channel()
async def autoplay(self, ctx: GuildContext) -> None:
"""Toggle autoplay (auto-queue related tracks when the queue ends)."""
diff --git a/config.py b/config.py
index 9a7f820..d01e244 100644
--- a/config.py
+++ b/config.py
@@ -43,6 +43,7 @@ def project_path(value: Optional[str]) -> Optional[str]:
"cogs.music",
"cogs.effects",
"cogs.lyrics",
+ "cogs.admin",
]
diff --git a/deploy/README.md b/deploy/README.md
index 0876229..c0f3ffa 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -52,6 +52,11 @@ sudo systemctl start loopify-bot
sudo journalctl -u loopify-bot -f # look for "Logged in as ..."
```
+Then, from the Discord account that owns the bot application, send `!sync` in
+any server the bot is in. That registers the `/` commands with Discord; until
+it runs, only the `!` commands exist. The bot does not do this on its own at
+startup because Discord rate-limits it and the bot restarts unattended.
+
## 3. Updating later
```bash
@@ -66,6 +71,9 @@ The units are reinstalled every time on purpose: a pull can change how the bot i
*run* (sandboxing, resource caps, stop timeouts), and restarting alone would keep
the old configuration while the repo claimed otherwise.
+If the update added, removed or changed a command, send `!sync` again so the
+`/` menu matches.
+
### If the host was deployed by copying files instead of cloning
`update.sh` refuses to run and tells you how to convert it in place:
diff --git a/docs/design/2026-09-27-interactive-controls.md b/docs/design/2026-09-27-interactive-controls.md
new file mode 100644
index 0000000..cb94ea1
--- /dev/null
+++ b/docs/design/2026-09-27-interactive-controls.md
@@ -0,0 +1,114 @@
+# Interactive controls and slash commands
+
+## Goal
+
+Make the bot usable without typing commands: buttons on the Now Playing
+message, and every command available as a `/` command alongside `!`.
+
+## Decisions
+
+- **Hybrid commands.** Every command answers to both `!name` and `/name`.
+ Aliases (`!p`, `!q`, `!bass`, โฆ) stay prefix-only, so the `/` menu does not
+ fill with duplicates.
+- **Buttons.** Two rows on each Now Playing message: `โฎ โฏ โญ โน` and
+ `๐ ๐ ๐ ๐ค`. No effects menu.
+- **Old messages.** When the next track starts, or the player is destroyed, the
+ previous Now Playing message keeps its embed and its buttons are disabled.
+
+## Architecture
+
+### `utils/controls.py` โ shared playback actions
+
+Pure functions over `Optional[MusicPlayer]` returning an `Outcome(ok, message)`:
+`pause`, `resume`, `skip`, `previous`, `shuffle`, `set_loop`, `cycle_loop`,
+`stop`. They hold the wording ("Nothing is playing.", "Skipped โญ") in one
+place. Commands and buttons both call them and differ only in how they deliver
+the result.
+
+### `utils/now_playing_view.py` โ the buttons
+
+`NowPlayingControls(discord.ui.View)`, built per announcement with the player,
+the track and its requester.
+
+- `interaction_check`: the presser must be in the bot's voice channel, and the
+ player must still be the guild's live one. Otherwise an ephemeral refusal.
+- `โฏ` and `๐` edit the message in place (the button's emoji, the Loop field).
+- `โญ โฎ โน ๐` post a short public line naming who pressed it, because they
+ change playback for everyone.
+- `๐` answers with the queue, ephemerally.
+- `๐ค` posts the lyrics to the channel through a `LyricsProvider`, which the
+ Lyrics cog registers on load and clears on unload. The view never imports the
+ cog. With no provider registered, the button is not added.
+- `timeout=None`, so a long track never loses its controls. A view with no
+ timeout stays in discord.py's view store until `stop()` is called, so every
+ retired view is stopped; otherwise each track played would leak one.
+
+### `utils/announcer.py`
+
+`now_playing` sends the embed with a fresh view and remembers the message.
+`retire()` disables and stops the previous view. It runs before each new
+announcement and when the player is destroyed.
+
+### Hybrid migration
+
+- `/play` defers first: joining voice can take up to 20s and yt-dlp 3โ8s, and
+ an interaction must be answered within 3s. `ctx.defer()` does not check
+ whether the interaction was already answered, so it is called exactly once;
+ `ctx.typing()` does check, and is safe after it.
+- `/play` on an idle player replies "Starting โฆ", because the Now Playing
+ announcement goes to the channel, and a deferred interaction left without a
+ reply shows "thinkingโฆ" forever.
+- `/loop mode:` is `Literal["track", "queue", "off"]` and `/volume vol:` is
+ `commands.Range[int, 0, 100]`. Discord then offers and validates them, and
+ under `!` a bad value is a `BadArgument` that `utils.errors` already reports.
+- Effects: `!effect` with no argument still shows the active effect;
+ `!effect ` and `/effect name:` (a choice list built from `EFFECTS`)
+ apply one. The `!bass`-style shortcuts are unchanged.
+- `/play query:` autocompletes from YouTube's search-suggestion endpoint
+ (`services/suggestions.py`). It is unofficial, so it has a 1.5s timeout and
+ any failure yields no suggestions rather than an error. URLs are not
+ completed.
+- Slash commands are registered with Discord by an owner-only, hidden `!sync`
+ (`cogs/admin.py`), run once after a deploy that changes commands. Syncing on
+ every start would hit Discord's rate limit, and the bot restarts on its own.
+- The command tree is declared guild-only (`allowed_contexts`), so `/` commands
+ are not offered in DMs at all. The existing `guild_only` check still covers
+ `!`.
+- Errors and voice-check refusals are sent with `ephemeral=True`: under `/` only
+ the person who erred sees them; under `!` the flag is ignored.
+- `utils.errors` unwraps `HybridCommandError` as well as `CommandInvokeError`.
+- `!help` and the bot's presence mention `/play`.
+
+## Edge cases
+
+| Situation | Behaviour |
+|---|---|
+| Button pressed by someone outside the bot's voice channel | Ephemeral "join my voice channel" |
+| Button pressed after the player was destroyed but before the message was retired | Ephemeral "This player has ended" |
+| `โญ` with nothing playing | Ephemeral "Nothing is playing." |
+| `โฎ` with no history | Ephemeral "No previous track in history." |
+| `๐` on an empty queue | Ephemeral "Queue is empty." |
+| `๐ค` finds no lyrics | Ephemeral "Couldn't find lyrics" |
+| Admin disconnects the bot from voice | Player is destroyed, which retires the message |
+| Now Playing channel deleted | Sending and retiring swallow `HTTPException`, as announcing already does |
+| Bot restarts | Old messages' buttons answer "interaction failed". Known limitation: no player survives a restart for them to control |
+| Suggestion endpoint slow or down | `/play` offers no suggestions and still works |
+| `/play` in a DM | Not offered by Discord |
+
+## Testing
+
+Unit tests, no gateway:
+
+- `controls`: every action with no player, idle player and playing player.
+- `NowPlayingControls`: the interaction check (outside voice, destroyed
+ player), each button's delivery (edit vs public line vs ephemeral), the
+ lyrics button's presence with and without a provider.
+- `ChannelAnnouncer`: retiring disables and stops the previous view; a failing
+ edit is swallowed.
+- `suggestions`: parses the endpoint's JSON, returns `[]` on timeout, bad JSON
+ and URLs.
+- Commands: `/play` defers once and answers on an idle player; `!effect` with and
+ without a name; errors unwrap `HybridCommandError`; replies are ephemeral.
+
+Manual, in a server, after `!sync`: each button, the `/` menu with its
+choices and autocompletion, and a button pressed from outside voice.
diff --git a/main.py b/main.py
index 22b22c9..03ba0bf 100644
--- a/main.py
+++ b/main.py
@@ -3,6 +3,7 @@
from typing import cast
import discord
+from discord import app_commands
from discord.ext import commands
import config
@@ -27,6 +28,9 @@
intents=intents,
help_command=None, # replaced by the generated one below
case_insensitive=True,
+ # Every command acts on a server's voice and queue, so `/` commands are not
+ # offered in DMs at all. guild_only below still covers the `!` ones.
+ allowed_contexts=app_commands.AppCommandContext(guild=True),
)
bot.add_check(guild_only)
@@ -38,7 +42,7 @@ async def on_ready() -> None:
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.listening,
- name=f"{COMMAND_PREFIX}play",
+ name="/play",
)
)
@@ -50,7 +54,7 @@ async def on_command_error(ctx: commands.Context[commands.Bot],
await errors.handle(ctx, error)
-@bot.command(name="help")
+@bot.hybrid_command(name="help")
async def help_command(ctx: commands.Context[commands.Bot]) -> None:
"""Show this message."""
await ctx.send(embed=build_help(bot, COMMAND_PREFIX))
diff --git a/services/suggestions.py b/services/suggestions.py
new file mode 100644
index 0000000..fde0e0e
--- /dev/null
+++ b/services/suggestions.py
@@ -0,0 +1,50 @@
+"""
+Search suggestions for `/play`, from YouTube's autocomplete endpoint.
+
+The endpoint is unofficial and undocumented, so everything here is best
+effort: a slow, changed or unreachable endpoint yields no suggestions, never an
+error. Discord drops an autocomplete answer after 3 seconds, hence the short
+timeout.
+"""
+
+import logging
+from typing import Any
+
+import aiohttp
+
+log = logging.getLogger("loopify.suggestions")
+
+SUGGEST_URL = "https://suggestqueries.google.com/complete/search"
+REQUEST_TIMEOUT = 1.5
+MAX_SUGGESTIONS = 10
+MAX_CHOICE_LEN = 100 # Discord's cap on a choice's name and value
+
+
+async def fetch(session: aiohttp.ClientSession, query: str) -> list[str]:
+ """Up to MAX_SUGGESTIONS search terms completing ``query``."""
+ query = query.strip()
+ if not query or query.startswith("http"):
+ return []
+ try:
+ async with session.get(
+ SUGGEST_URL,
+ # client=firefox answers plain JSON; ds=yt restricts to YouTube.
+ params={"client": "firefox", "ds": "yt", "q": query},
+ timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT),
+ ) as response:
+ if response.status != 200:
+ return []
+ payload = await response.json(content_type=None)
+ except Exception as e:
+ log.debug("Suggestions failed for %r: %s", query, e)
+ return []
+ return _terms(payload)
+
+
+def _terms(payload: Any) -> list[str]:
+ """The suggestions from ``[query, [term, ...], ...]``, or none if it isn't that."""
+ if not (isinstance(payload, list) and len(payload) > 1
+ and isinstance(payload[1], list)):
+ return []
+ terms = [t[:MAX_CHOICE_LEN] for t in payload[1] if isinstance(t, str) and t]
+ return terms[:MAX_SUGGESTIONS]
diff --git a/tests/test_announcer.py b/tests/test_announcer.py
new file mode 100644
index 0000000..259e39e
--- /dev/null
+++ b/tests/test_announcer.py
@@ -0,0 +1,67 @@
+"""Now Playing messages: each one gets controls, and the previous one loses them."""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+
+from utils import now_playing_view
+from utils.announcer import ChannelAnnouncer
+from utils.now_playing_view import NowPlayingControls
+
+
+@pytest.fixture
+def channel():
+ ch = MagicMock()
+ ch.send = AsyncMock(side_effect=lambda **kw: MagicMock(edit=AsyncMock()))
+ return ch
+
+
+@pytest.fixture(autouse=True)
+def no_lyrics(monkeypatch):
+ monkeypatch.setattr(now_playing_view, "_lyrics", None)
+
+
+async def test_now_playing_is_sent_with_controls(channel, player, track_factory):
+ announcer = ChannelAnnouncer(channel)
+ await announcer.now_playing(player, track_factory("Song"), MagicMock())
+ view = channel.send.await_args.kwargs["view"]
+ assert isinstance(view, NowPlayingControls) and not view.is_finished()
+
+
+async def test_the_next_track_retires_the_previous_controls(channel, player, track_factory):
+ announcer = ChannelAnnouncer(channel)
+ await announcer.now_playing(player, track_factory("One"), MagicMock())
+ first_view = channel.send.await_args.kwargs["view"]
+ first_message = announcer._live[0]
+
+ await announcer.now_playing(player, track_factory("Two"), MagicMock())
+
+ assert first_view.is_finished()
+ assert all(item.disabled for item in first_view.children)
+ first_message.edit.assert_awaited_once_with(view=first_view)
+
+
+async def test_retire_with_nothing_shown_does_nothing(channel):
+ await ChannelAnnouncer(channel).retire()
+
+
+async def test_retire_survives_a_deleted_message(channel, player, track_factory):
+ announcer = ChannelAnnouncer(channel)
+ await announcer.now_playing(player, track_factory("Song"), MagicMock())
+ announcer._live[0].edit.side_effect = discord.NotFound(MagicMock(), "gone")
+ await announcer.retire()
+ assert announcer._live is None
+
+
+async def test_a_failed_send_leaves_no_live_view(channel, player, track_factory):
+ channel.send.side_effect = discord.Forbidden(MagicMock(), "no access")
+ announcer = ChannelAnnouncer(channel)
+ await announcer.now_playing(player, track_factory("Song"), MagicMock())
+ assert announcer._live is None
+
+
+def test_destroying_the_player_retires_its_controls(player):
+ player.announcer.retire = MagicMock()
+ player.destroy()
+ player.announcer.retire.assert_called_once()
diff --git a/tests/test_checks.py b/tests/test_checks.py
index 432a448..f882a57 100644
--- a/tests/test_checks.py
+++ b/tests/test_checks.py
@@ -84,3 +84,9 @@ async def test_same_channel_passes_when_the_bot_is_not_connected_yet(ctx):
ctx.author.voice = in_channel(MagicMock())
ctx.voice_client = None
assert await same_voice_channel().predicate(ctx) is True
+
+
+async def test_refusals_are_private_under_slash(ctx):
+ """Under `/` only the person refused sees why; under `!` the flag is ignored."""
+ assert await user_in_voice().predicate(ctx) is False
+ assert ctx.send.await_args.kwargs["ephemeral"] is True
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 2c5d259..34b851d 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -13,6 +13,7 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
+from discord.ext import commands
from cogs.music import Music, MAX_QUERY_LEN, _is_playlist_url
from cogs.effects import EFFECTS, Effects
@@ -27,6 +28,8 @@ def ctx(fake_guild):
c.voice_client = None
c.author = MagicMock()
c.typing = MagicMock(return_value=AsyncMock())
+ c.defer = AsyncMock()
+ c.interaction = None # invoked as `!command` unless a test says so
return c
@@ -120,17 +123,22 @@ async def test_stop_with_no_player_and_no_voice_still_confirms(music_cog, ctx):
# -- out-of-range and oversized input ----------------------------------
-@pytest.mark.parametrize("vol", [-1, 101, 1000])
-async def test_volume_out_of_range_is_rejected(music_cog, ctx, vol):
- await Music.volume.callback(music_cog, ctx, vol)
- assert "between 0 and 100" in sent_text(ctx)
+async def convert_volume(ctx, raw: str):
+ """Run the volume argument through its converter, as `!volume ` would."""
+ param = Music.volume.clean_params["vol"]
+ return await commands.run_converters(ctx, param.converter, raw, param)
+
+
+@pytest.mark.parametrize("vol", ["-1", "101", "1000"])
+async def test_volume_out_of_range_is_rejected(ctx, vol):
+ with pytest.raises(commands.RangeError):
+ await convert_volume(ctx, vol)
@pytest.mark.parametrize("vol", [0, 50, 100])
-async def test_volume_boundaries_are_accepted(music_cog, ctx, vol):
+async def test_volume_boundaries_are_accepted(ctx, vol):
"""0 and 100 are valid - an off-by-one here would reject mute and max."""
- await Music.volume.callback(music_cog, ctx, vol)
- assert "between 0 and 100" not in sent_text(ctx)
+ assert await convert_volume(ctx, str(vol)) == vol
async def test_loop_rejects_an_unknown_mode(music_cog, ctx):
@@ -205,7 +213,7 @@ async def test_effect_changes_are_throttled_per_guild(effects_cog, ctx):
async def test_current_effect_reports_none_when_idle(effects_cog, ctx):
- await Effects.current_effect.callback(effects_cog, ctx)
+ await Effects.effect.callback(effects_cog, ctx)
assert "none" in sent_text(ctx).lower()
@@ -311,3 +319,74 @@ async def test_play_copes_with_the_author_leaving_voice_mid_command(music_cog, c
await Music.play.callback(music_cog, ctx, query="bohemian rhapsody")
assert "voice channel" in sent_text(ctx)
search.assert_not_awaited()
+
+
+# -- slash invocation -----------------------------------------------------
+
+async def test_play_defers_before_any_slow_work(music_cog, ctx):
+ """Joining voice alone can outlast Discord's three seconds to answer."""
+ ctx.author.voice = None
+ await Music.play.callback(music_cog, ctx, query="bohemian rhapsody")
+ ctx.defer.assert_awaited_once()
+
+
+async def test_slash_play_on_an_idle_player_still_answers(music_cog, ctx, player, track_factory):
+ """Now Playing goes to the channel; the interaction needs a reply of its own."""
+ ctx.interaction = MagicMock()
+ await music_cog._enqueue(ctx, player, [track_factory("Song")], None)
+ assert "Starting" in sent_text(ctx) and "Song" in sent_text(ctx)
+
+
+async def test_prefix_play_on_an_idle_player_leaves_it_to_now_playing(
+ music_cog, ctx, player, track_factory):
+ await music_cog._enqueue(ctx, player, [track_factory("Song")], None)
+ ctx.send.assert_not_awaited()
+
+
+async def test_failures_are_private_under_slash(music_cog, ctx):
+ await Music.skip.callback(music_cog, ctx)
+ assert ctx.send.await_args.kwargs["ephemeral"] is True
+
+
+async def test_play_suggests_search_terms(music_cog):
+ music_cog._session = MagicMock()
+ with patch("cogs.music.suggestions.fetch",
+ new=AsyncMock(return_value=["bohemian rhapsody"])):
+ choices = await Music._suggest(music_cog, MagicMock(), "bohem")
+ assert [c.value for c in choices] == ["bohemian rhapsody"]
+
+
+async def test_no_suggestions_before_the_cog_has_loaded(music_cog):
+ assert await Music._suggest(music_cog, MagicMock(), "bohem") == []
+
+
+# -- /effect ---------------------------------------------------------
+
+async def test_effect_by_name_applies_it(effects_cog, ctx):
+ ctx.author.voice.channel = ctx.voice_client
+ with patch.object(effects_cog, "_switch_to", new=AsyncMock()) as switch:
+ await Effects.effect.callback(effects_cog, ctx, "Nightcore")
+ name, flt, _label, rate = switch.await_args.args[1:]
+ assert name == "nightcore" and flt == EFFECTS["nightcore"].filter
+ assert rate == EFFECTS["nightcore"].rate
+
+
+async def test_an_unknown_effect_name_is_refused(effects_cog, ctx):
+ await Effects.effect.callback(effects_cog, ctx, "banana")
+ assert "Unknown effect" in sent_text(ctx)
+
+
+async def test_applying_by_name_still_needs_the_same_voice_channel(effects_cog, ctx):
+ ctx.author.voice = None
+ with patch.object(effects_cog, "_switch_to", new=AsyncMock()) as switch:
+ await Effects.effect.callback(effects_cog, ctx, "bass")
+ switch.assert_not_awaited()
+ assert "voice channel" in sent_text(ctx)
+
+
+async def test_effects_list_under_slash_points_at_effect(effects_cog, ctx):
+ ctx.clean_prefix = "/"
+ ctx.interaction = MagicMock()
+ await Effects.list_effects.callback(effects_cog, ctx)
+ text = sent_text(ctx)
+ assert "/effect" in text and "`/bass`" not in text
diff --git a/tests/test_controls.py b/tests/test_controls.py
new file mode 100644
index 0000000..8f5c691
--- /dev/null
+++ b/tests/test_controls.py
@@ -0,0 +1,100 @@
+"""
+The playback actions shared by commands and Now Playing buttons.
+
+Each action is checked with no player, with an idle one and with one that is
+playing, since the buttons can be pressed in any of those states.
+"""
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from utils import controls
+
+
+@pytest.fixture
+def voice(fake_guild):
+ vc = MagicMock()
+ vc.is_playing.return_value = True
+ vc.is_paused.return_value = False
+ fake_guild.voice_client = vc
+ return vc
+
+
+def test_pause_a_playing_track(player, voice):
+ outcome = controls.pause(player)
+ assert outcome.ok and "Paused" in outcome.message
+ voice.pause.assert_called_once()
+
+
+def test_resume_a_paused_track(player, voice):
+ voice.is_playing.return_value = False
+ voice.is_paused.return_value = True
+ outcome = controls.resume(player)
+ assert outcome.ok and "Resumed" in outcome.message
+ voice.resume.assert_called_once()
+
+
+@pytest.mark.parametrize("action", [controls.pause, controls.skip])
+def test_actions_on_an_idle_player_report_nothing_playing(player, action):
+ outcome = action(player)
+ assert not outcome.ok and "Nothing is playing" in outcome.message
+
+
+@pytest.mark.parametrize("action", [
+ controls.pause, controls.resume, controls.skip,
+ controls.previous, controls.shuffle, controls.cycle_loop, controls.stop,
+])
+def test_actions_without_a_player_fail_politely(action):
+ assert not action(None).ok
+
+
+def test_resume_when_nothing_is_paused(player):
+ assert controls.resume(player).message == "Nothing is paused."
+
+
+def test_skip_a_playing_track(player, voice):
+ outcome = controls.skip(player)
+ assert outcome.ok and "Skipped" in outcome.message
+ voice.stop.assert_called_once()
+
+
+def test_previous_with_no_history(player):
+ outcome = controls.previous(player)
+ assert not outcome.ok and "No previous track" in outcome.message
+
+
+def test_previous_with_history(player, track_factory):
+ player.history.append(track_factory("Earlier"))
+ assert controls.previous(player).ok
+
+
+def test_shuffle_an_empty_queue(player):
+ outcome = controls.shuffle(player)
+ assert not outcome.ok and "empty" in outcome.message
+
+
+def test_shuffle_a_queue(player, track_factory):
+ player.add_many([track_factory(str(i)) for i in range(5)])
+ assert controls.shuffle(player).ok
+ assert len(player.queue) == 5
+
+
+@pytest.mark.parametrize("start, expected", [
+ ("off", "track"), ("track", "queue"), ("queue", "off"),
+])
+def test_cycle_loop_walks_every_mode(player, start, expected):
+ player.loop_mode = start
+ outcome = controls.cycle_loop(player)
+ assert outcome.ok and player.loop_mode == expected
+ assert expected in outcome.message
+
+
+def test_set_loop(player):
+ assert controls.set_loop(player, "queue").ok
+ assert player.loop_mode == "queue"
+
+
+def test_stop_destroys_the_player(player):
+ outcome = controls.stop(player)
+ assert outcome.ok and player.is_destroyed
diff --git a/tests/test_errors.py b/tests/test_errors.py
index 6064016..36e2dc6 100644
--- a/tests/test_errors.py
+++ b/tests/test_errors.py
@@ -10,6 +10,7 @@
import discord
import pytest
+from discord import app_commands
from discord.ext import commands
from utils import errors
@@ -260,3 +261,24 @@ def test_the_global_check_is_registered_on_the_bot(monkeypatch):
assert guild_only in main.bot._checks
finally:
sys.modules.pop("main", None)
+
+
+# -- slash invocation -----------------------------------------------------
+
+async def test_an_out_of_range_value_names_the_bounds(ctx):
+ await errors.handle(ctx, commands.RangeError(101, minimum=0, maximum=100))
+ assert "between 0 and 100" in sent_text(ctx)
+
+
+async def test_a_slash_command_error_is_unwrapped_through_both_layers(ctx, caplog):
+ """Under `/` the real exception sits two wrappers deep."""
+ real = RuntimeError("boom")
+ inner = app_commands.CommandInvokeError(MagicMock(), real)
+ await errors.handle(ctx, commands.HybridCommandError(inner))
+ assert "Something went wrong" in sent_text(ctx)
+ assert any(r.exc_info and r.exc_info[1] is real for r in caplog.records)
+
+
+async def test_error_replies_are_private_under_slash(ctx):
+ await errors.handle(ctx, missing_arg())
+ assert ctx.send.await_args.kwargs["ephemeral"] is True
diff --git a/tests/test_help.py b/tests/test_help.py
index ff8d3ec..67cf588 100644
--- a/tests/test_help.py
+++ b/tests/test_help.py
@@ -33,13 +33,14 @@ def help_text(embed: discord.Embed) -> str:
async def test_every_registered_command_is_documented(bot):
text = help_text(build(bot, PREFIX))
- undocumented = [c.name for c in bot.commands if f"{PREFIX}{c.name}" not in text]
+ undocumented = [c.name for c in bot.commands
+ if not c.hidden and f"{PREFIX}{c.name}" not in text]
assert undocumented == [], f"commands missing from !help: {undocumented}"
async def test_effect_is_documented(bot):
"""The exact command the hardcoded help had silently dropped."""
- assert f"{PREFIX}effect`" in help_text(build(bot, PREFIX))
+ assert f"{PREFIX}effect [name]`" in help_text(build(bot, PREFIX))
async def test_every_alias_is_documented(bot):
@@ -84,3 +85,8 @@ async def move(ctx, from_pos: int, to_pos: int):
assert "`!move `" in line
assert "`!mv`" in line
assert "Move a track." in line
+
+
+async def test_the_owner_sync_command_stays_out_of_help(bot):
+ assert bot.get_command("sync").hidden
+ assert f"{PREFIX}sync" not in help_text(build(bot, PREFIX))
diff --git a/tests/test_lyrics_cog.py b/tests/test_lyrics_cog.py
index 11251c5..a51edec 100644
--- a/tests/test_lyrics_cog.py
+++ b/tests/test_lyrics_cog.py
@@ -150,3 +150,68 @@ async def test_a_query_without_a_separator_is_searched_once(cog, monkeypatch):
await cog._search("Bohemian Rhapsody")
assert fetch.await_count == 1, "there is no other order to try"
+
+
+# -- the Now Playing lyrics button ---------------------------------------
+
+async def test_the_cog_offers_itself_to_the_lyrics_button_while_loaded(monkeypatch):
+ from utils import now_playing_view
+
+ monkeypatch.setattr(now_playing_view, "_lyrics", None)
+ cog = LyricsCog(MagicMock())
+ await cog.cog_load()
+ assert now_playing_view._lyrics == cog.show_current
+ await cog.cog_unload()
+ assert now_playing_view._lyrics is None
+
+
+async def test_show_current_with_nothing_playing(cog):
+ player = MagicMock(current=None)
+ assert await cog.show_current(MagicMock(), player) is False
+
+
+async def test_show_current_when_nothing_is_found(cog, monkeypatch):
+ monkeypatch.setattr(cog, "_for_track", AsyncMock(return_value=None))
+ channel = MagicMock(send=AsyncMock())
+ assert await cog.show_current(channel, MagicMock()) is False
+ channel.send.assert_not_awaited()
+
+
+async def test_show_current_posts_unsynced_lyrics_to_the_channel(cog, monkeypatch):
+ plain = synced_lyrics.Lyrics("T", "A", plain="just words")
+ monkeypatch.setattr(cog, "_for_track", AsyncMock(return_value=plain))
+ channel = MagicMock(send=AsyncMock())
+ assert await cog.show_current(channel, MagicMock()) is True
+ assert "just words" in channel.send.await_args.kwargs["embed"].description
+
+
+async def test_show_current_follows_synced_lyrics(cog, monkeypatch):
+ synced = synced_lyrics.Lyrics("T", "A", lines=((0.0, "line"),))
+ monkeypatch.setattr(cog, "_for_track", AsyncMock(return_value=synced))
+ follow = AsyncMock()
+ monkeypatch.setattr(cog, "_follow", follow)
+ channel, player = MagicMock(), MagicMock()
+ assert await cog.show_current(channel, player) is True
+ follow.assert_awaited_once_with(channel, player, synced)
+
+
+# -- /lyrics ----------------------------------------------------------------
+
+async def test_slash_lyrics_go_to_the_channel_not_the_interaction(cog, monkeypatch):
+ """An interaction reply stops being editable after 15 minutes; a live
+ lyrics message is edited for as long as the music plays."""
+ from cogs.lyrics import Lyrics
+ from utils.player import players
+
+ monkeypatch.setattr(players, "get", lambda guild_id: None)
+ found = synced_lyrics.Lyrics("T", "A", plain="words")
+ monkeypatch.setattr(cog, "_search", AsyncMock(return_value=found))
+ present = AsyncMock()
+ monkeypatch.setattr(cog, "_present", present)
+ ctx = MagicMock(send=AsyncMock(), interaction=MagicMock())
+ ctx.typing = MagicMock(return_value=AsyncMock())
+
+ await Lyrics.lyrics.callback(cog, ctx, query="T - A")
+
+ assert present.await_args.args[0] is ctx.channel
+ assert ctx.send.await_args.kwargs["ephemeral"] is True
diff --git a/tests/test_now_playing_view.py b/tests/test_now_playing_view.py
new file mode 100644
index 0000000..b86d20c
--- /dev/null
+++ b/tests/test_now_playing_view.py
@@ -0,0 +1,199 @@
+"""
+The Now Playing buttons, pressed through faked interactions.
+
+Button callbacks are invoked directly with a fake interaction, the way
+discord.py would after its own dispatch; ``interaction_check`` is tested on its
+own, since discord.py runs it before any callback.
+"""
+
+from unittest.mock import AsyncMock, MagicMock
+
+import discord
+import pytest
+
+from utils import now_playing_view
+from utils.now_playing_view import NowPlayingControls
+
+
+@pytest.fixture
+def voice(fake_guild):
+ vc = MagicMock()
+ vc.is_playing.return_value = True
+ vc.is_paused.return_value = False
+ vc.channel = MagicMock(name="voice-channel")
+ vc.disconnect = AsyncMock()
+ fake_guild.voice_client = vc
+ return vc
+
+
+@pytest.fixture
+def interaction(voice):
+ i = MagicMock()
+ i.user = MagicMock(spec=discord.Member)
+ i.user.mention = "<@42>"
+ i.user.voice.channel = voice.channel
+ i.response.send_message = AsyncMock()
+ i.response.edit_message = AsyncMock()
+ i.response.defer = AsyncMock()
+ i.followup.send = AsyncMock()
+ i.channel = MagicMock(spec=discord.TextChannel)
+ return i
+
+
+@pytest.fixture
+def no_lyrics(monkeypatch):
+ monkeypatch.setattr(now_playing_view, "_lyrics", None)
+
+
+@pytest.fixture
+async def view(player, track_factory, no_lyrics):
+ return NowPlayingControls(player, track_factory("Song"), MagicMock())
+
+
+def reply(interaction) -> tuple[str, bool]:
+ """The text of the interaction's reply, and whether it was ephemeral."""
+ call = interaction.response.send_message.await_args
+ return call.kwargs["embed"].description, call.kwargs.get("ephemeral", False)
+
+
+def button(view: NowPlayingControls, emoji: str) -> discord.ui.Button:
+ return next(b for b in view.children
+ if isinstance(b, discord.ui.Button) and str(b.emoji) == emoji)
+
+
+async def press(view: NowPlayingControls, emoji: str, interaction) -> None:
+ b = button(view, emoji)
+ await b.callback(interaction)
+
+
+# -- who may press ------------------------------------------------------
+
+async def test_a_listener_in_the_bots_channel_may_press(view, interaction):
+ assert await view.interaction_check(interaction) is True
+
+
+async def test_someone_outside_the_voice_channel_is_refused(view, interaction):
+ interaction.user.voice.channel = MagicMock(name="another-channel")
+ assert await view.interaction_check(interaction) is False
+ text, ephemeral = reply(interaction)
+ assert ephemeral and "voice channel" in text
+
+
+async def test_someone_not_in_voice_at_all_is_refused(view, interaction):
+ interaction.user.voice = None
+ assert await view.interaction_check(interaction) is False
+
+
+async def test_a_destroyed_player_refuses_every_press(view, interaction):
+ view.player.destroy()
+ assert await view.interaction_check(interaction) is False
+ text, ephemeral = reply(interaction)
+ assert ephemeral and "ended" in text
+
+
+# -- each button ----------------------------------------------------------
+
+async def test_pause_edits_the_message_and_flips_the_button(view, interaction, voice):
+ await press(view, "โธ", interaction)
+ voice.pause.assert_called_once()
+ interaction.response.edit_message.assert_awaited_once()
+ assert str(view.pause_button.emoji) == "โถ๏ธ"
+
+
+async def test_pause_again_resumes(view, interaction, voice):
+ await press(view, "โธ", interaction)
+ voice.is_playing.return_value = False
+ voice.is_paused.return_value = True
+ await press(view, "โถ๏ธ", interaction)
+ voice.resume.assert_called_once()
+ assert str(view.pause_button.emoji) == "โธ"
+
+
+async def test_skip_is_announced_publicly_with_who_pressed(view, interaction, voice):
+ await press(view, "โญ", interaction)
+ voice.stop.assert_called_once()
+ text, ephemeral = reply(interaction)
+ assert not ephemeral and "Skipped" in text and "<@42>" in text
+
+
+async def test_skip_with_nothing_playing_is_a_private_error(view, interaction, voice):
+ voice.is_playing.return_value = False
+ await press(view, "โญ", interaction)
+ text, ephemeral = reply(interaction)
+ assert ephemeral and "Nothing is playing" in text
+
+
+async def test_previous_with_no_history_is_a_private_error(view, interaction):
+ await press(view, "โฎ", interaction)
+ text, ephemeral = reply(interaction)
+ assert ephemeral and "No previous track" in text
+
+
+async def test_stop_destroys_the_player(view, interaction):
+ await press(view, "โน", interaction)
+ assert view.player.is_destroyed
+ text, ephemeral = reply(interaction)
+ assert not ephemeral and "Disconnected" in text
+
+
+async def test_loop_cycles_and_shows_the_mode_in_place(view, interaction):
+ await press(view, "๐", interaction)
+ assert view.player.loop_mode == "track"
+ embed = interaction.response.edit_message.await_args.kwargs["embed"]
+ assert any(f.value == "Track" for f in embed.fields)
+
+
+async def test_shuffle_an_empty_queue_is_a_private_error(view, interaction):
+ await press(view, "๐", interaction)
+ text, ephemeral = reply(interaction)
+ assert ephemeral and "empty" in text
+
+
+async def test_queue_is_shown_only_to_who_asked(view, interaction):
+ await press(view, "๐", interaction)
+ call = interaction.response.send_message.await_args
+ assert call.kwargs["ephemeral"] is True
+ assert "Queue" in call.kwargs["embed"].title
+
+
+# -- lyrics -------------------------------------------------------------------
+
+async def test_no_lyrics_button_without_a_provider(view):
+ assert all(str(getattr(b, "emoji", "")) != "๐ค" for b in view.children)
+
+
+async def test_lyrics_button_hands_the_channel_to_the_provider(
+ player, track_factory, interaction, monkeypatch):
+ provider = AsyncMock(return_value=True)
+ monkeypatch.setattr(now_playing_view, "_lyrics", provider)
+ view = NowPlayingControls(player, track_factory("Song"), MagicMock())
+ await press(view, "๐ค", interaction)
+ provider.assert_awaited_once_with(interaction.channel, player)
+ interaction.followup.send.assert_not_awaited()
+
+
+async def test_lyrics_not_found_is_a_private_error(
+ player, track_factory, interaction, monkeypatch):
+ monkeypatch.setattr(now_playing_view, "_lyrics", AsyncMock(return_value=False))
+ view = NowPlayingControls(player, track_factory("Song"), MagicMock())
+ await press(view, "๐ค", interaction)
+ call = interaction.followup.send.await_args
+ assert call.kwargs["ephemeral"] is True
+ assert "Couldn't find lyrics" in call.kwargs["embed"].description
+
+
+def test_registering_and_clearing_the_provider(monkeypatch):
+ monkeypatch.setattr(now_playing_view, "_lyrics", None)
+ provider = AsyncMock()
+ now_playing_view.set_lyrics_provider(provider)
+ assert now_playing_view._lyrics is provider
+ now_playing_view.set_lyrics_provider(None)
+ assert now_playing_view._lyrics is None
+
+
+# -- retiring -------------------------------------------------------------------
+
+async def test_retire_disables_every_button_and_stops_the_view(view):
+ view.retire()
+ assert all(item.disabled for item in view.children)
+ assert view.is_finished()
diff --git a/tests/test_suggestions.py b/tests/test_suggestions.py
new file mode 100644
index 0000000..78241bb
--- /dev/null
+++ b/tests/test_suggestions.py
@@ -0,0 +1,42 @@
+"""Search suggestions for `/play`: best effort, and never an error."""
+
+import asyncio
+
+from services import suggestions
+from tests.test_synced_lyrics import FakeResponse, FakeSession
+
+PAYLOAD = ["bohemian", ["bohemian rhapsody", "bohemian rhapsody live"], [], {}]
+
+
+async def test_returns_the_suggested_terms():
+ session = FakeSession(FakeResponse(200, PAYLOAD))
+ assert await suggestions.fetch(session, "bohemian") == [
+ "bohemian rhapsody", "bohemian rhapsody live"]
+ assert session.calls[0]["params"]["q"] == "bohemian"
+
+
+async def test_urls_and_blank_input_are_not_looked_up():
+ session = FakeSession(FakeResponse(200, PAYLOAD))
+ assert await suggestions.fetch(session, "https://youtu.be/x") == []
+ assert await suggestions.fetch(session, " ") == []
+ assert session.calls == []
+
+
+async def test_caps_the_number_and_length_of_suggestions():
+ long = ["x" * 150] + [f"s{i}" for i in range(30)]
+ session = FakeSession(FakeResponse(200, ["q", long]))
+ found = await suggestions.fetch(session, "q")
+ assert len(found) == suggestions.MAX_SUGGESTIONS
+ assert all(len(s) <= suggestions.MAX_CHOICE_LEN for s in found)
+
+
+async def test_a_failing_endpoint_yields_nothing():
+ for session in (
+ FakeSession(FakeResponse(503)),
+ FakeSession(OSError("connection refused")),
+ FakeSession(asyncio.TimeoutError()),
+ FakeSession(FakeResponse(200, raises=ValueError("not json"))),
+ FakeSession(FakeResponse(200, {"unexpected": "shape"})),
+ FakeSession(FakeResponse(200, ["q", "not a list"])),
+ ):
+ assert await suggestions.fetch(session, "q") == []
diff --git a/utils/announcer.py b/utils/announcer.py
index bafa92f..3ef6b0e 100644
--- a/utils/announcer.py
+++ b/utils/announcer.py
@@ -8,11 +8,16 @@
"""
import logging
+from typing import TYPE_CHECKING, Optional
import discord
from services.media import Track
-from utils.embeds import info_embed, load_error_embed, now_playing_embed
+from utils.embeds import info_embed, load_error_embed
+from utils.now_playing_view import NowPlayingControls
+
+if TYPE_CHECKING:
+ from utils.player import MusicPlayer
log = logging.getLogger("loopify.announcer")
@@ -22,10 +27,30 @@ class ChannelAnnouncer:
def __init__(self, channel: discord.abc.Messageable) -> None:
self.channel = channel
+ # The Now Playing message whose buttons are still live, if any.
+ self._live: Optional[tuple[discord.Message, NowPlayingControls]] = None
+
+ async def now_playing(self, player: "MusicPlayer", track: Track,
+ requester: discord.abc.User) -> None:
+ await self.retire()
+ view = NowPlayingControls(player, track, requester)
+ message = await self._send(view.embed(), view)
+ if message is None:
+ view.stop()
+ else:
+ self._live = (message, view)
- async def now_playing(self, track: Track, requester: discord.abc.User,
- loop_mode: str) -> None:
- await self._send(now_playing_embed(track, requester, loop_mode=loop_mode))
+ async def retire(self) -> None:
+ """Disable the live Now Playing buttons, so only the current track has any."""
+ live, self._live = self._live, None
+ if live is None:
+ return
+ message, view = live
+ view.retire()
+ try:
+ await message.edit(view=view)
+ except discord.HTTPException as e:
+ log.debug("Could not retire Now Playing controls: %s", e)
async def load_failed(self, track: Track) -> None:
await self._send(load_error_embed(track))
@@ -36,10 +61,14 @@ async def idle_disconnect(self, after_seconds: float) -> None:
f"Disconnected after {after_seconds / 60:.0f} minutes of inactivity.",
))
- async def _send(self, embed: discord.Embed) -> None:
+ async def _send(self, embed: discord.Embed,
+ view: Optional[discord.ui.View] = None) -> Optional[discord.Message]:
"""A channel that has been deleted, or that we lost access to, is not an
error worth propagating into the playback loop."""
try:
- await self.channel.send(embed=embed)
+ if view is None:
+ return await self.channel.send(embed=embed)
+ return await self.channel.send(embed=embed, view=view)
except discord.HTTPException as e:
log.debug("Could not announce to channel: %s", e)
+ return None
diff --git a/utils/checks.py b/utils/checks.py
index 05b0242..169281a 100644
--- a/utils/checks.py
+++ b/utils/checks.py
@@ -13,21 +13,25 @@ def user_in_voice() -> Callable[[T], T]:
async def predicate(ctx: GuildContext) -> bool:
if not ctx.author.voice or not ctx.author.voice.channel:
await ctx.send(embed=error_embed(
- "You must be in a voice channel to use this command."))
+ "You must be in a voice channel to use this command."), ephemeral=True)
return False
return True
return commands.check(predicate)
+async def in_same_voice_channel(ctx: GuildContext) -> bool:
+ """The same-channel rule, callable on its own by a command that needs it
+ for only some of its arguments."""
+ if not ctx.author.voice:
+ await ctx.send(embed=error_embed("You must be in a voice channel."), ephemeral=True)
+ return False
+ if ctx.voice_client and ctx.author.voice.channel != ctx.voice_client.channel:
+ await ctx.send(embed=error_embed(
+ "You must be in the same voice channel as me."), ephemeral=True)
+ return False
+ return True
+
+
def same_voice_channel() -> Callable[[T], T]:
"""Check: user must be in the same voice channel as the bot."""
- async def predicate(ctx: GuildContext) -> bool:
- if not ctx.author.voice:
- await ctx.send(embed=error_embed("You must be in a voice channel."))
- return False
- if ctx.voice_client and ctx.author.voice.channel != ctx.voice_client.channel:
- await ctx.send(embed=error_embed(
- "You must be in the same voice channel as me."))
- return False
- return True
- return commands.check(predicate)
+ return commands.check(in_same_voice_channel)
diff --git a/utils/context.py b/utils/context.py
index 30eac24..5eca21a 100644
--- a/utils/context.py
+++ b/utils/context.py
@@ -11,7 +11,7 @@
without a ``None`` test the check has already made impossible.
"""
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional
import discord
from discord.ext import commands
@@ -29,8 +29,18 @@ def me(self) -> discord.Member: ...
@property
def voice_client(self) -> Optional[discord.VoiceClient]: ...
+
+ # mypy cannot solve commands.hybrid_command's union of Concatenate callback
+ # types when the callback is a cog method (it can for a plain function, and
+ # pyright can for both). This is the same decorator with a signature mypy
+ # accepts; at runtime it is commands.hybrid_command itself.
+ def hybrid_command(
+ name: str = ..., **attrs: Any,
+ ) -> Callable[[Callable[..., Coroutine[Any, Any, None]]],
+ commands.HybridCommand[Any, ..., None]]: ...
else:
GuildContext = commands.Context
+ hybrid_command = commands.hybrid_command
async def guild_only(ctx: commands.Context[Any]) -> bool:
diff --git a/utils/controls.py b/utils/controls.py
new file mode 100644
index 0000000..69554a8
--- /dev/null
+++ b/utils/controls.py
@@ -0,0 +1,79 @@
+"""
+Playback actions shared by commands and the Now Playing buttons.
+
+Both surfaces do the same thing and must say the same thing; only how the
+answer is delivered differs โ an embed for a command, an in-place edit or an
+ephemeral reply for a button. So each action returns an :class:`Outcome` and
+leaves the delivery to its caller.
+"""
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Literal, Optional
+
+if TYPE_CHECKING:
+ from utils.player import MusicPlayer
+
+LoopMode = Literal["off", "track", "queue"]
+
+LOOP_ICONS: dict[str, str] = {"off": "โก๏ธ", "track": "๐", "queue": "๐"}
+_NEXT_LOOP: dict[str, LoopMode] = {"off": "track", "track": "queue", "queue": "off"}
+
+NOTHING_PLAYING = "Nothing is playing."
+STOPPED = "Disconnected and cleared the queue."
+
+
+@dataclass(frozen=True)
+class Outcome:
+ ok: bool
+ message: str
+
+
+def pause(player: Optional["MusicPlayer"]) -> Outcome:
+ if player and player.pause():
+ return Outcome(True, "Paused โธ")
+ return Outcome(False, "Nothing is playing right now.")
+
+
+def resume(player: Optional["MusicPlayer"]) -> Outcome:
+ if player and player.resume():
+ return Outcome(True, "Resumed โถ๏ธ")
+ return Outcome(False, "Nothing is paused.")
+
+
+def skip(player: Optional["MusicPlayer"]) -> Outcome:
+ if player and player.skip():
+ return Outcome(True, "Skipped โญ")
+ return Outcome(False, NOTHING_PLAYING)
+
+
+def previous(player: Optional["MusicPlayer"]) -> Outcome:
+ if player and player.go_previous():
+ return Outcome(True, "Playing previous track โฎ")
+ return Outcome(False, "No previous track in history.")
+
+
+def shuffle(player: Optional["MusicPlayer"]) -> Outcome:
+ if not player or player.is_empty:
+ return Outcome(False, "Queue is empty.")
+ player.shuffle()
+ return Outcome(True, "Queue shuffled ๐")
+
+
+def set_loop(player: Optional["MusicPlayer"], mode: LoopMode) -> Outcome:
+ if not player:
+ return Outcome(False, NOTHING_PLAYING)
+ player.loop_mode = mode
+ return Outcome(True, f"Loop mode set to **{mode}** {LOOP_ICONS[mode]}")
+
+
+def cycle_loop(player: Optional["MusicPlayer"]) -> Outcome:
+ if not player:
+ return Outcome(False, NOTHING_PLAYING)
+ return set_loop(player, _NEXT_LOOP[player.loop_mode])
+
+
+def stop(player: Optional["MusicPlayer"]) -> Outcome:
+ if not player:
+ return Outcome(False, NOTHING_PLAYING)
+ player.destroy()
+ return Outcome(True, STOPPED)
diff --git a/utils/errors.py b/utils/errors.py
index b0f2171..bc9e990 100644
--- a/utils/errors.py
+++ b/utils/errors.py
@@ -14,12 +14,18 @@
import logging
import discord
+from discord import app_commands
from discord.ext import commands
from utils.embeds import error_embed
log = logging.getLogger("loopify.errors")
+# What discord.py wraps a command's real exception in. Under `/` it is wrapped
+# twice: HybridCommandError around app_commands.CommandInvokeError around it.
+_WRAPPERS = (commands.CommandInvokeError, commands.HybridCommandError,
+ app_commands.CommandInvokeError)
+
def usage(ctx: commands.Context) -> str:
"""
@@ -41,6 +47,8 @@ def _input_detail(error: commands.UserInputError) -> str:
return f"Missing the `{error.param.name}` argument."
if isinstance(error, commands.TooManyArguments):
return "That command takes fewer arguments than you gave it."
+ if isinstance(error, commands.RangeError):
+ return f"That value must be between {error.minimum} and {error.maximum}."
if isinstance(error, commands.BadArgument):
return "One of those arguments isn't the right type."
return "I couldn't make sense of that."
@@ -48,8 +56,8 @@ def _input_detail(error: commands.UserInputError) -> str:
async def handle(ctx: commands.Context, error: Exception) -> None:
"""Reply to the user, or log, depending on what went wrong."""
- # discord.py wraps exceptions raised inside a command body.
- error = getattr(error, "original", error)
+ while isinstance(error, _WRAPPERS):
+ error = error.original
if isinstance(error, commands.CommandNotFound):
return
@@ -81,6 +89,7 @@ async def handle(ctx: commands.Context, error: Exception) -> None:
async def _reply(ctx: commands.Context, message: str) -> None:
try:
- await ctx.send(embed=error_embed(message))
+ # Under `/` only the person who erred sees it; under `!` it is ignored.
+ await ctx.send(embed=error_embed(message), ephemeral=True)
except discord.HTTPException as e:
log.debug("Could not report an error to the channel: %s", e)
diff --git a/utils/help.py b/utils/help.py
index 86dbe32..8b6c398 100644
--- a/utils/help.py
+++ b/utils/help.py
@@ -58,6 +58,7 @@ def build(bot: commands.Bot, prefix: str) -> discord.Embed:
embed.add_field(name=name, value=value, inline=False)
embed.set_footer(
text=f"Tip: {prefix}play takes YouTube/SoundCloud searches "
- f"(prefix with sc:) and most links yt-dlp supports."
+ f"(prefix with sc:) and most links yt-dlp supports. "
+ f"Every command also works as /command."
)
return embed
diff --git a/utils/now_playing_view.py b/utils/now_playing_view.py
new file mode 100644
index 0000000..431a99f
--- /dev/null
+++ b/utils/now_playing_view.py
@@ -0,0 +1,151 @@
+"""
+The buttons under each Now Playing message.
+
+Every button runs the same action as its command (see :mod:`utils.controls`);
+what differs is the delivery. A button that changes what the message shows
+(pause, loop) edits it in place. One that changes playback for everyone (skip,
+previous, stop, shuffle) answers publicly, naming who pressed it. Anything
+personal โ the queue, a refusal, an error โ is ephemeral.
+
+The lyrics button needs the Lyrics cog, which this module must not import: the
+cog registers itself with :func:`set_lyrics_provider` on load, and without a
+provider the button is left off.
+"""
+
+from typing import TYPE_CHECKING, Awaitable, Callable, Optional
+
+import discord
+
+from services.media import Track
+from utils import controls
+from utils.embeds import error_embed, now_playing_embed, queue_embed, success_embed
+
+if TYPE_CHECKING:
+ from utils.player import MusicPlayer
+
+# Posts the playing track's lyrics to a channel; False when there are none.
+LyricsProvider = Callable[[discord.abc.Messageable, "MusicPlayer"], Awaitable[bool]]
+
+_lyrics: Optional[LyricsProvider] = None
+
+PAUSE, RESUME = "โธ", "โถ๏ธ"
+
+
+def set_lyrics_provider(provider: Optional[LyricsProvider]) -> None:
+ global _lyrics
+ _lyrics = provider
+
+
+class NowPlayingControls(discord.ui.View):
+ def __init__(self, player: "MusicPlayer", track: Track,
+ requester: discord.abc.User) -> None:
+ # No timeout: a long track must not lose its controls halfway through.
+ # The announcer retires the view instead, which also frees it.
+ super().__init__(timeout=None)
+ self.player = player
+ self.track = track
+ self.requester = requester
+ if _lyrics is None:
+ self.remove_item(self.lyrics_button)
+
+ def embed(self) -> discord.Embed:
+ return now_playing_embed(self.track, self.requester,
+ loop_mode=self.player.loop_mode)
+
+ def retire(self) -> None:
+ """Grey every button out and release the view from discord.py's store."""
+ for item in self.children:
+ if isinstance(item, discord.ui.Button):
+ item.disabled = True
+ self.stop()
+
+ async def interaction_check(self, interaction: discord.Interaction) -> bool:
+ if self.player.is_destroyed:
+ await _private_error(interaction, "This player has ended.")
+ return False
+ vc = self.player.voice
+ user = interaction.user
+ listening = (isinstance(user, discord.Member) and user.voice is not None
+ and vc is not None and user.voice.channel == vc.channel)
+ if not listening:
+ await _private_error(interaction,
+ "Join my voice channel to use these controls.")
+ return False
+ return True
+
+ async def _announce(self, interaction: discord.Interaction,
+ outcome: controls.Outcome) -> None:
+ if not outcome.ok:
+ return await _private_error(interaction, outcome.message)
+ await interaction.response.send_message(embed=success_embed(
+ f"{outcome.message} โ {interaction.user.mention}"))
+
+ async def _edit_in_place(self, interaction: discord.Interaction,
+ outcome: controls.Outcome) -> None:
+ if not outcome.ok:
+ return await _private_error(interaction, outcome.message)
+ await interaction.response.edit_message(embed=self.embed(), view=self)
+
+ # -- Row 1: transport ---------------------------------------------------
+
+ @discord.ui.button(emoji="โฎ", style=discord.ButtonStyle.secondary, row=0)
+ async def previous_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ await self._announce(interaction, controls.previous(self.player))
+
+ @discord.ui.button(emoji=PAUSE, style=discord.ButtonStyle.primary, row=0)
+ async def pause_button(self, interaction: discord.Interaction,
+ button: "discord.ui.Button[NowPlayingControls]") -> None:
+ if controls.pause(self.player).ok:
+ button.emoji = RESUME
+ elif controls.resume(self.player).ok:
+ button.emoji = PAUSE
+ else:
+ return await _private_error(interaction, controls.NOTHING_PLAYING)
+ await interaction.response.edit_message(embed=self.embed(), view=self)
+
+ @discord.ui.button(emoji="โญ", style=discord.ButtonStyle.secondary, row=0)
+ async def skip_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ await self._announce(interaction, controls.skip(self.player))
+
+ @discord.ui.button(emoji="โน", style=discord.ButtonStyle.danger, row=0)
+ async def stop_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ await self._announce(interaction, controls.stop(self.player))
+
+ # -- Row 2: queue and extras ------------------------------------------
+
+ @discord.ui.button(emoji="๐", style=discord.ButtonStyle.secondary, row=1)
+ async def loop_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ await self._edit_in_place(interaction, controls.cycle_loop(self.player))
+
+ @discord.ui.button(emoji="๐", style=discord.ButtonStyle.secondary, row=1)
+ async def shuffle_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ await self._announce(interaction, controls.shuffle(self.player))
+
+ @discord.ui.button(emoji="๐", style=discord.ButtonStyle.secondary, row=1)
+ async def queue_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ await interaction.response.send_message(
+ embed=queue_embed(self.player.to_list(), self.player.current),
+ ephemeral=True)
+
+ @discord.ui.button(emoji="๐ค", style=discord.ButtonStyle.secondary, row=1)
+ async def lyrics_button(self, interaction: discord.Interaction,
+ _button: "discord.ui.Button[NowPlayingControls]") -> None:
+ channel = interaction.channel
+ if _lyrics is None or not isinstance(channel, discord.abc.Messageable):
+ return await _private_error(interaction, "Lyrics are unavailable.")
+ # The lookup can take seconds; Discord wants an answer within three.
+ await interaction.response.defer()
+ if not await _lyrics(channel, self.player):
+ await interaction.followup.send(embed=error_embed(
+ "Couldn't find lyrics for this song."), ephemeral=True)
+
+
+async def _private_error(interaction: discord.Interaction, message: str) -> None:
+ await interaction.response.send_message(embed=error_embed(message),
+ ephemeral=True)
diff --git a/utils/player.py b/utils/player.py
index fe0a842..fd43677 100644
--- a/utils/player.py
+++ b/utils/player.py
@@ -396,9 +396,7 @@ async def _player_loop(self) -> None:
if not silent:
await self.announcer.now_playing(
- track, track.get("requester") or self.guild.me,
- self.loop_mode,
- )
+ self, track, track.get("requester") or self.guild.me)
await self._wait_for_end(track)
# Measured from the spawn, not from _start_ts, which is
@@ -523,6 +521,9 @@ def destroy(self) -> None:
asyncio.ensure_future(vc.disconnect(force=True))
if self._task and not self._task.done():
self._task.cancel()
+ # destroy() is sync, so the Now Playing buttons are greyed out in a
+ # task of their own; left live, they would act on a dead player.
+ self.bot.loop.create_task(self.announcer.retire())
if self._on_destroy is not None:
self._on_destroy(self.guild.id)