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 <name>` 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 <raw>` 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 <name> --------------------------------------------------------- + +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 <from_pos> <to_pos>`" 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)