modified: Dockerfile

modified:   app.py
	modified:   requirements.txt
This commit is contained in:
SimolZimol
2025-10-28 16:18:09 +01:00
parent 67c5c9016a
commit 8e6a2c7ef6
3 changed files with 4 additions and 246 deletions

View File

@@ -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
View File

@@ -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"""

View File

@@ -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