trimmed down music.py
This commit is contained in:
1 parent
f88fd7989a
commit
c2133705fe
36 files changed
+1010
-4
No files matched your search
@@ -0,0 +1 @@
|
||||
# player package
|
||||
@@ -0,0 +1,263 @@
|
||||
import wavelink
|
||||
import discord
|
||||
import logging
|
||||
from discord.ext import commands
|
||||
from ..shared.constants import (
|
||||
C_MAIN, C_QUEUE, C_OK, C_WARN, C_ERR,
|
||||
LOOP_OFF, LOOP_TRACK, LOOP_QUEUE, LOOP_LABEL,
|
||||
MODE_MUSIC, MODE_RADIO,
|
||||
)
|
||||
from ..shared.utils import fmt_duration, progress_bar, is_dj
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlayerCommands:
|
||||
"""Mixin: core playback commands (play, pause, resume, skip, stop, seek, loop, autoplay, nowplaying)."""
|
||||
|
||||
# ── !play ─────────────────────────────────────────────────────────────────
|
||||
@commands.command(aliases=["p"])
|
||||
async def play(self, ctx: commands.Context, *, query: str):
|
||||
"""Play a song or playlist. Accepts search terms or YouTube URLs."""
|
||||
if not ctx.author.voice:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="❌ Join a voice channel first.", color=C_ERR))
|
||||
|
||||
st = self.state(ctx.guild.id)
|
||||
|
||||
# Mode check: refuse if radio is active
|
||||
if st.mode == MODE_RADIO:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="📻 Radio is currently active. Use `!stop` first to switch back to music.",
|
||||
color=C_WARN))
|
||||
|
||||
async with self._lock(ctx.guild.id):
|
||||
vc = self._get_player(ctx)
|
||||
if not vc:
|
||||
vc = await ctx.author.voice.channel.connect(cls=wavelink.Player)
|
||||
vc.autoplay = wavelink.AutoPlayMode.disabled
|
||||
vc.inactive_timeout = None
|
||||
self._voice_channels[ctx.guild.id] = ctx.author.voice.channel.id
|
||||
|
||||
async with ctx.typing():
|
||||
results = await wavelink.Playable.search(query)
|
||||
|
||||
if not results:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="❌ Nothing found.", color=C_ERR))
|
||||
|
||||
# ── playlist ──────────────────────────────────────────────────────
|
||||
if isinstance(results, wavelink.Playlist):
|
||||
tracks = list(results.tracks)
|
||||
for t in tracks:
|
||||
t.extras = {"requester_id": ctx.author.id}
|
||||
st.queue.append(t)
|
||||
|
||||
if not vc.playing and not vc.paused and st.queue:
|
||||
st.current = st.queue.popleft()
|
||||
await vc.play(st.current)
|
||||
await self._set_channel_status(vc, st.current)
|
||||
|
||||
st.mode = MODE_MUSIC
|
||||
|
||||
embed = discord.Embed(
|
||||
title="📋 Playlist Queued",
|
||||
description=f"**{results.name}**",
|
||||
color=C_QUEUE,
|
||||
)
|
||||
embed.add_field(name="Tracks", value=str(len(tracks)))
|
||||
embed.add_field(name="Added by", value=ctx.author.mention)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# ── single track ──────────────────────────────────────────────────
|
||||
else:
|
||||
track = results[0]
|
||||
track.extras = {"requester_id": ctx.author.id}
|
||||
|
||||
if vc.playing or vc.paused:
|
||||
st.queue.append(track)
|
||||
embed = discord.Embed(
|
||||
title="➕ Added to Queue",
|
||||
description=f"**{track.title}**",
|
||||
color=C_QUEUE,
|
||||
)
|
||||
embed.add_field(name="Duration", value=fmt_duration(track.length))
|
||||
embed.add_field(name="Position", value=f"#{len(st.queue)}")
|
||||
embed.add_field(name="Requested by", value=ctx.author.mention)
|
||||
if track.artwork:
|
||||
embed.set_thumbnail(url=track.artwork)
|
||||
return await ctx.reply(embed=embed)
|
||||
|
||||
st.current = track
|
||||
await vc.play(track)
|
||||
await self._set_channel_status(vc, track)
|
||||
st.mode = MODE_MUSIC
|
||||
|
||||
embed = discord.Embed(
|
||||
title="▶️ Now Playing",
|
||||
description=f"**{track.title}**",
|
||||
color=C_MAIN,
|
||||
)
|
||||
embed.add_field(name="Duration", value=fmt_duration(track.length))
|
||||
embed.add_field(name="Requested by", value=ctx.author.mention)
|
||||
embed.add_field(name="Loop", value=LOOP_LABEL[st.loop], inline=True)
|
||||
embed.add_field(name="Autoplay", value="On" if st.autoplay else "Off", inline=True)
|
||||
if track.artwork:
|
||||
embed.set_thumbnail(url=track.artwork)
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# ── !pause / !resume ──────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def pause(self, ctx: commands.Context):
|
||||
vc = self._get_player(ctx)
|
||||
if vc and vc.playing:
|
||||
await vc.pause(True)
|
||||
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()
|
||||
@is_dj()
|
||||
async def resume(self, ctx: commands.Context):
|
||||
vc = self._get_player(ctx)
|
||||
if vc and vc.paused:
|
||||
await vc.pause(False)
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def skip(self, ctx: commands.Context):
|
||||
vc = self._get_player(ctx)
|
||||
if vc and (vc.playing or vc.paused):
|
||||
await vc.skip()
|
||||
await ctx.reply(embed=discord.Embed(description="⏭️ Skipped.", color=C_OK))
|
||||
else:
|
||||
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 (music or radio), clear the queue, and disconnect from voice."""
|
||||
st = self.state(ctx.guild.id)
|
||||
current_mode = st.mode
|
||||
|
||||
st.queue.clear()
|
||||
st.current = None
|
||||
st.loop = LOOP_OFF
|
||||
st.autoplay = False
|
||||
st.mode = None
|
||||
|
||||
vc = self._get_player(ctx)
|
||||
if vc:
|
||||
await self._set_channel_status(vc)
|
||||
await vc.disconnect()
|
||||
|
||||
self._voice_channels.pop(ctx.guild.id, None)
|
||||
self._radio_channels.pop(ctx.guild.id, None)
|
||||
self._locks.pop(ctx.guild.id, None)
|
||||
|
||||
msg = "⏹️ Stopped radio and disconnected." if current_mode == MODE_RADIO else "⏹️ Stopped and disconnected."
|
||||
await ctx.reply(embed=discord.Embed(description=msg, color=C_OK))
|
||||
|
||||
# ── !seek ─────────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def seek(self, ctx: commands.Context, timestamp: str):
|
||||
"""Seek to a timestamp. Format: 1:30 or 90 (seconds)."""
|
||||
vc = self._get_player(ctx)
|
||||
if not vc or not vc.playing:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="❌ Nothing is playing.", color=C_ERR))
|
||||
try:
|
||||
if ":" in timestamp:
|
||||
parts = timestamp.split(":")
|
||||
if len(parts) == 2:
|
||||
ms = (int(parts[0]) * 60 + int(parts[1])) * 1000
|
||||
else:
|
||||
ms = (int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])) * 1000
|
||||
else:
|
||||
ms = int(timestamp) * 1000
|
||||
except ValueError:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="❌ Invalid format. Use `1:30` or `90`.", color=C_ERR))
|
||||
|
||||
await vc.seek(ms)
|
||||
await ctx.reply(embed=discord.Embed(description=f"⏩ Seeked to `{timestamp}`.", color=C_OK))
|
||||
|
||||
# ── !looptrack / !loopqueue ───────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def looptrack(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
st.loop = LOOP_OFF if st.loop == LOOP_TRACK else LOOP_TRACK
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description=f"🔂 Loop track: **{'On' if st.loop == LOOP_TRACK else 'Off'}**",
|
||||
color=C_WARN))
|
||||
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def loopqueue(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
st.loop = LOOP_OFF if st.loop == LOOP_QUEUE else LOOP_QUEUE
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description=f"🔁 Loop queue: **{'On' if st.loop == LOOP_QUEUE else 'Off'}**",
|
||||
color=C_WARN))
|
||||
|
||||
# ── !autoplay ─────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def autoplay(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
vc = self._get_player(ctx)
|
||||
st.autoplay = not st.autoplay
|
||||
if vc:
|
||||
mode = wavelink.AutoPlayMode.enabled if st.autoplay else wavelink.AutoPlayMode.disabled
|
||||
await vc.set_autoplay(mode)
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description=f"✨ Autoplay: **{'On' if st.autoplay else 'Off'}**",
|
||||
color=C_OK))
|
||||
|
||||
# ── !nowplaying ───────────────────────────────────────────────────────────
|
||||
@commands.command(aliases=["np"])
|
||||
async def nowplaying(self, ctx: commands.Context):
|
||||
vc = self._get_player(ctx)
|
||||
st = self.state(ctx.guild.id)
|
||||
|
||||
if not vc or not vc.playing or not st.current:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="❌ Nothing is playing.", color=C_ERR))
|
||||
|
||||
track = st.current
|
||||
pos_ms = vc.position
|
||||
dur_ms = track.length
|
||||
bar = progress_bar(pos_ms, dur_ms)
|
||||
|
||||
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(
|
||||
title="🎵 Now Playing",
|
||||
description=f"### {track.title}",
|
||||
color=C_MAIN,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Progress",
|
||||
value=f"`{fmt_duration(pos_ms)}` {bar} `{fmt_duration(dur_ms)}`",
|
||||
inline=False,
|
||||
)
|
||||
embed.add_field(name="Requested by", value=requester, inline=True)
|
||||
embed.add_field(name="Loop", value=LOOP_LABEL[st.loop], inline=True)
|
||||
embed.add_field(name="Autoplay", value="On" if st.autoplay else "Off", inline=True)
|
||||
embed.add_field(name="Queue", value=f"{len(st.queue)} track(s) up next", inline=True)
|
||||
if track.artwork:
|
||||
embed.set_thumbnail(url=track.artwork)
|
||||
embed.set_footer(text="yuuka music")
|
||||
await ctx.reply(embed=embed)
|
||||
@@ -0,0 +1,131 @@
|
||||
import wavelink
|
||||
import discord
|
||||
import logging
|
||||
from discord.ext import commands
|
||||
from ..shared.constants import LOOP_TRACK, LOOP_QUEUE, MODE_RADIO
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MusicEvents:
|
||||
"""Mixin: wavelink + Discord events that belong to the music player."""
|
||||
|
||||
# ── channel status helpers ────────────────────────────────────────────────
|
||||
async def _set_channel_status(
|
||||
self,
|
||||
player: wavelink.Player,
|
||||
track: wavelink.Playable | None = None,
|
||||
*,
|
||||
title: str | None = None,
|
||||
):
|
||||
"""Set (or clear) the voice channel status. Pass *title* for radio; otherwise uses track.title."""
|
||||
channel = player.channel
|
||||
if channel is None:
|
||||
return
|
||||
try:
|
||||
if title is not None:
|
||||
status = f"📻 {title}"[:500]
|
||||
elif track is not None:
|
||||
status = f"🎵 {track.title}"[:500]
|
||||
else:
|
||||
status = None
|
||||
route = discord.http.Route(
|
||||
"PUT",
|
||||
"/channels/{channel_id}/voice-status",
|
||||
channel_id=channel.id,
|
||||
)
|
||||
await self.bot.http.request(route, json={"status": status})
|
||||
except Exception:
|
||||
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 ──────────────────────────────────────────────────────
|
||||
@commands.Cog.listener()
|
||||
async def on_ready(self):
|
||||
node = wavelink.Node(uri="http://127.0.0.1:2333", password="yuukabot")
|
||||
await wavelink.Pool.connect(nodes=[node], client=self.bot)
|
||||
log.info("Wavelink connected to Lavalink")
|
||||
|
||||
# ── voice state update → clean up 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 left a channel (kicked, moved out, or disconnected)
|
||||
if before.channel is not None and after.channel is None:
|
||||
guild_id = member.guild.id
|
||||
st = self._states.pop(guild_id, None)
|
||||
if st:
|
||||
st.queue.clear()
|
||||
self._locks.pop(guild_id, None)
|
||||
self._radio_channels.pop(guild_id, None)
|
||||
await self._clear_channel_status(guild_id)
|
||||
|
||||
# ── track end → advance queue ─────────────────────────────────────────────
|
||||
@commands.Cog.listener()
|
||||
async def on_wavelink_track_end(self, payload: wavelink.TrackEndEventPayload):
|
||||
player: wavelink.Player = payload.player
|
||||
if player is None:
|
||||
return
|
||||
|
||||
st = self.state(player.guild.id)
|
||||
|
||||
# Ignore stale events (e.g. track_end for an old radio stream after a switch)
|
||||
if not self._is_current_track(st, payload.track):
|
||||
return
|
||||
|
||||
if st.loop == LOOP_TRACK and st.current:
|
||||
await player.play(st.current)
|
||||
await self._set_channel_status(player, st.current)
|
||||
return
|
||||
|
||||
if st.loop == LOOP_QUEUE and st.current:
|
||||
st.queue.append(st.current)
|
||||
|
||||
if st.current:
|
||||
st.push_history(st.current)
|
||||
|
||||
if st.queue:
|
||||
next_track = st.queue.popleft()
|
||||
st.current = next_track
|
||||
await player.play(next_track)
|
||||
await self._set_channel_status(player, next_track)
|
||||
elif st.autoplay:
|
||||
await player.set_autoplay(wavelink.AutoPlayMode.enabled)
|
||||
else:
|
||||
if st.mode == MODE_RADIO:
|
||||
await self._notify_radio_drop(player.guild.id)
|
||||
st.current = None
|
||||
st.mode = None
|
||||
await self._set_channel_status(player)
|
||||
|
||||
# ── track start → update voice status ─────────────────────────────────────
|
||||
@commands.Cog.listener()
|
||||
async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload):
|
||||
player: wavelink.Player = payload.player
|
||||
if player is None:
|
||||
return
|
||||
st = self.state(player.guild.id)
|
||||
st.current = payload.track
|
||||
# Radio sets its own title; only update status for music mode
|
||||
if st.mode != MODE_RADIO:
|
||||
await self._set_channel_status(player, st.current)
|
||||
@@ -0,0 +1,109 @@
|
||||
import discord
|
||||
import random
|
||||
from collections import deque
|
||||
from discord.ext import commands
|
||||
from ..shared.constants import C_QUEUE, C_OK, C_ERR, C_HIST, LOOP_LABEL
|
||||
from ..shared.utils import fmt_duration, is_dj
|
||||
|
||||
|
||||
class QueueCommands:
|
||||
"""Mixin: queue management commands (queue, remove, move, shuffle, clear, history)."""
|
||||
|
||||
# ── !queue ────────────────────────────────────────────────────────────────
|
||||
@commands.command(aliases=["q"])
|
||||
async def queue(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
|
||||
if not st.current and not st.queue:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="📋 Queue is empty.", color=C_QUEUE))
|
||||
|
||||
embed = discord.Embed(title="📋 Queue", color=C_QUEUE)
|
||||
|
||||
if st.current:
|
||||
embed.add_field(
|
||||
name="▶️ Now Playing",
|
||||
value=f"**{st.current.title}** `{fmt_duration(st.current.length)}`",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if st.queue:
|
||||
lines = []
|
||||
for i, t in enumerate(list(st.queue)[:15], 1):
|
||||
lines.append(f"`{i:02}.` **{t.title}** `{fmt_duration(t.length)}`")
|
||||
if len(st.queue) > 15:
|
||||
lines.append(f"*... and {len(st.queue) - 15} more*")
|
||||
embed.add_field(name="Up Next", value="\n".join(lines), inline=False)
|
||||
|
||||
total_ms = sum(t.length for t in st.queue)
|
||||
embed.set_footer(text=f"{len(st.queue)} tracks · {fmt_duration(total_ms)} remaining · Loop: {LOOP_LABEL[st.loop]}")
|
||||
await ctx.reply(embed=embed)
|
||||
|
||||
# ── !remove ───────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def remove(self, ctx: commands.Context, position: int):
|
||||
st = self.state(ctx.guild.id)
|
||||
if position < 1 or position > len(st.queue):
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description=f"❌ Position must be between 1 and {len(st.queue)}. Use `!queue` to see positions.",
|
||||
color=C_ERR))
|
||||
q = list(st.queue)
|
||||
removed = q.pop(position - 1)
|
||||
st.queue = deque(q)
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description=f"🗑️ Removed **{removed.title}** from queue.", color=C_OK))
|
||||
|
||||
# ── !move ─────────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def move(self, ctx: commands.Context, from_pos: int, to_pos: int):
|
||||
st = self.state(ctx.guild.id)
|
||||
size = len(st.queue)
|
||||
if not (1 <= from_pos <= size) or not (1 <= to_pos <= size):
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description=f"❌ Invalid positions. Queue has {size} items. Use `!queue` to see positions.",
|
||||
color=C_ERR))
|
||||
q = list(st.queue)
|
||||
track = q.pop(from_pos - 1)
|
||||
q.insert(to_pos - 1, track)
|
||||
st.queue = deque(q)
|
||||
await ctx.reply(embed=discord.Embed(
|
||||
description=f"↕️ Moved **{track.title}** to position **{to_pos}**.", color=C_OK))
|
||||
|
||||
# ── !shuffle ──────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def shuffle(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
if not st.queue:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="❌ Queue is empty.", color=C_ERR))
|
||||
q = list(st.queue)
|
||||
random.shuffle(q)
|
||||
st.queue = deque(q)
|
||||
await ctx.reply(embed=discord.Embed(description="🔀 Queue shuffled.", color=C_OK))
|
||||
|
||||
# ── !clear ────────────────────────────────────────────────────────────────
|
||||
@commands.command()
|
||||
@is_dj()
|
||||
async def clear(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
st.queue.clear()
|
||||
await ctx.reply(embed=discord.Embed(description="🗑️ Queue cleared.", color=C_OK))
|
||||
|
||||
# ── !history ──────────────────────────────────────────────────────────────
|
||||
@commands.command(aliases=["h"])
|
||||
async def history(self, ctx: commands.Context):
|
||||
st = self.state(ctx.guild.id)
|
||||
if not st.history:
|
||||
return await ctx.reply(embed=discord.Embed(
|
||||
description="📜 No history yet.", color=C_HIST))
|
||||
lines = [f"`{i:02}.` **{t.title}** `{fmt_duration(t.length)}`"
|
||||
for i, t in enumerate(st.history, 1)]
|
||||
embed = discord.Embed(
|
||||
title="📜 Recently Played",
|
||||
description="\n".join(lines),
|
||||
color=C_HIST,
|
||||
)
|
||||
await ctx.reply(embed=embed)
|
||||
Reference in new issue
Block a user