From f958b0632886c252a8a7c2e6e37176662f2d87b1 Mon Sep 17 00:00:00 2001 From: Ismael Leon Date: Fri, 11 Sep 2026 21:29:12 -0600 Subject: [PATCH] Remove dead code and untangle the responsibilities behind it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project worked, but it carried leftovers from earlier refactors: a provider that no longer exists, constants nobody read, and a few classes doing three jobs each. None of it broke anything; all of it made every change cost more. Dead code: - `bot_in_voice()` had no caller outside the two tests written for it. - `track["spotify_url"]` was read in two places and written in none, since Spotify support was removed. - `cogs/lyrics.py` split titles on " — " to undo Spotify's "Song — Artist" format. Without Spotify that split truncates legitimate YouTube titles. - `track["stream"]` was set to None on every track and never read. - `config.COOKIES_PATH` was defined but unused: `services/media.py` read the environment variable itself, leaving the setting with two owners. Responsibilities: - `MusicPlayer` no longer builds embeds or sends messages. A new `ChannelAnnouncer` owns that, so wording changes stay out of the audio core. - `MusicPlayer` no longer calls the global `players` registry from `destroy()`. It takes an `on_destroy` callback instead, which breaks the cycle between the class and the registry holding it — the test fixture no longer has to scrub process-wide state after every test. - `PlayerManager` reads `is_destroyed` rather than another object's private attribute, and takes a guild and a channel instead of a whole command Context it only needed two fields from. - Effects come from one registry. Adding one was three edits (filter dict, label dict, a near-identical command method); it is now a single entry, with the command names derived from the registry's keys. - `!help` is generated from the commands actually registered. The hardcoded list had already drifted: `!effect` existed and was documented nowhere. The README row it was also missing is added, and `tests/test_help.py` fails if any command or alias goes undocumented again. Also: `utils/checks` reuses `error_embed` rather than reimplementing it; `lyrics_api` logs instead of printing and reuses one Genius client; and the `@lyrics.error` handler is gone — it leaked raw exception text to users and, because `errors.handle` defers to any command with its own handler, silently disabled central error handling for the whole lyrics cog. Comments that restated their own line are gone. The ones explaining why something is the way it is — the player-client order, stderr as a file rather than a pipe, the load-bearing `aresample` — are kept; each documents a bug that already cost a fix. No behaviour changes beyond two deliberate ones: `!help` now lists `!effect`, and `!lyrics` failures no longer print exception text into the channel. Tests: 293 passing, up from 279. No assertion was relaxed. Closes #31 --- README.md | 1 + cogs/effects.py | 149 ++++++++++++----------------------- cogs/lyrics.py | 54 ++++++------- cogs/music.py | 20 ++--- config.py | 12 +-- main.py | 39 ++------- services/lyrics_api.py | 52 ++++++------ services/media.py | 15 ++-- tests/conftest.py | 17 ++-- tests/test_checks.py | 14 +--- tests/test_commands.py | 29 +++++-- tests/test_effect_filters.py | 12 +-- tests/test_help.py | 86 ++++++++++++++++++++ tests/test_player_advance.py | 2 - utils/announcer.py | 44 +++++++++++ utils/checks.py | 25 ++---- utils/embeds.py | 89 +++++++++++++-------- utils/help.py | 63 +++++++++++++++ utils/player.py | 78 +++++++++--------- 19 files changed, 463 insertions(+), 338 deletions(-) create mode 100644 tests/test_help.py create mode 100644 utils/announcer.py create mode 100644 utils/help.py 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: