Fix music cog: requester display, autoplay status, add !stop, polish
This commit is contained in:
1 parent
38ea83d862
commit
e38f56bbe0
1 file changed
+97
-34
+97
-34
@@ -3,10 +3,9 @@ import discord
|
|||||||
import random
|
import random
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
from discord.ext import commands
|
|
||||||
from collections import deque
|
|
||||||
|
|
||||||
# ── colours & assets ────────────────────────────────────────────────────────
|
# ── colours & assets ────────────────────────────────────────────────────────
|
||||||
C_MAIN = 0x8BE9FD # cyan – now playing
|
C_MAIN = 0x8BE9FD # cyan – now playing
|
||||||
@@ -29,7 +28,7 @@ LOOP_LABEL = {
|
|||||||
# ── per-guild state ──────────────────────────────────────────────────────────
|
# ── per-guild state ──────────────────────────────────────────────────────────
|
||||||
class GuildState:
|
class GuildState:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.queue: deque = deque()
|
self.queue: list = []
|
||||||
self.history: list = [] # last 10 tracks
|
self.history: list = [] # last 10 tracks
|
||||||
self.loop: int = LOOP_OFF
|
self.loop: int = LOOP_OFF
|
||||||
self.autoplay: bool = False
|
self.autoplay: bool = False
|
||||||
@@ -73,12 +72,20 @@ class Music(commands.Cog):
|
|||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
self._states: dict[int, GuildState] = {}
|
self._states: dict[int, GuildState] = {}
|
||||||
|
self._voice_channels: dict[int, int] = {} # guild_id → channel_id for disconnect cleanup
|
||||||
|
|
||||||
def state(self, guild_id: int) -> GuildState:
|
def state(self, guild_id: int) -> GuildState:
|
||||||
if guild_id not in self._states:
|
if guild_id not in self._states:
|
||||||
self._states[guild_id] = GuildState()
|
self._states[guild_id] = GuildState()
|
||||||
return self._states[guild_id]
|
return self._states[guild_id]
|
||||||
|
|
||||||
|
def _get_player(self, ctx: commands.Context) -> wavelink.Player | None:
|
||||||
|
"""Get the wavelink Player for a context, or None."""
|
||||||
|
vc = ctx.voice_client
|
||||||
|
if isinstance(vc, wavelink.Player):
|
||||||
|
return vc
|
||||||
|
return None
|
||||||
|
|
||||||
# ── channel status ────────────────────────────────────────────────────────
|
# ── channel status ────────────────────────────────────────────────────────
|
||||||
async def _set_channel_status(self, player: wavelink.Player, track: wavelink.Playable | None):
|
async def _set_channel_status(self, player: wavelink.Player, track: wavelink.Playable | None):
|
||||||
"""Set (or clear) the voice channel status to reflect the current track."""
|
"""Set (or clear) the voice channel status to reflect the current track."""
|
||||||
@@ -99,12 +106,41 @@ class Music(commands.Cog):
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.warning(f"Failed to set voice channel status in {player.guild.id}", exc_info=True)
|
log.warning(f"Failed to set voice channel status in {player.guild.id}", exc_info=True)
|
||||||
|
|
||||||
|
async def _clear_channel_status(self, guild_id: int):
|
||||||
|
"""Clear voice channel status after the bot was disconnected."""
|
||||||
|
channel_id = self._voice_channels.pop(guild_id, None)
|
||||||
|
if channel_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
route = discord.http.Route(
|
||||||
|
'PUT',
|
||||||
|
'/channels/{channel_id}/voice-status',
|
||||||
|
channel_id=channel_id,
|
||||||
|
)
|
||||||
|
await self.bot.http.request(route, json={'status': None})
|
||||||
|
except Exception:
|
||||||
|
log.warning(f"Failed to clear voice channel status in guild {guild_id}", exc_info=True)
|
||||||
|
|
||||||
# ── connect lavalink ─────────────────────────────────────────────────────
|
# ── connect lavalink ─────────────────────────────────────────────────────
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
node = wavelink.Node(uri="http://127.0.0.1:2333", password="yuukabot")
|
node = wavelink.Node(uri="http://127.0.0.1:2333", password="yuukabot")
|
||||||
await wavelink.Pool.connect(nodes=[node], client=self.bot)
|
await wavelink.Pool.connect(nodes=[node], client=self.bot)
|
||||||
print("Wavelink connected to Lavalink")
|
log.info("Wavelink connected to Lavalink")
|
||||||
|
|
||||||
|
# ── voice state update → clear status on disconnect ──────────────────────
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_voice_state_update(self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState):
|
||||||
|
if member.id != self.bot.user.id:
|
||||||
|
return
|
||||||
|
# Bot was in a channel and left (kicked, moved, or disconnected)
|
||||||
|
if before.channel is not None and after.channel is None:
|
||||||
|
guild_id = member.guild.id
|
||||||
|
# Also clean up guild state
|
||||||
|
st = self._states.pop(guild_id, None)
|
||||||
|
if st:
|
||||||
|
st.queue.clear()
|
||||||
|
await self._clear_channel_status(guild_id)
|
||||||
|
|
||||||
# ── track end → advance queue ─────────────────────────────────────────────
|
# ── track end → advance queue ─────────────────────────────────────────────
|
||||||
@commands.Cog.listener()
|
@commands.Cog.listener()
|
||||||
@@ -127,7 +163,7 @@ class Music(commands.Cog):
|
|||||||
st.push_history(st.current)
|
st.push_history(st.current)
|
||||||
|
|
||||||
if st.queue:
|
if st.queue:
|
||||||
next_track = st.queue.popleft()
|
next_track = st.queue.pop(0)
|
||||||
st.current = next_track
|
st.current = next_track
|
||||||
await player.play(next_track)
|
await player.play(next_track)
|
||||||
await self._set_channel_status(player, next_track)
|
await self._set_channel_status(player, next_track)
|
||||||
@@ -145,6 +181,7 @@ class Music(commands.Cog):
|
|||||||
if player is None:
|
if player is None:
|
||||||
return
|
return
|
||||||
st = self.state(player.guild.id)
|
st = self.state(player.guild.id)
|
||||||
|
st.current = payload.track
|
||||||
await self._set_channel_status(player, st.current)
|
await self._set_channel_status(player, st.current)
|
||||||
|
|
||||||
# ── !play ─────────────────────────────────────────────────────────────────
|
# ── !play ─────────────────────────────────────────────────────────────────
|
||||||
@@ -155,11 +192,13 @@ class Music(commands.Cog):
|
|||||||
return await ctx.reply(embed=discord.Embed(
|
return await ctx.reply(embed=discord.Embed(
|
||||||
description="❌ Join a voice channel first.", color=C_ERR))
|
description="❌ Join a voice channel first.", color=C_ERR))
|
||||||
|
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
if not vc:
|
if not vc:
|
||||||
vc = await ctx.author.voice.channel.connect(cls=wavelink.Player)
|
vc = await ctx.author.voice.channel.connect(cls=wavelink.Player)
|
||||||
vc.autoplay = wavelink.AutoPlayMode.disabled
|
vc.autoplay = wavelink.AutoPlayMode.disabled
|
||||||
vc.inactive_timeout = None
|
vc.inactive_timeout = None
|
||||||
|
# Track channel for disconnect cleanup
|
||||||
|
self._voice_channels[ctx.guild.id] = ctx.author.voice.channel.id
|
||||||
|
|
||||||
st = self.state(ctx.guild.id)
|
st = self.state(ctx.guild.id)
|
||||||
|
|
||||||
@@ -174,10 +213,12 @@ class Music(commands.Cog):
|
|||||||
if isinstance(results, wavelink.Playlist):
|
if isinstance(results, wavelink.Playlist):
|
||||||
tracks = list(results.tracks)
|
tracks = list(results.tracks)
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
t.extras = {"requester": str(ctx.author)}
|
t.extras = {"requester_id": ctx.author.id}
|
||||||
st.queue.append(t)
|
st.queue.append(t)
|
||||||
|
|
||||||
|
# Start the first track if nothing is playing
|
||||||
if not vc.playing and not vc.paused and st.queue:
|
if not vc.playing and not vc.paused and st.queue:
|
||||||
st.current = st.queue.popleft()
|
st.current = st.queue.pop(0)
|
||||||
await vc.play(st.current)
|
await vc.play(st.current)
|
||||||
await self._set_channel_status(vc, st.current)
|
await self._set_channel_status(vc, st.current)
|
||||||
|
|
||||||
@@ -192,7 +233,7 @@ class Music(commands.Cog):
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
track = results[0]
|
track = results[0]
|
||||||
track.extras = {"requester": str(ctx.author)}
|
track.extras = {"requester_id": ctx.author.id}
|
||||||
|
|
||||||
if vc.playing or vc.paused:
|
if vc.playing or vc.paused:
|
||||||
st.queue.append(track)
|
st.queue.append(track)
|
||||||
@@ -229,36 +270,59 @@ class Music(commands.Cog):
|
|||||||
@commands.command()
|
@commands.command()
|
||||||
@is_dj()
|
@is_dj()
|
||||||
async def pause(self, ctx: commands.Context):
|
async def pause(self, ctx: commands.Context):
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
if vc and vc.playing:
|
if vc and vc.playing:
|
||||||
await vc.pause(True)
|
await vc.pause(True)
|
||||||
await ctx.reply(embed=discord.Embed(description="⏸️ Paused.", color=C_OK))
|
await ctx.reply(embed=discord.Embed(description="⏸️ Paused.", color=C_OK))
|
||||||
|
else:
|
||||||
|
await ctx.reply(embed=discord.Embed(description="❌ Nothing is playing.", color=C_ERR))
|
||||||
|
|
||||||
@commands.command()
|
@commands.command()
|
||||||
@is_dj()
|
@is_dj()
|
||||||
async def resume(self, ctx: commands.Context):
|
async def resume(self, ctx: commands.Context):
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
if vc and vc.paused:
|
if vc and vc.paused:
|
||||||
await vc.pause(False)
|
await vc.pause(False)
|
||||||
await ctx.reply(embed=discord.Embed(description="▶️ Resumed.", color=C_OK))
|
await ctx.reply(embed=discord.Embed(description="▶️ Resumed.", color=C_OK))
|
||||||
|
else:
|
||||||
|
await ctx.reply(embed=discord.Embed(description="❌ Nothing is paused.", color=C_ERR))
|
||||||
|
|
||||||
# ── !skip ─────────────────────────────────────────────────────────────────
|
# ── !skip ─────────────────────────────────────────────────────────────────
|
||||||
@commands.command()
|
@commands.command()
|
||||||
@is_dj()
|
@is_dj()
|
||||||
async def skip(self, ctx: commands.Context):
|
async def skip(self, ctx: commands.Context):
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
if vc and (vc.playing or vc.paused):
|
if vc and (vc.playing or vc.paused):
|
||||||
await vc.skip()
|
await vc.skip()
|
||||||
await ctx.reply(embed=discord.Embed(description="⏭️ Skipped.", color=C_OK))
|
await ctx.reply(embed=discord.Embed(description="⏭️ Skipped.", color=C_OK))
|
||||||
else:
|
else:
|
||||||
await ctx.reply(embed=discord.Embed(description="❌ Nothing to skip.", color=C_ERR))
|
await ctx.reply(embed=discord.Embed(description="❌ Nothing to skip.", color=C_ERR))
|
||||||
|
|
||||||
|
# ── !stop / !leave ────────────────────────────────────────────────────────
|
||||||
|
@commands.command(aliases=["leave", "disconnect"])
|
||||||
|
@is_dj()
|
||||||
|
async def stop(self, ctx: commands.Context):
|
||||||
|
"""Stop playback, clear the queue, and disconnect from voice."""
|
||||||
|
st = self.state(ctx.guild.id)
|
||||||
|
st.queue.clear()
|
||||||
|
st.current = None
|
||||||
|
st.loop = LOOP_OFF
|
||||||
|
st.autoplay = False
|
||||||
|
|
||||||
|
vc = self._get_player(ctx)
|
||||||
|
if vc:
|
||||||
|
await self._set_channel_status(vc, None)
|
||||||
|
await vc.disconnect()
|
||||||
|
|
||||||
|
self._voice_channels.pop(ctx.guild.id, None)
|
||||||
|
await ctx.reply(embed=discord.Embed(description="⏹️ Stopped and disconnected.", color=C_OK))
|
||||||
|
|
||||||
# ── !seek ─────────────────────────────────────────────────────────────────
|
# ── !seek ─────────────────────────────────────────────────────────────────
|
||||||
@commands.command()
|
@commands.command()
|
||||||
@is_dj()
|
@is_dj()
|
||||||
async def seek(self, ctx: commands.Context, timestamp: str):
|
async def seek(self, ctx: commands.Context, timestamp: str):
|
||||||
"""Seek to a timestamp. Format: 1:30 or 90 (seconds)."""
|
"""Seek to a timestamp. Format: 1:30 or 90 (seconds)."""
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
if not vc or not vc.playing:
|
if not vc or not vc.playing:
|
||||||
return await ctx.reply(embed=discord.Embed(
|
return await ctx.reply(embed=discord.Embed(
|
||||||
description="❌ Nothing is playing.", color=C_ERR))
|
description="❌ Nothing is playing.", color=C_ERR))
|
||||||
@@ -303,7 +367,7 @@ class Music(commands.Cog):
|
|||||||
@is_dj()
|
@is_dj()
|
||||||
async def autoplay(self, ctx: commands.Context):
|
async def autoplay(self, ctx: commands.Context):
|
||||||
st = self.state(ctx.guild.id)
|
st = self.state(ctx.guild.id)
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
st.autoplay = not st.autoplay
|
st.autoplay = not st.autoplay
|
||||||
if vc:
|
if vc:
|
||||||
mode = wavelink.AutoPlayMode.enabled if st.autoplay else wavelink.AutoPlayMode.disabled
|
mode = wavelink.AutoPlayMode.enabled if st.autoplay else wavelink.AutoPlayMode.disabled
|
||||||
@@ -315,7 +379,7 @@ class Music(commands.Cog):
|
|||||||
# ── !nowplaying ───────────────────────────────────────────────────────────
|
# ── !nowplaying ───────────────────────────────────────────────────────────
|
||||||
@commands.command(aliases=["np"])
|
@commands.command(aliases=["np"])
|
||||||
async def nowplaying(self, ctx: commands.Context):
|
async def nowplaying(self, ctx: commands.Context):
|
||||||
vc: wavelink.Player = ctx.voice_client # type: ignore
|
vc = self._get_player(ctx)
|
||||||
st = self.state(ctx.guild.id)
|
st = self.state(ctx.guild.id)
|
||||||
|
|
||||||
if not vc or not vc.playing or not st.current:
|
if not vc or not vc.playing or not st.current:
|
||||||
@@ -326,7 +390,12 @@ class Music(commands.Cog):
|
|||||||
pos_ms = vc.position
|
pos_ms = vc.position
|
||||||
dur_ms = track.length
|
dur_ms = track.length
|
||||||
bar = progress_bar(pos_ms, dur_ms)
|
bar = progress_bar(pos_ms, dur_ms)
|
||||||
requester = getattr(track.extras, "requester", "Unknown") if track.extras else "Unknown"
|
|
||||||
|
if track.extras and isinstance(track.extras, dict):
|
||||||
|
requester_id = track.extras.get("requester_id")
|
||||||
|
requester = f"<@{requester_id}>" if requester_id else "Unknown"
|
||||||
|
else:
|
||||||
|
requester = "Unknown"
|
||||||
|
|
||||||
embed = discord.Embed(
|
embed = discord.Embed(
|
||||||
title="🎵 Now Playing",
|
title="🎵 Now Playing",
|
||||||
@@ -366,12 +435,11 @@ class Music(commands.Cog):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if st.queue:
|
if st.queue:
|
||||||
tracks = list(st.queue)
|
|
||||||
lines = []
|
lines = []
|
||||||
for i, t in enumerate(tracks[:15], 1):
|
for i, t in enumerate(st.queue[:15], 1):
|
||||||
lines.append(f"`{i:02}.` **{t.title}** `{fmt_duration(t.length)}`")
|
lines.append(f"`{i:02}.` **{t.title}** `{fmt_duration(t.length)}`")
|
||||||
if len(tracks) > 15:
|
if len(st.queue) > 15:
|
||||||
lines.append(f"*... and {len(tracks) - 15} more*")
|
lines.append(f"*... and {len(st.queue) - 15} more*")
|
||||||
embed.add_field(name="Up Next", value="\n".join(lines), inline=False)
|
embed.add_field(name="Up Next", value="\n".join(lines), inline=False)
|
||||||
|
|
||||||
total_ms = sum(t.length for t in st.queue)
|
total_ms = sum(t.length for t in st.queue)
|
||||||
@@ -385,10 +453,8 @@ class Music(commands.Cog):
|
|||||||
st = self.state(ctx.guild.id)
|
st = self.state(ctx.guild.id)
|
||||||
if position < 1 or position > len(st.queue):
|
if position < 1 or position > len(st.queue):
|
||||||
return await ctx.reply(embed=discord.Embed(
|
return await ctx.reply(embed=discord.Embed(
|
||||||
description=f"❌ Position must be between 1 and {len(st.queue)}.", color=C_ERR))
|
description=f"❌ Position must be between 1 and {len(st.queue)}. Use `!queue` to see positions.", color=C_ERR))
|
||||||
queue_list = list(st.queue)
|
removed = st.queue.pop(position - 1)
|
||||||
removed = queue_list.pop(position - 1)
|
|
||||||
st.queue = deque(queue_list)
|
|
||||||
await ctx.reply(embed=discord.Embed(
|
await ctx.reply(embed=discord.Embed(
|
||||||
description=f"🗑️ Removed **{removed.title}** from queue.", color=C_OK))
|
description=f"🗑️ Removed **{removed.title}** from queue.", color=C_OK))
|
||||||
|
|
||||||
@@ -397,13 +463,12 @@ class Music(commands.Cog):
|
|||||||
@is_dj()
|
@is_dj()
|
||||||
async def move(self, ctx: commands.Context, from_pos: int, to_pos: int):
|
async def move(self, ctx: commands.Context, from_pos: int, to_pos: int):
|
||||||
st = self.state(ctx.guild.id)
|
st = self.state(ctx.guild.id)
|
||||||
q = list(st.queue)
|
size = len(st.queue)
|
||||||
if not (1 <= from_pos <= len(q)) or not (1 <= to_pos <= len(q)):
|
if not (1 <= from_pos <= size) or not (1 <= to_pos <= size):
|
||||||
return await ctx.reply(embed=discord.Embed(
|
return await ctx.reply(embed=discord.Embed(
|
||||||
description="❌ Invalid positions.", color=C_ERR))
|
description=f"❌ Invalid positions. Queue has {size} items. Use `!queue` to see positions.", color=C_ERR))
|
||||||
track = q.pop(from_pos - 1)
|
track = st.queue.pop(from_pos - 1)
|
||||||
q.insert(to_pos - 1, track)
|
st.queue.insert(to_pos - 1, track)
|
||||||
st.queue = deque(q)
|
|
||||||
await ctx.reply(embed=discord.Embed(
|
await ctx.reply(embed=discord.Embed(
|
||||||
description=f"↕️ Moved **{track.title}** to position **{to_pos}**.", color=C_OK))
|
description=f"↕️ Moved **{track.title}** to position **{to_pos}**.", color=C_OK))
|
||||||
|
|
||||||
@@ -415,9 +480,7 @@ class Music(commands.Cog):
|
|||||||
if not st.queue:
|
if not st.queue:
|
||||||
return await ctx.reply(embed=discord.Embed(
|
return await ctx.reply(embed=discord.Embed(
|
||||||
description="❌ Queue is empty.", color=C_ERR))
|
description="❌ Queue is empty.", color=C_ERR))
|
||||||
q = list(st.queue)
|
random.shuffle(st.queue)
|
||||||
random.shuffle(q)
|
|
||||||
st.queue = deque(q)
|
|
||||||
await ctx.reply(embed=discord.Embed(
|
await ctx.reply(embed=discord.Embed(
|
||||||
description="🔀 Queue shuffled.", color=C_OK))
|
description="🔀 Queue shuffled.", color=C_OK))
|
||||||
|
|
||||||
@@ -453,7 +516,7 @@ class Music(commands.Cog):
|
|||||||
embed = discord.Embed(title="🎵 Music Commands", color=C_MAIN)
|
embed = discord.Embed(title="🎵 Music Commands", color=C_MAIN)
|
||||||
embed.add_field(name="Playback", value=(
|
embed.add_field(name="Playback", value=(
|
||||||
"`!play <query/url>` · `!p`\n"
|
"`!play <query/url>` · `!p`\n"
|
||||||
"`!pause` · `!resume` · `!skip`\n"
|
"`!pause` · `!resume` · `!skip` · `!stop`\n"
|
||||||
"`!seek <1:30>` · `!looptrack` · `!loopqueue`\n"
|
"`!seek <1:30>` · `!looptrack` · `!loopqueue`\n"
|
||||||
"`!autoplay`"
|
"`!autoplay`"
|
||||||
), inline=False)
|
), inline=False)
|
||||||
|
|||||||
Reference in new issue
Block a user