modified: Dockerfile
modified: app.py modified: requirements.txt
This commit is contained in:
@@ -4,9 +4,7 @@ WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libffi-dev \
|
||||
libsodium23 \
|
||||
libopus0 \
|
||||
ffmpeg \
|
||||
libnacl-dev \
|
||||
python3-dev \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
240
app.py
240
app.py
@@ -8,8 +8,6 @@ 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()
|
||||
@@ -90,21 +88,6 @@ async def on_ready():
|
||||
print(f'Bot ID: {bot.user.id}')
|
||||
print(f'Discord.py Version: {discord.__version__}')
|
||||
print('------')
|
||||
# Voice libs diagnostics
|
||||
try:
|
||||
if not discord.opus.is_loaded():
|
||||
# Common soname on Debian-based distros
|
||||
discord.opus.load_opus('libopus.so.0')
|
||||
print(f"🎧 Opus loaded: {discord.opus.is_loaded()}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not load Opus: {e}")
|
||||
try:
|
||||
import nacl
|
||||
print(f"🔐 PyNaCl available: {getattr(nacl, '__version__', 'unknown')}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ PyNaCl import failed: {e}")
|
||||
cpath = _cookies_path()
|
||||
print(f"🍪 yt-dlp cookies: {'found at ' + cpath if cpath else 'not found'}")
|
||||
|
||||
# Initialize database
|
||||
await init_database()
|
||||
@@ -1361,229 +1344,6 @@ 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(timeout=15.0, reconnect=True)
|
||||
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()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def after_play(err):
|
||||
try:
|
||||
if err:
|
||||
logging.warning(f"Player error: {err}")
|
||||
finally:
|
||||
# Signal to loop that track ended from voice thread
|
||||
loop.call_soon_threadsafe(done_evt.set)
|
||||
|
||||
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"""
|
||||
|
||||
@@ -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
|
||||
yt-dlp==2024.08.06
|
||||
PyNaCl==1.5.0
|
||||
PyMySQL==1.1.0
|
||||
Reference in New Issue
Block a user