trimmed down music.py

This commit is contained in:
esef committed 2026-07-09 17:40:23 +03:00
1 parent f88fd7989a
commit c2133705fe
36 files changed
+1010 -4

No files matched your search

Binary file not shown.
Binary file not shown.
File renamed without changes.
+103
View File
@@ -0,0 +1,103 @@
import wavelink
import discord
import asyncio
import json
import logging
from pathlib import Path
from discord.ext import commands
from .shared.constants import C_MAIN
from .shared.state import GuildState
from .player.commands import PlayerCommands
from .player.queue import QueueCommands
from .player.events import MusicEvents
from .radio.commands import RadioCommands
from .radio.events import RadioEvents
log = logging.getLogger(__name__)
class Music(PlayerCommands, QueueCommands, MusicEvents, RadioCommands, RadioEvents, commands.Cog):
"""
Main Music cog. Inherits all command/event mixins:
- PlayerCommands (play, pause, resume, skip, stop, seek, loop, autoplay, nowplaying)
- QueueCommands (queue, remove, move, shuffle, clear, history)
- MusicEvents (on_ready, on_voice_state_update, on_wavelink_track_end/start)
- RadioCommands (radio, _play_radio_stream, _notify_radio_drop)
- RadioEvents (on_wavelink_track_exception, on_wavelink_track_stuck)
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
# Per-guild state and infrastructure
self._states: dict[int, GuildState] = {}
self._voice_channels: dict[int, int] = {} # guild_id → voice channel id (for status cleanup)
self._radio_channels: dict[int, int] = {} # guild_id → text channel id (for drop notifications)
self._locks: dict[int, asyncio.Lock] = {}
# Load radio stations
stations_path = Path(__file__).parent / "radio_stations.json"
with open(stations_path, "r", encoding="utf-8") as f:
self._radio_stations: dict[str, list[dict]] = json.load(f)
log.info(
f"Loaded radio stations: "
f"{sum(len(v) for v in self._radio_stations.values())} stations "
f"in {len(self._radio_stations)} categories"
)
# ── shared helpers (used by all mixins via self) ──────────────────────────
def state(self, guild_id: int) -> GuildState:
if guild_id not in self._states:
self._states[guild_id] = GuildState()
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
def _lock(self, guild_id: int) -> asyncio.Lock:
"""Get or create a per-guild asyncio.Lock."""
if guild_id not in self._locks:
self._locks[guild_id] = asyncio.Lock()
return self._locks[guild_id]
def _is_current_track(self, st: GuildState, event_track: wavelink.Playable | None) -> bool:
"""True if event_track is the same track currently stored in guild state."""
if st.current is None or event_track is None:
return False
return event_track.identifier == st.current.identifier
# ── !music ────────────────────────────────────────────────────────────────
@commands.command()
async def music(self, ctx: commands.Context):
"""List all music commands."""
embed = discord.Embed(title="🎵 Music Commands", color=C_MAIN)
embed.add_field(name="Playback", value=(
"`!play <query/url>` · `!p`\n"
"`!pause` · `!resume` · `!skip` · `!stop`\n"
"`!seek <1:30>` · `!looptrack` · `!loopqueue`\n"
"`!autoplay`"
), inline=False)
embed.add_field(name="Radio", value=(
"`!radio` — Browse & play radio stations\n"
"Use `!stop` to stop either music or radio"
), inline=False)
embed.add_field(name="Queue", value=(
"`!queue` · `!q`\n"
"`!remove <pos>` · `!move <from> <to>`\n"
"`!shuffle` · `!clear`"
), inline=False)
embed.add_field(name="Info", value=(
"`!nowplaying` · `!np`\n"
"`!history` · `!h`"
), inline=False)
embed.set_footer(text="🎧 DJ role required for control commands")
await ctx.reply(embed=embed)
async def setup(bot: commands.Bot):
await bot.add_cog(Music(bot))
+1
View File
@@ -0,0 +1 @@
# player package
+263
View File
@@ -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)
+131
View File
@@ -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)
+109
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
# radio package
+175
View File
@@ -0,0 +1,175 @@
import wavelink
import discord
import logging
from discord.ext import commands
from ..shared.constants import (
C_RADIO, C_ERR, C_WARN,
LOOP_OFF, MODE_MUSIC, MODE_RADIO,
)
from .views import _CategoryView
log = logging.getLogger(__name__)
class RadioCommands:
"""Mixin: radio browsing command, stream playback, and drop notifications."""
# ── !radio ────────────────────────────────────────────────────────────────
@commands.command()
async def radio(self, ctx: commands.Context):
"""Browse and play radio stations with a two-step interactive picker."""
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 music is active
if st.mode == MODE_MUSIC and (st.current or st.queue):
return await ctx.reply(embed=discord.Embed(
description="🎵 Music is currently playing. Use `!stop` first to switch to radio.",
color=C_WARN))
# Step 1: show category buttons
view = _CategoryView(self.bot, self._radio_stations)
embed = discord.Embed(
title="📻 Radio — Choose a Category",
description="Select a genre to browse stations:",
color=C_RADIO,
)
await ctx.reply(embed=embed, view=view)
# ── radio playback helper (called from _StationSelect callback) ──────────
async def _play_radio_stream(
self,
interaction: discord.Interaction,
category: str,
station: dict,
):
"""Connect, play a radio stream, and set mode. Called from the station picker UI."""
guild = interaction.guild
if guild is None:
return
member = guild.get_member(interaction.user.id)
if member is None or member.voice is None:
await interaction.followup.send(
embed=discord.Embed(description="❌ You must be in a voice channel.", color=C_ERR),
ephemeral=True,
)
return
st = self.state(guild.id)
async with self._lock(guild.id):
# Re-check mode — music may have started between !radio and now
if st.mode == MODE_MUSIC:
await interaction.followup.send(
embed=discord.Embed(
description="🎵 Music is currently playing. Use `!stop` first to switch to radio.",
color=C_WARN,
),
ephemeral=True,
)
return
# Connect or reuse existing player
vc = guild.voice_client
if not isinstance(vc, wavelink.Player):
try:
vc = await member.voice.channel.connect(cls=wavelink.Player)
except Exception as e:
await interaction.followup.send(
embed=discord.Embed(description=f"❌ Failed to connect: {e}", color=C_ERR),
ephemeral=True,
)
return
vc.autoplay = wavelink.AutoPlayMode.disabled
vc.inactive_timeout = None
self._voice_channels[guild.id] = member.voice.channel.id
# Clear mode before skipping so the old track_end handler
# doesn't interfere with the new station's state
if vc.playing or vc.paused:
st.mode = None
st.current = None
await vc.skip()
# Resolve and play the stream URL via Lavalink
try:
results = await wavelink.Playable.search(station["url"])
if not results:
await interaction.followup.send(
embed=discord.Embed(
description=(
f"❌ Could not resolve stream: `{station['url']}`\n"
"Lavalink may need the **LavaSrc** plugin for direct HTTP/Icecast URLs."
),
color=C_ERR,
),
ephemeral=True,
)
return
track = results[0]
except Exception as e:
await interaction.followup.send(
embed=discord.Embed(description=f"❌ Failed to load stream: {e}", color=C_ERR),
ephemeral=True,
)
return
# Set radio state *before* play so on_wavelink_track_start
# sees MODE_RADIO and doesn't overwrite our status text
st.queue.clear()
st.current = track
st.loop = LOOP_OFF
st.autoplay = False
st.mode = MODE_RADIO
self._radio_channels[guild.id] = interaction.channel_id
try:
await vc.play(track)
except Exception as e:
st.mode = None
self._radio_channels.pop(guild.id, None)
await interaction.followup.send(
embed=discord.Embed(description=f"❌ Failed to play stream: {e}", color=C_ERR),
ephemeral=True,
)
return
# Set channel status with the JSON station name
await self._set_channel_status(vc, title=station["name"])
# Send now-playing embed
embed = discord.Embed(
title="📻 Now Playing",
description=f"### {station['name']}",
color=C_RADIO,
)
if station["desc"]:
embed.add_field(name="Description", value=station["desc"], inline=False)
embed.add_field(name="Category", value=category, inline=True)
embed.add_field(name="Source", value=station["url"], inline=True)
embed.set_footer(text="yuuka radio · use !stop to disconnect")
await interaction.followup.send(embed=embed)
# ── radio drop notification ───────────────────────────────────────────────
async def _notify_radio_drop(self, guild_id: int):
"""Send a notification to the channel where radio was started."""
channel_id = self._radio_channels.pop(guild_id, None)
if channel_id is None:
return
guild = self.bot.get_guild(guild_id)
if guild is None:
return
channel = guild.get_channel_or_thread(channel_id)
if channel is None:
return
try:
await channel.send(embed=discord.Embed(
description="📻 Radio stream ended unexpectedly. Use `!radio` to restart.",
color=C_WARN,
))
except Exception:
log.warning(f"Failed to send radio-drop notification in guild {guild_id}", exc_info=True)
+40
View File
@@ -0,0 +1,40 @@
import wavelink
import logging
from discord.ext import commands
from ..shared.constants import MODE_RADIO
log = logging.getLogger(__name__)
class RadioEvents:
"""Mixin: wavelink failure events that only apply to radio mode."""
# ── track exception → radio stream failure ────────────────────────────────
@commands.Cog.listener()
async def on_wavelink_track_exception(self, payload: wavelink.TrackExceptionEventPayload):
player: wavelink.Player = payload.player
if player is None:
return
st = self.state(player.guild.id)
if st.mode == MODE_RADIO:
if not self._is_current_track(st, payload.track):
return
log.warning(f"Radio stream exception in guild {player.guild.id}: {payload.exception}")
await self._notify_radio_drop(player.guild.id)
st.mode = None
await self._set_channel_status(player)
# ── track stuck → radio stream failure ────────────────────────────────────
@commands.Cog.listener()
async def on_wavelink_track_stuck(self, payload: wavelink.TrackStuckEventPayload):
player: wavelink.Player = payload.player
if player is None:
return
st = self.state(player.guild.id)
if st.mode == MODE_RADIO:
if not self._is_current_track(st, payload.track):
return
log.warning(f"Radio stream stuck in guild {player.guild.id}, threshold {payload.threshold_ms}ms")
await self._notify_radio_drop(player.guild.id)
st.mode = None
await self._set_channel_status(player)
+102
View File
@@ -0,0 +1,102 @@
import discord
from discord.ext import commands
from ..shared.constants import C_RADIO
class _CategoryView(discord.ui.View):
"""Step 1: buttons for each radio category."""
def __init__(self, bot: commands.Bot, stations: dict, timeout: float = 120.0):
super().__init__(timeout=timeout)
self.bot = bot
self.stations = stations
self.message: discord.Message | None = None
for i, category in enumerate(stations.keys()):
label = category[:80] # Discord button label limit
btn = discord.ui.Button(
label=label,
style=discord.ButtonStyle.primary,
row=i // 5, # 5 buttons per row max
)
btn.callback = self._make_category_callback(category)
self.add_item(btn)
def _make_category_callback(self, category: str):
async def callback(interaction: discord.Interaction):
station_list = self.stations[category]
view = _StationSelect(self.bot, category, station_list, interaction, timeout=self.timeout)
embed = discord.Embed(
title=f"📻 {category}",
description="Pick a station from the dropdown below:",
color=C_RADIO,
)
await interaction.response.edit_message(embed=embed, view=view)
view.message = interaction.message
return callback
async def on_timeout(self):
if self.message:
try:
for child in self.children:
child.disabled = True
await self.message.edit(view=self)
except Exception:
pass
class _StationSelect(discord.ui.View):
"""Step 2: dropdown to pick a specific station."""
def __init__(
self,
bot: commands.Bot,
category: str,
stations: list[dict],
interaction: discord.Interaction,
timeout: float = 120.0,
):
super().__init__(timeout=timeout)
self.bot = bot
self.category = category
self.stations = stations
self._interaction = interaction
self.message: discord.Message | None = None
self._picked: dict | None = None
options = []
for s in stations:
label = s["name"][:100] # Discord select option limit
desc = s["desc"][:100] if s["desc"] else "\u200b" # zero-width space if empty
options.append(discord.SelectOption(label=label, description=desc, value=s["name"]))
select = discord.ui.Select(placeholder="Choose a station…", options=options)
select.callback = self._on_select
self.add_item(select)
async def _on_select(self, interaction: discord.Interaction):
station_name = interaction.data["values"][0] # type: ignore
for s in self.stations:
if s["name"] == station_name:
self._picked = s
break
# Disable the dropdown immediately
for child in self.children:
child.disabled = True
await interaction.response.edit_message(view=self)
# Delegate playback to the cog
cog = self.bot.get_cog("Music")
if cog and self._picked:
await cog._play_radio_stream(interaction, self.category, self._picked)
self.stop()
async def on_timeout(self):
if self.message:
try:
for child in self.children:
child.disabled = True
await self.message.edit(view=self)
except Exception:
pass
+1
View File
@@ -0,0 +1 @@
# shared package
+23
View File
@@ -0,0 +1,23 @@
# Colour palette
C_MAIN = 0x8BE9FD # cyan – now playing
C_QUEUE = 0xBD93F9 # purple – queue
C_OK = 0x50FA7B # green – success
C_WARN = 0xFFB86C # orange – warnings / loop
C_ERR = 0xFF5555 # red – errors
C_HIST = 0x6272A4 # muted – history
C_RADIO = 0xFF79C6 # pink – radio
# Loop modes
LOOP_OFF = 0
LOOP_TRACK = 1
LOOP_QUEUE = 2
LOOP_LABEL = {
LOOP_OFF: "Off",
LOOP_TRACK: "🔂 Track",
LOOP_QUEUE: "🔁 Queue",
}
# Playback modes
MODE_MUSIC = "music"
MODE_RADIO = "radio"
+18
View File
@@ -0,0 +1,18 @@
import wavelink
from collections import deque
from .constants import LOOP_OFF
class GuildState:
def __init__(self):
self.queue: deque = deque()
self.history: list = [] # last 10 tracks
self.loop: int = LOOP_OFF
self.autoplay: bool = False
self.current: wavelink.Playable | None = None
self.mode: str | None = None
def push_history(self, track: wavelink.Playable):
self.history.insert(0, track)
if len(self.history) > 10:
self.history.pop()
+33
View File
@@ -0,0 +1,33 @@
import discord
from discord.ext import commands
from .constants import C_ERR
def fmt_duration(ms: int) -> str:
s = ms // 1000
m, s = divmod(s, 60)
h, m = divmod(m, 60)
return f"{h}:{m:02}:{s:02}" if h else f"{m}:{s:02}"
def progress_bar(position_ms: int, duration_ms: int, width: int = 14) -> str:
if duration_ms == 0:
return "▬" * width
pct = min(position_ms / duration_ms, 1.0)
filled = int(pct * width)
return "▬" * filled + "🔘" + "▬" * (width - filled)
def is_dj():
"""Soft DJ check — allows anyone to use info/play, restricts control commands."""
async def predicate(ctx: commands.Context):
if any(r.name.lower() == "dj" for r in ctx.author.roles):
return True
await ctx.reply(
embed=discord.Embed(
description="🎧 You need the **DJ** role to use this command.",
color=C_ERR,
)
)
return False
return commands.check(predicate)