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

+3
View File
@@ -9,3 +9,6 @@ cogs/archived/
.vscode
music/__pycache__/music.cpython-312.pyc
yuuka-radio-streams.md
esef_learns.md
icy_supported.json
icy_metadata_radios.md
View File
Whitespace-only changes.
+5 -2
View File
@@ -12,7 +12,7 @@ from collections import defaultdict, deque
# pyrefly: ignore [missing-import]
from openai import AsyncOpenAI
#only last 12 messages on discord will be fed into the ai's memory
#only last 15 messages on discord will be fed into the ai's memory
MEMORY_SIZE = 15
# These are the ONLY two valid DeepSeek V4 model IDs. deepseek-chat and
@@ -38,6 +38,7 @@ MAX_TOKENS_THINKING = 4096
# Per-1M-token pricing (USD), per DeepSeek's official pricing page.
# Pro costs roughly 3x flash on input and output — tracked separately so
# !aicost is accurate when you've been toggling !turbo.
#i could not find a way to implement dynamic pricing, this is just basic maths based on their pricing
PRICING = {
MODEL_FLASH: {'cache_hit': 0.0028, 'cache_miss': 0.14, 'output': 0.28},
MODEL_PRO: {'cache_hit': 0.003625, 'cache_miss': 0.435, 'output': 0.87},
@@ -72,7 +73,7 @@ def list_personas():
names.append(fname.replace('_prompt.txt', ''))
return sorted(names)
#this splits messages into chunks if they are too long (optimization)
#this splits messages (sent by bot) into chunks if they are too long (optimization)
def split_msg(text, limit=2000):
chunks = []
while len(text) > limit:
@@ -81,6 +82,7 @@ def split_msg(text, limit=2000):
split_at = limit
chunks.append(text[:split_at])
text = text[split_at:].lstrip('\n')
#must be put outside the loop (dont try putting inside :sob:)
if text:
chunks.append(text)
return chunks
@@ -92,6 +94,7 @@ def split_msg(text, limit=2000):
#dropdown for thinking mode — scoped to one channel, not global
class ThinkingSelect(discord.ui.Select):
def __init__(self, cog, channel_id):
self.cog = cog
self.channel_id = channel_id
current = cog.thinking_effort.get(channel_id, 'off') if channel_id in cog.thinking_channels else 'off'
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -45,7 +45,7 @@ twitter_mao = TwitterRSSForwarder(
@bot.event
async def on_ready():
print(f'✅ {bot.user} is online!')
print(f'[+] {bot.user} is online!')
asyncio.create_task(forwarder.start())
asyncio.create_task(twitter.start())
asyncio.create_task(twitter_mao.start())
@@ -58,7 +58,7 @@ async def main():
await bot.load_extension('cogs.gifs')
await bot.load_extension('cogs.copypasta')
await bot.load_extension('cogs.sysinfo')
await bot.load_extension('music.music')
await bot.load_extension('music.music_wrapper')
await bot.load_extension('cogs.statchannel')
await bot.load_extension('cogs.ffmpeg_things')
await bot.load_extension('cogs.archive')
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)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.