diff --git a/README.md b/README.md index b4b1501..c2b233f 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,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 | | `!effects` | List all available effects | ### ๐ŸŽค Extras diff --git a/cogs/effects.py b/cogs/effects.py index 5d60f05..6241805 100644 --- a/cogs/effects.py +++ b/cogs/effects.py @@ -1,138 +1,92 @@ import time +from dataclasses import dataclass import discord from discord.ext import commands -from utils.player import players -from utils.embeds import success_embed, error_embed from utils.checks import same_voice_channel +from utils.embeds import BLURPLE, error_embed, success_embed +from utils.player import players # Minimum seconds between effect changes per guild โ€” each one restarts the # FFmpeg process, so rapid toggling is throttled to protect CPU/memory. _EFFECT_COOLDOWN = 3.0 -# FFmpeg audio-filter presets. Applying one respawns the stream through -# ``-af ``, resuming at the current playback position โ€” see -# ``MusicPlayer.apply_effect``. -EFFECTS = { - "bass": "equalizer=f=54:width_type=o:width=2:g=5", # gentle low-end lift - "bassboost": "equalizer=f=54:width_type=o:width=2:g=10", # heavy low-end lift +@dataclass(frozen=True) +class Effect: + """An FFmpeg filter chain and what the bot says when it is applied.""" + filter: str + label: str + + +# The single source of truth for effects: the filter, the reply, and โ€” via the +# command aliases built from these keys below โ€” the command name itself. Adding +# an effect is one entry here and nothing else. +# +# Applying one respawns the stream through ``-af ``, resuming at the +# current playback position โ€” see ``MusicPlayer.apply_effect``. +EFFECTS: dict[str, Effect] = { + # gentle / heavy low-end lift + "bass": Effect("equalizer=f=54:width_type=o:width=2:g=5", "Bass boost applied ๐Ÿ”Š"), + "bassboost": Effect("equalizer=f=54:width_type=o:width=2:g=10", "Heavy bass boost applied ๐Ÿ’ฅ"), # The leading `aresample=48000` is load-bearing: `asetrate` *reinterprets* a # stream's declared rate instead of scaling it, so without normalising first # the speed factor becomes 48000*N/ and differs per track. A # 44.1 kHz upload ran at 1.36x and a 22 kHz one at 2.72x, not 1.25x. - "nightcore": "aresample=48000,asetrate=48000*1.25,aresample=48000", # +pitch, +speed - "vaporwave": "aresample=48000,asetrate=48000*0.8,aresample=48000", # -pitch, -speed - "treble": "equalizer=f=8000:width_type=o:width=2:g=5", # high-end lift - "echo": "aecho=0.8:0.88:60:0.4", # short echo - "karaoke": "pan=stereo|c0=c0-c1|c1=c1-c0", # cancel centre vocals - "8d": "apulsator=hz=0.08", # rotating stereo + "nightcore": Effect("aresample=48000,asetrate=48000*1.25,aresample=48000", + "Nightcore effect applied ๐ŸŒ™โœจ"), + "vaporwave": Effect("aresample=48000,asetrate=48000*0.8,aresample=48000", + "Vaporwave effect applied ๐ŸŒŠ๐ŸŽถ"), + "treble": Effect("equalizer=f=8000:width_type=o:width=2:g=5", "Treble boost applied ๐ŸŽต"), + "echo": Effect("aecho=0.8:0.88:60:0.4", "Echo effect applied ๐Ÿ””"), + "karaoke": Effect("pan=stereo|c0=c0-c1|c1=c1-c0", "Karaoke mode on ๐ŸŽค"), + "8d": Effect("apulsator=hz=0.08", "8D audio applied ๐ŸŽง *Use headphones!*"), } -_LABELS = { - "bass": "Bass boost applied ๐Ÿ”Š", - "bassboost": "Heavy bass boost applied ๐Ÿ’ฅ", - "nightcore": "Nightcore effect applied ๐ŸŒ™โœจ", - "vaporwave": "Vaporwave effect applied ๐ŸŒŠ๐ŸŽถ", - "treble": "Treble boost applied ๐ŸŽต", - "echo": "Echo effect applied ๐Ÿ””", - "8d": "8D audio applied ๐ŸŽง *Use headphones!*", - "karaoke": "Karaoke mode on ๐ŸŽค", -} +# discord.py's CogMeta collects commands when the class body is executed, so a +# command cannot be registered per effect after the fact. One command carrying +# every effect name as an alias gets the same result from a single definition: +# `ctx.invoked_with` says which name the user actually typed. +_EFFECT_NAMES = list(EFFECTS) -class Effects(commands.Cog): +class Effects(commands.Cog, name="๐ŸŽ›๏ธ Audio Effects"): def __init__(self, bot: commands.Bot): self.bot = bot self._last_change: dict[int, float] = {} # guild_id โ†’ monotonic time def _throttled(self, ctx) -> bool: now = time.monotonic() - last = self._last_change.get(ctx.guild.id, 0.0) - if now - last < _EFFECT_COOLDOWN: + if now - self._last_change.get(ctx.guild.id, 0.0) < _EFFECT_COOLDOWN: return True self._last_change[ctx.guild.id] = now return False - def _apply(self, ctx, name: str, filter_str: str) -> bool: - player = players.get(ctx.guild.id) - if not player: - return False - return player.apply_effect(name, filter_str) - - async def _run(self, ctx, name: str): + async def _switch_to(self, ctx, name: str | None, filter_str: str, label: str) -> None: + """Throttle, apply, and report โ€” the whole path every effect command takes.""" if self._throttled(ctx): return await ctx.send(embed=error_embed( f"Easy โ€” wait {_EFFECT_COOLDOWN:.0f}s between effect changes." )) - if self._apply(ctx, name, EFFECTS[name]): - await ctx.send(embed=success_embed(_LABELS[name])) + player = players.get(ctx.guild.id) + if player and player.apply_effect(name, filter_str): + await ctx.send(embed=success_embed(label)) else: await ctx.send(embed=error_embed("Nothing is playing.")) - # โ”€โ”€ Effect commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - @commands.command() - @same_voice_channel() - async def bass(self, ctx): - """Add a light bass boost.""" - await self._run(ctx, "bass") - - @commands.command() - @same_voice_channel() - async def bassboost(self, ctx): - """Add a heavy bass boost.""" - await self._run(ctx, "bassboost") - - @commands.command() - @same_voice_channel() - async def nightcore(self, ctx): - """Apply nightcore (faster + higher pitch).""" - await self._run(ctx, "nightcore") - - @commands.command() + @commands.command(name=_EFFECT_NAMES[0], aliases=_EFFECT_NAMES[1:], + help="Apply an audio effect. Use !effects to see them all.") @same_voice_channel() - async def vaporwave(self, ctx): - """Apply vaporwave (slower + lower pitch).""" - await self._run(ctx, "vaporwave") - - @commands.command() - @same_voice_channel() - async def treble(self, ctx): - """Boost treble frequencies.""" - await self._run(ctx, "treble") - - @commands.command() - @same_voice_channel() - async def echo(self, ctx): - """Add an echo effect.""" - await self._run(ctx, "echo") - - @commands.command(name="8d") - @same_voice_channel() - async def eight_d(self, ctx): - """Apply 8D audio (rotating stereo).""" - await self._run(ctx, "8d") - - @commands.command() - @same_voice_channel() - async def karaoke(self, ctx): - """Remove centre vocals.""" - await self._run(ctx, "karaoke") + async def apply_effect(self, ctx): + effect = EFFECTS[ctx.invoked_with.lower()] + await self._switch_to(ctx, ctx.invoked_with.lower(), effect.filter, effect.label) @commands.command(name="reset", aliases=["fxreset", "noeffect"]) @same_voice_channel() async def reset_effect(self, ctx): """Remove all audio effects.""" - if self._throttled(ctx): - return await ctx.send(embed=error_embed( - f"Easy โ€” wait {_EFFECT_COOLDOWN:.0f}s between effect changes." - )) - if self._apply(ctx, None, ""): - await ctx.send(embed=success_embed("Audio effects removed โœ…")) - else: - await ctx.send(embed=error_embed("Nothing is playing.")) + await self._switch_to(ctx, None, "", "Audio effects removed โœ…") @commands.command(name="effect") async def current_effect(self, ctx): @@ -144,13 +98,12 @@ async def current_effect(self, ctx): @commands.command(name="effects") async def list_effects(self, ctx): """List all available audio effects.""" - names = ", ".join(f"`!{k}`" for k in EFFECTS) - embed = discord.Embed( + await ctx.send(embed=discord.Embed( title="๐ŸŽ›๏ธ Available Effects", - description=names + "\n\nUse `!reset` to remove all effects.", - color=0x5865F2, - ) - await ctx.send(embed=embed) + description=", ".join(f"`!{name}`" for name in EFFECTS) + + "\n\nUse `!reset` to remove all effects.", + color=BLURPLE, + )) async def setup(bot): diff --git a/cogs/lyrics.py b/cogs/lyrics.py index 44b9be8..db2af4e 100644 --- a/cogs/lyrics.py +++ b/cogs/lyrics.py @@ -1,13 +1,29 @@ from discord.ext import commands + from services import lyrics_api +from utils.embeds import error_embed, lyrics_embed from utils.player import players -from utils.embeds import lyrics_embed, error_embed -class Lyrics(commands.Cog): +class Lyrics(commands.Cog, name="๐ŸŽค Lyrics"): def __init__(self, bot): self.bot = bot + @staticmethod + def _split_query(query: str) -> tuple[str, str]: + """Split a ``title - artist`` query. A query without the separator is all title.""" + if " - " in query: + title, artist = query.split(" - ", 1) + return title.strip(), artist.strip() + return query.strip(), "" + + def _current_track(self, ctx) -> tuple[str, str]: + """Title and artist of whatever is playing, or ``("", "")`` if nothing is.""" + player = players.get(ctx.guild.id) + if not player or not player.current: + return "", "" + return player.current["title"], player.current.get("uploader", "") + @commands.command(aliases=["ly"]) async def lyrics(self, ctx, *, query: str = None): """ @@ -17,42 +33,22 @@ async def lyrics(self, ctx, *, query: str = None): !lyrics - <artist> โ†’ title + artist """ async with ctx.typing(): - title, artist = "", "" - - if query: - # Support "title - artist" format - if " - " in query: - parts = query.split(" - ", 1) - title, artist = parts[0].strip(), parts[1].strip() - else: - title = query.strip() - else: - # Fall back to the currently playing track. - player = players.get(ctx.guild.id) - if not player or not player.current: - return await ctx.send(embed=error_embed( - "Nothing is playing. Provide a song name: `!lyrics <title>`" - )) - current = player.current - # Spotify titles are "Song โ€” Artist"; split for a cleaner search. - title = current["title"].split(" โ€” ")[0].strip() - artist = current.get("uploader", "") + title, artist = (self._split_query(query) if query + else self._current_track(ctx)) + if not title: + return await ctx.send(embed=error_embed( + "Nothing is playing. Provide a song name: `!lyrics <title>`" + )) result = await lyrics_api.fetch(title, artist, loop=self.bot.loop) - if not result: return await ctx.send(embed=error_embed( f"Couldn't find lyrics for **{title}**." )) - embeds = lyrics_embed(result["title"], result["artist"], result["lyrics"]) - for embed in embeds: + for embed in lyrics_embed(result["title"], result["artist"], result["lyrics"]): await ctx.send(embed=embed) - @lyrics.error - async def lyrics_error(self, ctx, error): - await ctx.send(embed=error_embed(str(error))) - async def setup(bot): await bot.add_cog(Lyrics(bot)) diff --git a/cogs/music.py b/cogs/music.py index 639b6f2..cd9176a 100644 --- a/cogs/music.py +++ b/cogs/music.py @@ -6,7 +6,8 @@ from services import media from utils.player import players, MusicPlayer, MAX_QUEUE -from utils.embeds import queue_embed, now_playing_embed, error_embed, success_embed +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 log = logging.getLogger("loopify.music") @@ -25,7 +26,7 @@ def _is_playlist_url(query: str) -> bool: return "/sets/" in q or "/album/" in q # SoundCloud set / Bandcamp album -class Music(commands.Cog): +class Music(commands.Cog, name="๐ŸŽต Music & Queue"): def __init__(self, bot: commands.Bot): self.bot = bot @@ -55,7 +56,7 @@ async def _ensure_voice(self, ctx) -> bool: return False def _player(self, ctx) -> MusicPlayer: - return players.get_or_create(self.bot, ctx) + return players.get_or_create(self.bot, ctx.guild, ctx.channel) # โ”€โ”€ Playback commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -98,7 +99,7 @@ async def _enqueue(self, ctx, player: MusicPlayer, tracks: list[dict], batch_lab f"Queue is full (max {MAX_QUEUE} tracks)." )) if not was_idle: - await ctx.send(embed=self._added_embed(tracks[0])) + await ctx.send(embed=added_embed(tracks[0])) else: added = player.add_many(tracks) if added == 0: @@ -109,16 +110,6 @@ async def _enqueue(self, ctx, player: MusicPlayer, tracks: list[dict], batch_lab await ctx.send(embed=success_embed( f"Added **{added} tracks** from {batch_label} to the queue.{skipped}" )) - # When idle, the player loop picks up the newly-added track automatically. - - @staticmethod - def _added_embed(track: dict) -> discord.Embed: - url = track.get("url") or track.get("spotify_url") - title = f"[{track['title']}]({url})" if url else track["title"] - embed = discord.Embed(description=f"โž• Added to queue: **{title}**", color=0x5865F2) - if track.get("thumbnail"): - embed.set_thumbnail(url=track["thumbnail"]) - return embed @commands.command() @same_voice_channel() @@ -280,7 +271,6 @@ async def on_voice_state_update(self, member, before, after): vc = member.guild.voice_client if not vc: return - # Only react to people leaving the bot's own channel. if before.channel != vc.channel: return if len([m for m in vc.channel.members if not m.bot]) == 0: diff --git a/config.py b/config.py index 5891527..aee26f6 100644 --- a/config.py +++ b/config.py @@ -20,18 +20,12 @@ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) load_dotenv(os.path.join(PROJECT_ROOT, ".env")) -# โ”€โ”€ Bot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ DISCORD_TOKEN = os.getenv("DISCORD_TOKEN") COMMAND_PREFIX = os.getenv("COMMAND_PREFIX", "!") +GENIUS_TOKEN = os.getenv("GENIUS_TOKEN") +COOKIES_PATH = os.getenv("COOKIES_PATH") +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() -# โ”€โ”€ Genius (Lyrics) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -GENIUS_TOKEN = os.getenv("GENIUS_TOKEN") - -# โ”€โ”€ Misc โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -COOKIES_PATH = os.getenv("COOKIES_PATH") -LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() - -# โ”€โ”€ Cogs to load โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ COGS = [ "cogs.music", "cogs.effects", diff --git a/main.py b/main.py index d4589a1..a1e371f 100644 --- a/main.py +++ b/main.py @@ -5,8 +5,9 @@ from discord.ext import commands import config -from config import DISCORD_TOKEN, COMMAND_PREFIX, COGS +from config import COGS, COMMAND_PREFIX, DISCORD_TOKEN from utils import errors +from utils.help import build as build_help config.configure_logging() config.log_runtime() @@ -21,13 +22,11 @@ bot = commands.Bot( command_prefix=COMMAND_PREFIX, intents=intents, - help_command=None, # custom help below + help_command=None, # replaced by the generated one below case_insensitive=True, ) -# โ”€โ”€ Events โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @bot.event async def on_ready(): log.info("Logged in as %s (ID: %s) โ€” serving %d guild(s)", @@ -46,37 +45,11 @@ async def on_command_error(ctx, error): await errors.handle(ctx, error) -# โ”€โ”€ Custom help command โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - @bot.command(name="help") async def help_command(ctx): - p = COMMAND_PREFIX - embed = discord.Embed(title="๐ŸŽต Music Bot โ€” Commands", color=0x1DB954) - embed.add_field(name="โ–ถ๏ธ Playback", value=( - f"`{p}play <song/url>` โ€” Play from YouTube, SoundCloud or a link\n" - f"`{p}pause` ยท `{p}resume` ยท `{p}skip` ยท `{p}previous`\n" - f"`{p}stop` โ€” Stop & disconnect\n" - f"`{p}nowplaying` โ€” Show current track" - ), inline=False) - embed.add_field(name="๐Ÿ“‹ Queue", value=( - f"`{p}queue [page]` ยท `{p}shuffle` ยท `{p}remove <#>`\n" - f"`{p}move <from> <to>` ยท `{p}clear`\n" - f"`{p}loop <track|queue|off>` ยท `{p}autoplay`" - ), inline=False) - embed.add_field(name="๐ŸŽ›๏ธ Effects", value=( - f"`{p}bass` `{p}bassboost` `{p}nightcore` `{p}vaporwave`\n" - f"`{p}treble` `{p}echo` `{p}8d` `{p}karaoke` `{p}reset`\n" - f"`{p}effects` โ€” List all effects" - ), inline=False) - embed.add_field(name="๐ŸŽค Extras", value=( - f"`{p}lyrics [song]` โ€” Get song lyrics\n" - f"`{p}volume <0-100>` โ€” Set volume" - ), inline=False) - embed.set_footer(text=f"Tip: {p}play works with YouTube/SoundCloud searches (use sc:) and most links yt-dlp supports!") - await ctx.send(embed=embed) - - -# โ”€โ”€ Load cogs & run โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + """Show this message.""" + await ctx.send(embed=build_help(bot, COMMAND_PREFIX)) + async def main(): async with bot: diff --git a/services/lyrics_api.py b/services/lyrics_api.py index 887f930..33b262e 100644 --- a/services/lyrics_api.py +++ b/services/lyrics_api.py @@ -1,19 +1,25 @@ """ -Lyrics service โ€” uses lyricsgenius (Genius API). +Lyrics service โ€” Genius, via lyricsgenius. -Required .env variable: - GENIUS_TOKEN +Required .env variable: ``GENIUS_TOKEN`` (see :mod:`config`). """ -import os import asyncio -import lyricsgenius +import logging +from functools import lru_cache from typing import Optional +import lyricsgenius + +from config import GENIUS_TOKEN + +log = logging.getLogger("loopify.lyrics") -def _get_client() -> lyricsgenius.Genius: - token = os.getenv("GENIUS_TOKEN") - genius = lyricsgenius.Genius(token, quiet=True, skip_non_songs=True) + +@lru_cache(maxsize=1) +def _client() -> lyricsgenius.Genius: + """One client for the process โ€” rebuilding it per query buys nothing.""" + genius = lyricsgenius.Genius(GENIUS_TOKEN, quiet=True, skip_non_songs=True) genius.remove_section_headers = False return genius @@ -21,28 +27,26 @@ def _get_client() -> lyricsgenius.Genius: async def fetch(title: str, artist: str = "", *, loop=None) -> Optional[dict]: """ Search Genius for lyrics. - Returns dict with keys: title, artist, lyrics, url โ€” or None. + + Returns a dict with ``title``, ``artist``, ``lyrics`` and ``url``, or None + when there is no match or Genius is unreachable โ€” a missing lyric is never + a reason to take the bot down. """ loop = loop or asyncio.get_event_loop() def _search(): - genius = _get_client() - if artist: - song = genius.search_song(title, artist) - else: - song = genius.search_song(title) - return song + return _client().search_song(title, artist) if artist else _client().search_song(title) try: song = await loop.run_in_executor(None, _search) - if not song: - return None - return { - "title": song.title, - "artist": song.artist, - "lyrics": song.lyrics, - "url": song.url, - } except Exception as e: - print(f"[Lyrics] Error: {e}") + log.warning("Genius lookup failed for %r: %s", title, e) + return None + if not song: return None + return { + "title": song.title, + "artist": song.artist, + "lyrics": song.lyrics, + "url": song.url, + } diff --git a/services/media.py b/services/media.py index 418c4d4..5a7d9af 100644 --- a/services/media.py +++ b/services/media.py @@ -9,13 +9,12 @@ - Fast enqueue: searches/playlists use flat extraction (metadata only). - Reliable playback: the audio is streamed by yt-dlp itself and piped into FFmpeg (``spawn_stream`` + ``make_pipe_source``). yt-dlp owns cookies, - signature solving and throttling, which is what makes YouTube work from - datacenter IPs โ€” see the note above those functions. + signature solving and throttling โ€” see the note above those functions. - Non-blocking: every yt-dlp metadata call runs in a thread executor. Track dict shape:: - {title, url, stream, duration, thumbnail, uploader, source, query} + {title, url, duration, thumbnail, uploader, source, query} """ import os @@ -32,6 +31,8 @@ import discord import yt_dlp +from config import COOKIES_PATH + log = logging.getLogger("loopify.media") # YouTube player clients, tried in order. ONE definition โ€” both the metadata @@ -71,10 +72,9 @@ } # Optional cookies file (helps with age/region-gated or bot-checked videos). -_cookies_path = os.getenv("COOKIES_PATH") -if _cookies_path and os.path.exists(_cookies_path): - YTDL_OPTIONS["cookiefile"] = _cookies_path - log.info("Using cookies from %s", _cookies_path) +if COOKIES_PATH and os.path.exists(COOKIES_PATH): + YTDL_OPTIONS["cookiefile"] = COOKIES_PATH + log.info("Using cookies from %s", COOKIES_PATH) # Search-prefix aliases users can type: "!play sc: lofi" โ†’ SoundCloud search. _SEARCH_PREFIXES = { @@ -90,7 +90,6 @@ def _build_track(info: dict, *, query: str = "") -> dict: return { "title": info.get("title") or "Unknown Title", "url": info.get("webpage_url") or info.get("url"), - "stream": None, "duration": info.get("duration"), "thumbnail": info.get("thumbnail") or _first_thumb(info), "uploader": info.get("uploader") or info.get("channel"), diff --git a/tests/conftest.py b/tests/conftest.py index 0092077..416d681 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ # Make the project importable without installing it. sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from utils.player import MusicPlayer, players # noqa: E402 +from utils.player import MusicPlayer # noqa: E402 def make_track(title: str = "Track", **overrides) -> dict: @@ -23,7 +23,6 @@ def make_track(title: str = "Track", **overrides) -> dict: track = { "title": title, "url": f"https://example.invalid/{title.replace(' ', '_')}", - "stream": None, "duration": 180, "thumbnail": None, "uploader": "Uploader", @@ -70,17 +69,19 @@ def fake_guild(): @pytest.fixture def player(fake_bot, fake_guild): - """A MusicPlayer whose background loop is never started.""" - p = MusicPlayer(fake_bot, fake_guild, MagicMock()) - yield p - # Keep the module-level singleton clean between tests. - players.discard(fake_guild.id) + """ + A MusicPlayer whose background loop is never started. + + No cleanup needed: the player is told who to notify on destroy rather than + registering itself in a module-level singleton, so nothing here leaks into + the next test. + """ + return MusicPlayer(fake_bot, fake_guild, MagicMock()) @pytest.fixture def clock_at_180(player, monkeypatch): """A player 180 seconds into whatever it is playing.""" - import time as _time from utils import player as player_module base = 1000.0 diff --git a/tests/test_checks.py b/tests/test_checks.py index 418577b..432a448 100644 --- a/tests/test_checks.py +++ b/tests/test_checks.py @@ -10,7 +10,7 @@ import pytest -from utils.checks import user_in_voice, bot_in_voice, same_voice_channel +from utils.checks import user_in_voice, same_voice_channel @pytest.fixture @@ -53,18 +53,6 @@ async def test_user_in_voice_refuses_a_stale_voice_state(ctx): assert await user_in_voice().predicate(ctx) is False -# -- bot_in_voice ------------------------------------------------------ - -async def test_bot_in_voice_passes_when_the_bot_is_connected(ctx): - ctx.voice_client = MagicMock() - assert await bot_in_voice().predicate(ctx) is True - - -async def test_bot_in_voice_refuses_and_explains_when_the_bot_is_not(ctx): - assert await bot_in_voice().predicate(ctx) is False - assert "not connected" in sent_text(ctx) - - # -- same_voice_channel ------------------------------------------------ async def test_same_channel_passes(ctx): diff --git a/tests/test_commands.py b/tests/test_commands.py index dd136c4..65b9728 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -15,7 +15,7 @@ import pytest from cogs.music import Music, MAX_QUERY_LEN, _is_playlist_url -from cogs.effects import Effects +from cogs.effects import EFFECTS, Effects from utils.player import players @@ -78,7 +78,7 @@ async def test_skip_with_nothing_playing_reports_it(music_cog, ctx): async def test_skip_with_an_empty_queue_does_not_raise(music_cog, ctx, fake_bot, fake_guild): """An empty queue must not crash the command.""" - player = players.get_or_create(fake_bot, ctx) + player = players.get_or_create(fake_bot, ctx.guild, ctx.channel) try: player.queue.clear() fake_guild.voice_client = None @@ -161,15 +161,34 @@ async def test_move_with_no_player(music_cog, ctx): # -- effects with no active player ------------------------------------- +async def apply(effects_cog, ctx, name: str): + """Invoke the effect command the way Discord would, as the named alias.""" + ctx.invoked_with = name + await Effects.apply_effect.callback(effects_cog, ctx) + + +@pytest.mark.parametrize("name", sorted(EFFECTS)) +async def test_every_effect_name_is_dispatchable(effects_cog, ctx, name): + """ + Every key in EFFECTS must be reachable as a command. + + The commands are registered as aliases of one handler, so a key that never + made it into the alias list would only fail at runtime, in Discord. + """ + assert name in {Effects.apply_effect.name, *Effects.apply_effect.aliases} + await apply(effects_cog, ctx, name) + assert "Nothing is playing" in sent_text(ctx) + + async def test_effect_command_with_no_player(effects_cog, ctx): - await Effects.bassboost.callback(effects_cog, ctx) + await apply(effects_cog, ctx, "bassboost") assert "Nothing is playing" in sent_text(ctx) async def test_effect_changes_are_throttled_per_guild(effects_cog, ctx): - await Effects.bass.callback(effects_cog, ctx) + await apply(effects_cog, ctx, "bass") ctx.send.reset_mock() - await Effects.nightcore.callback(effects_cog, ctx) + await apply(effects_cog, ctx, "nightcore") assert "wait" in sent_text(ctx).lower(), "a second effect inside the cooldown must be refused" diff --git a/tests/test_effect_filters.py b/tests/test_effect_filters.py index d83a891..4929f82 100644 --- a/tests/test_effect_filters.py +++ b/tests/test_effect_filters.py @@ -57,12 +57,12 @@ def speed_factor(audio_filter: str, source_rate: int) -> float: @pytest.mark.parametrize("source_rate", SOURCE_RATES) def test_nightcore_speed_does_not_depend_on_the_source_rate(source_rate): - assert speed_factor(EFFECTS["nightcore"], source_rate) == pytest.approx(1.25, rel=0.02) + assert speed_factor(EFFECTS["nightcore"].filter, source_rate) == pytest.approx(1.25, rel=0.02) @pytest.mark.parametrize("source_rate", SOURCE_RATES) def test_vaporwave_speed_does_not_depend_on_the_source_rate(source_rate): - assert speed_factor(EFFECTS["vaporwave"], source_rate) == pytest.approx(0.8, rel=0.02) + assert speed_factor(EFFECTS["vaporwave"].filter, source_rate) == pytest.approx(0.8, rel=0.02) def test_the_hardcoded_form_really_was_rate_dependent(): @@ -81,18 +81,18 @@ def test_the_hardcoded_form_really_was_rate_dependent(): @pytest.mark.parametrize("name", sorted(EFFECTS)) def test_every_preset_is_accepted_by_ffmpeg(name): """A typo in a filter string would only surface as silence at playback.""" - rendered_seconds(EFFECTS[name], 48000) # check=True raises on rejection + rendered_seconds(EFFECTS[name].filter, 48000) # check=True raises on rejection @pytest.mark.parametrize("name", sorted(set(EFFECTS) - {"nightcore", "vaporwave"})) def test_non_speed_effects_leave_the_duration_alone(name): """Only the two pitch effects are meant to change how long a track runs.""" - assert speed_factor(EFFECTS[name], 44100) == pytest.approx(1.0, rel=0.05) + assert speed_factor(EFFECTS[name].filter, 44100) == pytest.approx(1.0, rel=0.05) @pytest.mark.parametrize("name", sorted(EFFECTS)) def test_every_preset_produces_audio(name): - assert rendered_seconds(EFFECTS[name], 44100) > 0 + assert rendered_seconds(EFFECTS[name].filter, 44100) > 0 # -- resuming in place, through the real audio path --------------------- @@ -141,6 +141,6 @@ def test_seeking_and_an_effect_apply_together(tone_file): """A resumed track keeps the effect that triggered the respawn.""" with open(tone_file, "rb") as handle: source = media.make_pipe_source( - handle, ffmpeg_filter=EFFECTS["bassboost"], seek_seconds=20, + handle, ffmpeg_filter=EFFECTS["bassboost"].filter, seek_seconds=20, ) assert played_seconds(source) == pytest.approx(10.0, abs=0.3) diff --git a/tests/test_help.py b/tests/test_help.py new file mode 100644 index 0000000..ff8d3ec --- /dev/null +++ b/tests/test_help.py @@ -0,0 +1,86 @@ +""" +The generated help must describe every command the bot actually has. + +This is the regression guard for the drift the hardcoded help had accumulated: +`!effect` existed and was missing from the list, and nothing failed. Building +the embed from the live registry makes that impossible, and these tests fail if +anyone reintroduces a hand-maintained list. +""" + +import discord +import pytest +from discord.ext import commands + +from config import COGS +from utils.help import FIELD_LIMIT, build, describe, pack + +PREFIX = "!" + + +@pytest.fixture +async def bot(): + """A bot with the real cogs loaded, but no gateway connection.""" + b = commands.Bot(command_prefix=PREFIX, intents=discord.Intents.none(), + help_command=None) + for cog in COGS: + await b.load_extension(cog) + return b + + +def help_text(embed: discord.Embed) -> str: + return "\n".join(f"{field.name}\n{field.value}" for field in embed.fields) + + +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] + 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)) + + +async def test_every_alias_is_documented(bot): + """Aliases are how every effect except the first is reachable.""" + text = help_text(build(bot, PREFIX)) + missing = [a for c in bot.commands for a in c.aliases if f"{PREFIX}{a}`" not in text] + assert missing == [], f"aliases missing from !help: {missing}" + + +async def test_no_field_exceeds_discord_limit(bot): + """Discord rejects the whole message if one field runs over.""" + for field in build(bot, PREFIX).fields: + assert len(field.value) <= FIELD_LIMIT + assert len(field.name) <= 256 + + +async def test_commands_carry_a_summary(bot): + """A command with no docstring renders as a bare name and helps nobody.""" + text = help_text(build(bot, PREFIX)) + assert "โ€”" in text + + +def test_pack_splits_instead_of_overflowing(): + lines = [f"`!command{i}` โ€” a reasonably wordy description of what it does" + for i in range(40)] + fields = pack("Cog", lines) + assert len(fields) > 1, "40 lines must not fit in one field" + assert all(len(value) <= FIELD_LIMIT for _, value in fields) + assert sum(value.count("\n") + 1 for _, value in fields) == len(lines) + + +def test_pack_keeps_a_single_field_when_it_fits(): + assert len(pack("Cog", ["`!one` โ€” does one thing"])) == 1 + + +def test_describe_renders_arguments_and_aliases(): + @commands.command(name="move", aliases=["mv"]) + async def move(ctx, from_pos: int, to_pos: int): + """Move a track.""" + + line = describe(move, PREFIX) + assert "`!move <from_pos> <to_pos>`" in line + assert "`!mv`" in line + assert "Move a track." in line diff --git a/tests/test_player_advance.py b/tests/test_player_advance.py index 640b188..a2d707f 100644 --- a/tests/test_player_advance.py +++ b/tests/test_player_advance.py @@ -12,8 +12,6 @@ import asyncio from unittest.mock import AsyncMock, patch -import pytest - from tests.conftest import make_track diff --git a/utils/announcer.py b/utils/announcer.py new file mode 100644 index 0000000..54e48d3 --- /dev/null +++ b/utils/announcer.py @@ -0,0 +1,44 @@ +""" +Publishing player events to a Discord text channel. + +The playback loop decides *that* something is worth announcing; this decides how +it reads and what to do when the channel has gone away. Keeping the two apart is +what lets :class:`~utils.player.MusicPlayer` run in a test without Discord, and +what stops a change of wording from reaching into the audio core. +""" + +import logging + +import discord + +from utils.embeds import info_embed, load_error_embed, now_playing_embed + +log = logging.getLogger("loopify.announcer") + + +class ChannelAnnouncer: + """Sends one guild's player notifications to a text channel.""" + + def __init__(self, channel: discord.abc.Messageable) -> None: + self.channel = channel + + async def now_playing(self, track: dict, requester: discord.abc.User, + loop_mode: str) -> None: + await self._send(now_playing_embed(track, requester, loop_mode=loop_mode)) + + async def load_failed(self, track: dict) -> None: + await self._send(load_error_embed(track)) + + async def idle_disconnect(self, after_seconds: float) -> None: + await self._send(info_embed( + "๐Ÿ‘‹ Left the channel", + f"Disconnected after {after_seconds / 60:.0f} minutes of inactivity.", + )) + + async def _send(self, embed: discord.Embed) -> None: + """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) + except (discord.HTTPException, discord.Forbidden) as e: + log.debug("Could not announce to channel: %s", e) diff --git a/utils/checks.py b/utils/checks.py index 5e24010..09030d9 100644 --- a/utils/checks.py +++ b/utils/checks.py @@ -1,22 +1,14 @@ -import discord from discord.ext import commands +from utils.embeds import error_embed + def user_in_voice(): """Check: user must be in a voice channel.""" async def predicate(ctx): if not ctx.author.voice or not ctx.author.voice.channel: - await ctx.send(embed=_error("You must be in a voice channel to use this command.")) - return False - return True - return commands.check(predicate) - - -def bot_in_voice(): - """Check: bot must be connected to voice.""" - async def predicate(ctx): - if not ctx.voice_client: - await ctx.send(embed=_error("I'm not connected to any voice channel.")) + await ctx.send(embed=error_embed( + "You must be in a voice channel to use this command.")) return False return True return commands.check(predicate) @@ -26,14 +18,11 @@ def same_voice_channel(): """Check: user must be in the same voice channel as the bot.""" async def predicate(ctx): if not ctx.author.voice: - await ctx.send(embed=_error("You must be in a voice channel.")) + 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("You must be in the same voice channel as me.")) + await ctx.send(embed=error_embed( + "You must be in the same voice channel as me.")) return False return True return commands.check(predicate) - - -def _error(msg: str) -> discord.Embed: - return discord.Embed(description=f"โŒ {msg}", color=0xFF4444) diff --git a/utils/embeds.py b/utils/embeds.py index 3e4ec0c..2b12816 100644 --- a/utils/embeds.py +++ b/utils/embeds.py @@ -1,84 +1,111 @@ -import discord from datetime import timedelta +from typing import Optional + +import discord +GREEN = 0x1DB954 +BLURPLE = 0x5865F2 +RED = 0xFF4444 +GOLD = 0xFFD700 -def format_duration(seconds: int) -> str: + +def format_duration(seconds: Optional[int]) -> str: if not seconds: return "๐Ÿ”ด LIVE" return str(timedelta(seconds=seconds)) -def now_playing_embed(track: dict, requester: discord.abc.User, loop_mode: str = "off") -> discord.Embed: - url = track.get("url") or track.get("spotify_url") - title = f"[{track['title']}]({url})" if url else track["title"] +def _linked_title(track: dict) -> str: + url = track.get("url") + return f"[{track['title']}]({url})" if url else track["title"] + + +def now_playing_embed(track: dict, requester: discord.abc.User, + loop_mode: str = "off") -> discord.Embed: embed = discord.Embed( title="๐ŸŽต Now Playing", - description=f"**{title}**", - color=0x1DB954 + description=f"**{_linked_title(track)}**", + color=GREEN, ) embed.add_field(name="โฑ Duration", value=format_duration(track.get("duration")), inline=True) embed.add_field(name="๐Ÿ‘ค Requested by", value=requester.mention, inline=True) embed.add_field(name="๐Ÿ” Loop", value=loop_mode.capitalize(), inline=True) - if track.get("uploader"): embed.add_field(name="๐Ÿ“บ Channel", value=track["uploader"], inline=True) - if track.get("thumbnail"): embed.set_thumbnail(url=track["thumbnail"]) - embed.set_footer(text="๐ŸŽง Use !queue to see upcoming tracks") return embed -def queue_embed(queue: list, current: dict, page: int = 1, per_page: int = 10) -> discord.Embed: - embed = discord.Embed(title="๐Ÿ“‹ Music Queue", color=0x5865F2) +def added_embed(track: dict) -> discord.Embed: + embed = discord.Embed( + description=f"โž• Added to queue: **{_linked_title(track)}**", color=BLURPLE) + if track.get("thumbnail"): + embed.set_thumbnail(url=track["thumbnail"]) + return embed + + +def queue_embed(queue: list, current: Optional[dict], page: int = 1, + per_page: int = 10) -> discord.Embed: + embed = discord.Embed(title="๐Ÿ“‹ Music Queue", color=BLURPLE) if current: embed.add_field( name="๐ŸŽต Now Playing", value=f"**{current['title']}** `{format_duration(current.get('duration'))}`", - inline=False + inline=False, ) start = (page - 1) * per_page - end = start + per_page - page_items = queue[start:end] - + page_items = queue[start:start + per_page] if page_items: - lines = [] - for i, track in enumerate(page_items, start=start + 1): - lines.append(f"`{i}.` **{track['title']}** `{format_duration(track.get('duration'))}`") - embed.add_field(name="โญ Up Next", value="\n".join(lines), inline=False) + lines = [ + f"`{i}.` **{track['title']}** `{format_duration(track.get('duration'))}`" + for i, track in enumerate(page_items, start=start + 1) + ] + value = "\n".join(lines) else: - embed.add_field(name="โญ Up Next", value="*Queue is empty*", inline=False) + value = "*Queue is empty*" + embed.add_field(name="โญ Up Next", value=value, inline=False) total_pages = max(1, (len(queue) + per_page - 1) // per_page) embed.set_footer(text=f"Page {page}/{total_pages} โ€ข {len(queue)} tracks in queue") return embed +def load_error_embed(track: dict) -> discord.Embed: + """Explain why a track produced no audio, per ``AudioStream.classify_error``.""" + title = track.get("title", "track") + if track.get("error") == "blocked": + return error_embed( + f"YouTube is rate-limiting this server, so **{title}** can't be " + f"loaded right now. Try SoundCloud instead โ€” e.g. `!play sc: {title}`." + ) + return error_embed(f"Couldn't load **{title}** โ€” skipping.") + + def error_embed(message: str) -> discord.Embed: - return discord.Embed(description=f"โŒ {message}", color=0xFF4444) + return discord.Embed(description=f"โŒ {message}", color=RED) def success_embed(message: str) -> discord.Embed: - return discord.Embed(description=f"โœ… {message}", color=0x1DB954) + return discord.Embed(description=f"โœ… {message}", color=GREEN) def info_embed(title: str, message: str) -> discord.Embed: - return discord.Embed(title=title, description=message, color=0x5865F2) + return discord.Embed(title=title, description=message, color=BLURPLE) def lyrics_embed(title: str, artist: str, lyrics: str) -> list[discord.Embed]: - """Split lyrics into multiple embeds if too long.""" + """Split lyrics across as many embeds as Discord's length limit requires.""" max_len = 4000 chunks = [lyrics[i:i + max_len] for i in range(0, len(lyrics), max_len)] - embeds = [] - for i, chunk in enumerate(chunks): - embed = discord.Embed( + return [ + discord.Embed( title=f"๐ŸŽค {title} โ€” {artist}" if i == 0 else f"๐ŸŽค {title} (cont.)", description=chunk, - color=0xFFD700 + color=GOLD, ) - embeds.append(embed) - return embeds + for i, chunk in enumerate(chunks) + ] diff --git a/utils/help.py b/utils/help.py new file mode 100644 index 0000000..86dbe32 --- /dev/null +++ b/utils/help.py @@ -0,0 +1,63 @@ +""" +The ``!help`` embed, generated from the commands actually registered. + +Built from the live command registry rather than a hand-written list. The +previous hardcoded version had already drifted โ€” `!effect` existed and was +undocumented โ€” and a list of commands maintained beside the commands themselves +will always drift again. +""" + +import discord +from discord.ext import commands + +from utils.embeds import GREEN + +FIELD_LIMIT = 1024 # Discord's hard cap on an embed field's value + + +def describe(command: commands.Command, prefix: str) -> str: + """One help line: every name the command answers to, its args, its summary.""" + line = f"`{prefix}{command.name}" + line += f" {command.signature}`" if command.signature else "`" + line += "".join(f" `{prefix}{alias}`" for alias in command.aliases) + return f"{line} โ€” {command.short_doc}" if command.short_doc else line + + +def pack(title: str, lines: list[str]) -> list[tuple[str, str]]: + """ + Group help lines into embed fields, splitting when one would exceed the cap. + + Discord rejects the whole message if any field runs over, so a cog that + grows past the limit must split rather than silently fail to render. + """ + fields: list[tuple[str, str]] = [] + current: list[str] = [] + + def flush() -> None: + if current: + fields.append((title if not fields else f"{title} (cont.)", + "\n".join(current))) + + for line in lines: + if current and len("\n".join(current + [line])) > FIELD_LIMIT: + flush() + current = [line] + else: + current.append(line) + flush() + return fields + + +def build(bot: commands.Bot, prefix: str) -> discord.Embed: + """The help embed for every visible command the bot has loaded.""" + embed = discord.Embed(title="๐ŸŽต Music Bot โ€” Commands", color=GREEN) + for cog in bot.cogs.values(): + visible = sorted((c for c in cog.get_commands() if not c.hidden), + key=lambda c: c.name) + for name, value in pack(cog.qualified_name, [describe(c, prefix) for c in visible]): + 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." + ) + return embed diff --git a/utils/player.py b/utils/player.py index dd4b309..a426b59 100644 --- a/utils/player.py +++ b/utils/player.py @@ -11,7 +11,7 @@ Track dict shape (see ``services.media._build_track``):: - {title, url, stream, duration, thumbnail, uploader, source, query} + {title, url, duration, thumbnail, uploader, source, query} """ import time @@ -19,12 +19,12 @@ import asyncio import logging from collections import deque -from typing import Optional +from typing import Callable, Optional import discord from services import media -from utils.embeds import now_playing_embed, error_embed, info_embed +from utils.announcer import ChannelAnnouncer log = logging.getLogger("loopify.player") @@ -44,10 +44,15 @@ class MusicPlayer: """Owns the queue, playback loop and voice state for a single guild.""" def __init__(self, bot: discord.Client, guild: discord.Guild, - text_channel: discord.abc.Messageable): + text_channel: discord.abc.Messageable, *, + on_destroy: Optional[Callable[[int], None]] = None): self.bot = bot self.guild = guild - self.text_channel = text_channel + self.announcer = ChannelAnnouncer(text_channel) + # Who to tell when this player is finished. Injected rather than reached + # for, so the player never has to know about the registry holding it โ€” + # and a test can build one without touching process-wide state. + self._on_destroy = on_destroy self.queue: deque[dict] = deque() self.history: list[dict] = [] @@ -123,7 +128,7 @@ def clear(self) -> None: @property def is_empty(self) -> bool: - return len(self.queue) == 0 + return not self.queue def to_list(self) -> list[dict]: return list(self.queue) @@ -132,6 +137,18 @@ def to_list(self) -> list[dict]: def voice(self) -> Optional[discord.VoiceClient]: return self.guild.voice_client + @property + def text_channel(self) -> discord.abc.Messageable: + return self.announcer.channel + + @text_channel.setter + def text_channel(self, channel: discord.abc.Messageable) -> None: + self.announcer.channel = channel + + @property + def is_destroyed(self) -> bool: + return self._destroyed + # โ”€โ”€ Command-facing controls โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ def skip(self) -> bool: @@ -353,16 +370,16 @@ async def _player_loop(self) -> None: spawned_at = time.monotonic() if not silent: - await self._safe_send(now_playing_embed( + await self.announcer.now_playing( track, track.get("requester") or self.guild.me, - loop_mode=self.loop_mode, - )) + self.loop_mode, + ) await self._wait_for_end(track) # Measured from the spawn, not from _start_ts, which is # backdated when resuming partway into a track. played = time.monotonic() - spawned_at - source.cleanup() # stop FFmpeg + source.cleanup() # close() waits on the child and reads its stderr, so keep it # off the event loop. await self.bot.loop.run_in_executor(None, stream.close) @@ -373,7 +390,7 @@ async def _player_loop(self) -> None: if (not self._destroyed and not self._skip and not was_replay and played < LOAD_FAILURE_SECONDS): track["error"] = stream.classify_error() # cached by close() - await self._safe_send(self._load_error_embed(track)) + await self.announcer.load_failed(track) self.current = None except asyncio.CancelledError: raise @@ -414,7 +431,6 @@ async def _advance(self) -> tuple[Optional[dict], bool]: self.current = self.queue.popleft() return self.current, False - # Queue empty โ†’ try autoplay. if self.autoplay and prev: nxt = await media.related(prev, loop=self.bot.loop) if nxt: @@ -430,21 +446,8 @@ async def _advance(self) -> tuple[Optional[dict], bool]: return None, False return await self._advance() - @staticmethod - def _load_error_embed(track: dict) -> discord.Embed: - title = track.get("title", "track") - if track.get("error") == "blocked": - return error_embed( - f"YouTube is rate-limiting this server, so **{title}** can't be " - f"loaded right now. Try SoundCloud instead โ€” e.g. `!play sc: {title}`." - ) - return error_embed(f"Couldn't load **{title}** โ€” skipping.") - async def _idle_disconnect(self) -> None: - await self._safe_send(info_embed( - "๐Ÿ‘‹ Left the channel", - "Disconnected after 5 minutes of inactivity.", - )) + await self.announcer.idle_disconnect(INACTIVITY_TIMEOUT) self.destroy() # โ”€โ”€ Teardown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -473,13 +476,8 @@ def destroy(self) -> None: asyncio.ensure_future(vc.disconnect(force=True)) if self._task and not self._task.done(): self._task.cancel() - players.discard(self.guild.id) - - async def _safe_send(self, embed: discord.Embed) -> None: - try: - await self.text_channel.send(embed=embed) - except (discord.HTTPException, discord.Forbidden) as e: - log.debug("Could not send message to guild %s: %s", self.guild.id, e) + if self._on_destroy is not None: + self._on_destroy(self.guild.id) class PlayerManager: @@ -491,13 +489,15 @@ def __init__(self) -> None: def get(self, guild_id: int) -> Optional[MusicPlayer]: return self._players.get(guild_id) - def get_or_create(self, bot: discord.Client, ctx) -> MusicPlayer: - player = self._players.get(ctx.guild.id) - if player is None or player._destroyed: - player = MusicPlayer(bot, ctx.guild, ctx.channel) - self._players[ctx.guild.id] = player + def get_or_create(self, bot: discord.Client, guild: discord.Guild, + channel: discord.abc.Messageable) -> MusicPlayer: + """The guild's player, creating one if it has none or its last one died.""" + player = self._players.get(guild.id) + if player is None or player.is_destroyed: + player = MusicPlayer(bot, guild, channel, on_destroy=self.discard) + self._players[guild.id] = player else: - player.text_channel = ctx.channel # follow the latest command channel + player.text_channel = channel # follow the latest command channel return player def discard(self, guild_id: int) -> None: