diff --git a/app.py b/app.py index f16b19b..f6f6747 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,8 @@ import aiomysql import json from datetime import datetime from typing import Optional, List, Dict +import yt_dlp +from functools import partial # Load environment variables load_dotenv() @@ -1344,6 +1346,228 @@ async def hoi4leaderboard(ctx, game_type: Optional[str] = "standard", limit: Opt except Exception as e: await ctx.send(f"❌ Error getting leaderboard: {str(e)}") +# ===================== +# YouTube Music Player đŸŽĩ +# ===================== + +# FFmpeg options for reconnect/resilience +FFMPEG_BEFORE_OPTS = "-nostdin -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5" +FFMPEG_OPTS = "-vn" + +def _cookies_path() -> Optional[str]: + # Coolify mount default + default_path = "/app/cookie.txt" + p = os.getenv("YTDLP_COOKIES_PATH", default_path) + return p if os.path.exists(p) else None + +def _ytdl_opts() -> dict: + opts = { + "format": "bestaudio/best", + "noplaylist": True, + "default_search": "auto", + "quiet": True, + "nocheckcertificate": True, + "source_address": "0.0.0.0", + "cachedir": False, + } + cpath = _cookies_path() + if cpath: + opts["cookiefile"] = cpath + return opts + +class Track: + def __init__(self, title: str, webpage_url: str, requested_by: discord.Member): + self.title = title + self.webpage_url = webpage_url + self.requested_by = requested_by + +class GuildMusic: + def __init__(self, guild: discord.Guild): + self.guild = guild + self.voice: Optional[discord.VoiceClient] = None + self.queue: asyncio.Queue[Track] = asyncio.Queue() + self.current: Optional[Track] = None + self.player_task: Optional[asyncio.Task] = None + self.volume: float = 0.5 + + async def ensure_voice(self, ctx): + if ctx.author.voice is None or ctx.author.voice.channel is None: + raise commands.CommandError("You must be in a voice channel to use this.") + channel = ctx.author.voice.channel + if self.voice is None or not self.voice.is_connected(): + self.voice = await channel.connect() + else: + if self.voice.channel != channel: + await self.voice.move_to(channel) + + async def enqueue(self, track: Track): + await self.queue.put(track) + if self.player_task is None or self.player_task.done(): + self.player_task = asyncio.create_task(self._player_loop()) + + async def _player_loop(self): + while True: + try: + self.current = await asyncio.wait_for(self.queue.get(), timeout=300.0) + except asyncio.TimeoutError: + # idle timeout: disconnect if alone + if self.voice and self.voice.is_connected(): + try: + await self.voice.disconnect() + except Exception: + pass + self.current = None + break + + # Extract stream url + ytdl = yt_dlp.YoutubeDL(_ytdl_opts()) + info = await asyncio.get_running_loop().run_in_executor(None, lambda: ytdl.extract_info(self.current.webpage_url, download=False)) + if "entries" in info: + info = info["entries"][0] + stream_url = info.get("url") + title = info.get("title") or self.current.title + + # Play via FFmpeg + if not self.voice or not self.voice.is_connected(): + self.current = None + continue + + audio = discord.FFmpegPCMAudio(stream_url, before_options=FFMPEG_BEFORE_OPTS, options=FFMPEG_OPTS) + source = discord.PCMVolumeTransformer(audio, volume=self.volume) + done_evt = asyncio.Event() + + def after_play(err): + try: + if err: + logging.warning(f"Player error: {err}") + finally: + # Signal to loop that track ended + asyncio.run_coroutine_threadsafe(done_evt.set(), asyncio.get_event_loop()) + + self.voice.play(source, after=after_play) + + # Announce now playing in a text channel? Optional: skip spam in prod; leave to /queue + try: + await done_evt.wait() + except Exception: + pass + finally: + self.current = None + + async def stop(self): + # Clear queue and stop current + try: + while not self.queue.empty(): + self.queue.get_nowait() + self.queue.task_done() + except Exception: + pass + if self.voice and self.voice.is_playing(): + self.voice.stop() + + async def leave(self): + await self.stop() + if self.voice and self.voice.is_connected(): + await self.voice.disconnect() + self.voice = None + +MUSIC: Dict[int, GuildMusic] = {} + +def get_music(guild: discord.Guild) -> GuildMusic: + state = MUSIC.get(guild.id) + if not state: + state = GuildMusic(guild) + MUSIC[guild.id] = state + return state + +async def _extract_basic(query: str, requester: discord.Member) -> Track: + ytdl = yt_dlp.YoutubeDL(_ytdl_opts()) + info = await asyncio.get_running_loop().run_in_executor(None, lambda: ytdl.extract_info(query, download=False)) + if "entries" in info: + info = info["entries"][0] + title = info.get("title", query) + webpage_url = info.get("webpage_url", query) + return Track(title=title, webpage_url=webpage_url, requested_by=requester) + +@bot.hybrid_command(name="play", description="Play a YouTube URL or search query in your voice channel") +async def play(ctx: commands.Context, *, query: str): + try: + if not ctx.guild: + await ctx.reply("This command can only be used in a server.") + return + music = get_music(ctx.guild) + await music.ensure_voice(ctx) + track = await _extract_basic(query, ctx.author if isinstance(ctx.author, discord.Member) else None) + await music.enqueue(track) + cookies_note = " with cookies" if _cookies_path() else "" + await ctx.reply(f"â–ļī¸ Queued: **{track.title}**{cookies_note}") + except commands.CommandError as e: + await ctx.reply(f"❌ {e}") + except Exception as e: + await ctx.reply(f"❌ Failed to play: {e}") + +@bot.hybrid_command(name="skip", description="Skip the current track") +async def skip(ctx: commands.Context): + music = get_music(ctx.guild) + if music.voice and music.voice.is_playing(): + music.voice.stop() + await ctx.reply("â­ī¸ Skipped.") + else: + await ctx.reply("Nothing is playing.") + +@bot.hybrid_command(name="pause", description="Pause playback") +async def pause(ctx: commands.Context): + music = get_music(ctx.guild) + if music.voice and music.voice.is_playing(): + music.voice.pause() + await ctx.reply("â¸ī¸ Paused.") + else: + await ctx.reply("Nothing is playing.") + +@bot.hybrid_command(name="resume", description="Resume playback") +async def resume(ctx: commands.Context): + music = get_music(ctx.guild) + if music.voice and music.voice.is_paused(): + music.voice.resume() + await ctx.reply("â–ļī¸ Resumed.") + else: + await ctx.reply("Nothing is paused.") + +@bot.hybrid_command(name="stop", description="Stop playback and clear the queue") +async def stop_cmd(ctx: commands.Context): + music = get_music(ctx.guild) + await music.stop() + await ctx.reply("âšī¸ Stopped and cleared queue.") + +@bot.hybrid_command(name="leave", description="Leave the voice channel") +async def leave(ctx: commands.Context): + music = get_music(ctx.guild) + await music.leave() + await ctx.reply("👋 Disconnected.") + +@bot.hybrid_command(name="queue", description="Show the next few queued tracks") +async def show_queue(ctx: commands.Context): + music = get_music(ctx.guild) + items: List[Track] = [] + try: + # Peek up to 10 items without removing them + n = min(10, music.queue.qsize()) + tmp = [] + for _ in range(n): + item = await music.queue.get() + items.append(item) + tmp.append(item) + for item in tmp: + await music.queue.put(item) + except Exception: + pass + if not items: + await ctx.reply("Queue is empty.") + return + desc = "\n".join(f"â€ĸ {t.title}" for t in items) + embed = discord.Embed(title="đŸŽĩ Queue", description=desc, color=discord.Color.blurple()) + await ctx.reply(embed=embed) + @bot.event async def on_command_error(ctx, error): """Handles command errors""" diff --git a/requirements.txt b/requirements.txt index 90db89a..3bdd41f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ discord.py==2.3.2 python-dotenv==1.0.0 aiohttp==3.9.1 -asyncpg==0.29.0 -psycopg2-binary==2.9.9 aiomysql==0.2.0 -PyMySQL==1.1.0 \ No newline at end of file +PyMySQL==1.1.0 +yt-dlp==2024.08.06 +PyNaCl==1.5.0 \ No newline at end of file