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 @@
|
||||
# radio package
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user