diff --git a/.gitignore b/.gitignore index 25cda4f..d7f3246 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CODEBASE_GUIDE.md b/CODEBASE_GUIDE.md new file mode 100644 index 0000000..e69de29 diff --git a/ai/ai_roleplay.py b/ai/ai_roleplay.py index 6ad62bd..939392b 100644 --- a/ai/ai_roleplay.py +++ b/ai/ai_roleplay.py @@ -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' diff --git a/cogs/__pycache__/__init__.cpython-312.pyc b/cogs/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 790c86b..0000000 Binary files a/cogs/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/cogs/__pycache__/__init__.cpython-314.pyc b/cogs/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 1e656f8..0000000 Binary files a/cogs/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/cogs/__pycache__/chance.cpython-312.pyc b/cogs/__pycache__/chance.cpython-312.pyc deleted file mode 100644 index 599773d..0000000 Binary files a/cogs/__pycache__/chance.cpython-312.pyc and /dev/null differ diff --git a/cogs/__pycache__/chance.cpython-314.pyc b/cogs/__pycache__/chance.cpython-314.pyc deleted file mode 100644 index 3501d8b..0000000 Binary files a/cogs/__pycache__/chance.cpython-314.pyc and /dev/null differ diff --git a/cogs/__pycache__/copypasta.cpython-314.pyc b/cogs/__pycache__/copypasta.cpython-314.pyc deleted file mode 100644 index 63c3484..0000000 Binary files a/cogs/__pycache__/copypasta.cpython-314.pyc and /dev/null differ diff --git a/cogs/__pycache__/general.cpython-312.pyc b/cogs/__pycache__/general.cpython-312.pyc deleted file mode 100644 index 44e69f9..0000000 Binary files a/cogs/__pycache__/general.cpython-312.pyc and /dev/null differ diff --git a/cogs/__pycache__/general.cpython-314.pyc b/cogs/__pycache__/general.cpython-314.pyc deleted file mode 100644 index 4b292ec..0000000 Binary files a/cogs/__pycache__/general.cpython-314.pyc and /dev/null differ diff --git a/cogs/__pycache__/gifs.cpython-312.pyc b/cogs/__pycache__/gifs.cpython-312.pyc deleted file mode 100644 index 55d73df..0000000 Binary files a/cogs/__pycache__/gifs.cpython-312.pyc and /dev/null differ diff --git a/cogs/__pycache__/gifs.cpython-314.pyc b/cogs/__pycache__/gifs.cpython-314.pyc deleted file mode 100644 index c672fa5..0000000 Binary files a/cogs/__pycache__/gifs.cpython-314.pyc and /dev/null differ diff --git a/cogs/__pycache__/sysinfo.cpython-314.pyc b/cogs/__pycache__/sysinfo.cpython-314.pyc deleted file mode 100644 index 479c045..0000000 Binary files a/cogs/__pycache__/sysinfo.cpython-314.pyc and /dev/null differ diff --git a/main.py b/main.py index 43e60dd..0e8f967 100644 --- a/main.py +++ b/main.py @@ -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') diff --git a/music/__pycache__/__init__.cpython-314.pyc b/music/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 6b18e73..0000000 Binary files a/music/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/music/__pycache__/music.cpython-314.pyc b/music/__pycache__/music.cpython-314.pyc deleted file mode 100644 index b813394..0000000 Binary files a/music/__pycache__/music.cpython-314.pyc and /dev/null differ diff --git a/music/music.py b/music/backup/music.py similarity index 100% rename from music/music.py rename to music/backup/music.py diff --git a/music/music_wrapper.py b/music/music_wrapper.py new file mode 100644 index 0000000..1e78d2a --- /dev/null +++ b/music/music_wrapper.py @@ -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 ` · `!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 ` · `!move `\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)) diff --git a/music/player/__init__.py b/music/player/__init__.py new file mode 100644 index 0000000..9554d93 --- /dev/null +++ b/music/player/__init__.py @@ -0,0 +1 @@ +# player package diff --git a/music/player/commands.py b/music/player/commands.py new file mode 100644 index 0000000..2f68f9b --- /dev/null +++ b/music/player/commands.py @@ -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) diff --git a/music/player/events.py b/music/player/events.py new file mode 100644 index 0000000..6114d50 --- /dev/null +++ b/music/player/events.py @@ -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) diff --git a/music/player/queue.py b/music/player/queue.py new file mode 100644 index 0000000..cca62d7 --- /dev/null +++ b/music/player/queue.py @@ -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) diff --git a/music/radio/__init__.py b/music/radio/__init__.py new file mode 100644 index 0000000..740f43e --- /dev/null +++ b/music/radio/__init__.py @@ -0,0 +1 @@ +# radio package diff --git a/music/radio/commands.py b/music/radio/commands.py new file mode 100644 index 0000000..e3be220 --- /dev/null +++ b/music/radio/commands.py @@ -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) diff --git a/music/radio/events.py b/music/radio/events.py new file mode 100644 index 0000000..891a2a2 --- /dev/null +++ b/music/radio/events.py @@ -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) diff --git a/music/radio/views.py b/music/radio/views.py new file mode 100644 index 0000000..d2afb54 --- /dev/null +++ b/music/radio/views.py @@ -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 diff --git a/music/shared/__init__.py b/music/shared/__init__.py new file mode 100644 index 0000000..ad5da11 --- /dev/null +++ b/music/shared/__init__.py @@ -0,0 +1 @@ +# shared package diff --git a/music/shared/constants.py b/music/shared/constants.py new file mode 100644 index 0000000..973f68d --- /dev/null +++ b/music/shared/constants.py @@ -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" diff --git a/music/shared/state.py b/music/shared/state.py new file mode 100644 index 0000000..470fd5b --- /dev/null +++ b/music/shared/state.py @@ -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() diff --git a/music/shared/utils.py b/music/shared/utils.py new file mode 100644 index 0000000..087bbbc --- /dev/null +++ b/music/shared/utils.py @@ -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) diff --git a/tgbridge/__pycache__/__init__.cpython-312.pyc b/tgbridge/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index f52be02..0000000 Binary files a/tgbridge/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/tgbridge/__pycache__/forwarder.cpython-312.pyc b/tgbridge/__pycache__/forwarder.cpython-312.pyc deleted file mode 100644 index 7983b00..0000000 Binary files a/tgbridge/__pycache__/forwarder.cpython-312.pyc and /dev/null differ diff --git a/twitterbridge/__pycache__/__init__.cpython-312.pyc b/twitterbridge/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 2c7be73..0000000 Binary files a/twitterbridge/__pycache__/__init__.cpython-312.pyc and /dev/null differ diff --git a/twitterbridge/__pycache__/rss.cpython-312.pyc b/twitterbridge/__pycache__/rss.cpython-312.pyc deleted file mode 100644 index 9bb71a9..0000000 Binary files a/twitterbridge/__pycache__/rss.cpython-312.pyc and /dev/null differ diff --git a/wavelink/__pycache__/__init__.cpython-314.pyc b/wavelink/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 868aef4..0000000 Binary files a/wavelink/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/wavelink/__pycache__/music.cpython-314.pyc b/wavelink/__pycache__/music.cpython-314.pyc deleted file mode 100644 index 5aeb880..0000000 Binary files a/wavelink/__pycache__/music.cpython-314.pyc and /dev/null differ