modified: Dockerfile

modified:   app.py
	modified:   requirements.txt
This commit is contained in:
SimolZimol
2025-10-27 22:35:59 +01:00
parent 18bb4de57e
commit d8067c8769
3 changed files with 274 additions and 4 deletions

View File

@@ -3,8 +3,8 @@ FROM python:3.10-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
ffmpeg \
libffi-dev \ libffi-dev \
libnacl-dev \
python3-dev \ python3-dev \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

270
app.py
View File

@@ -8,6 +8,15 @@ import aiomysql
import json import json
from datetime import datetime from datetime import datetime
from typing import Optional, List, Dict from typing import Optional, List, Dict
from concurrent.futures import ThreadPoolExecutor
import functools
import traceback
# Optional: YouTube extraction
try:
import yt_dlp as ytdlp
except Exception:
ytdlp = None
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
@@ -81,6 +90,9 @@ intents.members = True
bot = commands.Bot(command_prefix='!', intents=intents) bot = commands.Bot(command_prefix='!', intents=intents)
# Thread pool for blocking operations like yt-dlp extraction
executor = ThreadPoolExecutor(max_workers=4)
@bot.event @bot.event
async def on_ready(): async def on_ready():
"""Event triggered when the bot is ready""" """Event triggered when the bot is ready"""
@@ -105,6 +117,13 @@ async def on_ready():
except Exception as e: except Exception as e:
print(f'Failed to sync commands: {e}') print(f'Failed to sync commands: {e}')
# Pre-warm voice: ensure PyNaCl is importable
try:
import nacl
print(f"🔊 Voice ready (PyNaCl {getattr(nacl, '__version__', 'unknown')})")
except Exception as e:
print(f"⚠️ Voice (PyNaCl) not available: {e}")
@bot.event @bot.event
async def on_guild_join(guild): async def on_guild_join(guild):
"""Event triggered when the bot joins a server""" """Event triggered when the bot joins a server"""
@@ -297,6 +316,257 @@ def get_team_emoji(ctx: commands.Context, team_name: str) -> str:
return custom return custom
return TEAM_EMOTE_OVERRIDES.get("default", "🎖️") return TEAM_EMOTE_OVERRIDES.get("default", "🎖️")
# =========================
# Music / YouTube playback
# =========================
# Global music state per guild
MUSIC_STATE: Dict[int, Dict] = {}
YTDL_OPTS = {
'format': 'bestaudio/best',
'noplaylist': True,
'quiet': True,
'default_search': 'ytsearch',
'skip_download': True,
}
FFMPEG_BEFORE = "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5"
FFMPEG_OPTS = {
'before_options': FFMPEG_BEFORE,
'options': '-vn'
}
def _get_state(guild: discord.Guild) -> Dict:
st = MUSIC_STATE.get(guild.id)
if not st:
st = MUSIC_STATE[guild.id] = {
'queue': [],
'now': None,
'volume': 0.5, # 50%
}
return st
async def _ensure_connected(ctx: commands.Context) -> Optional[discord.VoiceClient]:
if not ctx.guild:
await ctx.reply("❌ This command can only be used in a server.")
return None
if not ctx.author.voice or not ctx.author.voice.channel:
await ctx.reply("❌ You must be connected to a voice channel.")
return None
channel = ctx.author.voice.channel
if ctx.voice_client and ctx.voice_client.channel == channel:
return ctx.voice_client
if ctx.voice_client and ctx.voice_client.channel != channel:
try:
await ctx.voice_client.move_to(channel)
return ctx.voice_client
except Exception as e:
await ctx.reply(f"❌ Couldn't move to your channel: {e}")
return None
try:
vc = await channel.connect()
return vc
except Exception as e:
await ctx.reply(f"❌ Couldn't join voice: {e}")
return None
async def _ytdlp_extract(loop: asyncio.AbstractEventLoop, query: str) -> Optional[Dict]:
if not ytdlp:
return None
def _extract():
with ytdlp.YoutubeDL(YTDL_OPTS) as ytdl:
return ytdl.extract_info(query, download=False)
try:
info = await loop.run_in_executor(executor, _extract)
if info is None:
return None
if 'entries' in info:
info = info['entries'][0]
return info
except Exception:
traceback.print_exc()
return None
async def _create_audio_source(loop: asyncio.AbstractEventLoop, search: str, volume: float):
# Accept either URL or search text; prepend ytsearch1: if not a URL
if not (search.startswith('http://') or search.startswith('https://')):
search = f"ytsearch1:{search}"
info = await _ytdlp_extract(loop, search)
if not info:
return None
url = info.get('url')
webpage_url = info.get('webpage_url') or info.get('original_url') or url
title = info.get('title') or 'Unknown title'
uploader = info.get('uploader') or ''
thumb = None
if isinstance(info.get('thumbnails'), list) and info['thumbnails']:
thumb = info['thumbnails'][-1].get('url')
# Use PCM + volume transformer for runtime volume control
audio = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(url, **FFMPEG_OPTS), volume=max(0.0, min(2.0, volume)))
return {
'source': audio,
'title': title,
'webpage_url': webpage_url,
'uploader': uploader,
'thumbnail': thumb,
'stream_url': url,
}
def _start_next(guild: discord.Guild, ctx_channel: Optional[discord.abc.Messageable] = None):
state = _get_state(guild)
vc = guild.voice_client
if not vc:
state['now'] = None
state['queue'].clear()
return
if vc.is_playing() or vc.is_paused():
return
if not state['queue']:
state['now'] = None
return
item = state['queue'].pop(0)
state['now'] = item
def _after(error):
if error:
print(f"Audio error: {error}")
# Schedule next on event loop thread-safely
bot.loop.call_soon_threadsafe(_start_next, guild, None)
try:
vc.play(item['source'], after=_after)
except Exception as e:
print(f"Failed to start playback: {e}")
bot.loop.call_soon_threadsafe(_start_next, guild, None)
return
# Optionally announce now playing
if ctx_channel:
try:
embed = discord.Embed(title="🎶 Now Playing", description=f"[{item['title']}]({item['webpage_url']})", color=discord.Color.purple())
if item.get('thumbnail'):
embed.set_thumbnail(url=item['thumbnail'])
ctx_task = ctx_channel.send(embed=embed)
asyncio.create_task(ctx_task)
except Exception:
pass
# ------------- Music Commands -------------
@bot.hybrid_command(name='join', description='Join your voice channel')
async def join(ctx: commands.Context):
vc = await _ensure_connected(ctx)
if vc:
await ctx.reply(f"✅ Joined {vc.channel.mention}")
@bot.hybrid_command(name='leave', description='Leave the voice channel and clear the queue')
async def leave(ctx: commands.Context):
if not ctx.voice_client:
await ctx.reply("❌ I'm not in a voice channel.")
return
state = _get_state(ctx.guild)
state['queue'].clear()
state['now'] = None
try:
await ctx.voice_client.disconnect()
await ctx.reply("👋 Left the voice channel and cleared the queue.")
except Exception as e:
await ctx.reply(f"❌ Couldn't leave: {e}")
@bot.hybrid_command(name='play', description='Play a YouTube URL or search query')
async def play(ctx: commands.Context, *, query: str):
vc = await _ensure_connected(ctx)
if not vc:
return
loop = asyncio.get_running_loop()
state = _get_state(ctx.guild)
await ctx.reply("🔎 Searching…")
item = await _create_audio_source(loop, query, state['volume'])
if not item:
await ctx.reply("❌ Couldn't get audio from that query.")
return
state['queue'].append(item)
if not vc.is_playing() and not vc.is_paused() and not state['now']:
_start_next(ctx.guild, ctx.channel)
else:
await ctx.reply(f" Queued: [{item['title']}]({item['webpage_url']})")
@bot.hybrid_command(name='skip', description='Skip the current track')
async def skip(ctx: commands.Context):
if not ctx.voice_client or not ctx.voice_client.is_playing():
await ctx.reply("❌ Nothing is playing.")
return
ctx.voice_client.stop()
await ctx.reply("⏭️ Skipped.")
@bot.hybrid_command(name='stop', description='Stop playback and clear the queue')
async def stop(ctx: commands.Context):
state = _get_state(ctx.guild)
state['queue'].clear()
state['now'] = None
if ctx.voice_client and (ctx.voice_client.is_playing() or ctx.voice_client.is_paused()):
ctx.voice_client.stop()
await ctx.reply("⏹️ Stopped and cleared the queue.")
@bot.hybrid_command(name='pause', description='Pause playback')
async def pause(ctx: commands.Context):
if not ctx.voice_client or not ctx.voice_client.is_playing():
await ctx.reply("❌ Nothing is playing.")
return
ctx.voice_client.pause()
await ctx.reply("⏸️ Paused.")
@bot.hybrid_command(name='resume', description='Resume playback')
async def resume(ctx: commands.Context):
if not ctx.voice_client or not ctx.voice_client.is_paused():
await ctx.reply("❌ Nothing to resume.")
return
ctx.voice_client.resume()
await ctx.reply("▶️ Resumed.")
@bot.hybrid_command(name='queue', description='Show the queue')
async def queue_cmd(ctx: commands.Context):
state = _get_state(ctx.guild)
now = state.get('now')
q = state.get('queue', [])
if not now and not q:
await ctx.reply("🗒️ Queue is empty.")
return
desc = ""
if now:
desc += f"Now: [{now['title']}]({now['webpage_url']})\n\n"
if q:
for i, it in enumerate(q[:10], 1):
desc += f"{i}. [{it['title']}]({it['webpage_url']})\n"
if len(q) > 10:
desc += f"…and {len(q)-10} more"
embed = discord.Embed(title="📜 Queue", description=desc, color=discord.Color.blurple())
await ctx.reply(embed=embed)
@bot.hybrid_command(name='np', description='Show the current track')
async def now_playing(ctx: commands.Context):
state = _get_state(ctx.guild)
now = state.get('now')
if not now:
await ctx.reply("❌ Nothing is playing.")
return
embed = discord.Embed(title="🎶 Now Playing", description=f"[{now['title']}]({now['webpage_url']})", color=discord.Color.purple())
if now.get('thumbnail'):
embed.set_thumbnail(url=now['thumbnail'])
await ctx.reply(embed=embed)
@bot.hybrid_command(name='volume', description='Set the player volume (0-200%)')
async def volume(ctx: commands.Context, percent: int):
if percent < 0 or percent > 200:
await ctx.reply("❌ Volume must be between 0 and 200.")
return
state = _get_state(ctx.guild)
vol = percent / 100.0
state['volume'] = vol
# Update current source if playing
now = state.get('now')
if now and now['source'] and isinstance(now['source'], discord.PCMVolumeTransformer):
now['source'].volume = vol
await ctx.reply(f"🔊 Volume set to {percent}%.")
def _flag_from_iso2(code: str) -> Optional[str]: def _flag_from_iso2(code: str) -> Optional[str]:
"""Return unicode flag from 2-letter ISO code (e.g., 'DE' -> 🇩🇪).""" """Return unicode flag from 2-letter ISO code (e.g., 'DE' -> 🇩🇪)."""
if not code or len(code) != 2: if not code or len(code) != 2:

View File

@@ -1,7 +1,7 @@
discord.py==2.3.2 discord.py==2.3.2
python-dotenv==1.0.0 python-dotenv==1.0.0
aiohttp==3.9.1 aiohttp==3.9.1
asyncpg==0.29.0
psycopg2-binary==2.9.9
aiomysql==0.2.0 aiomysql==0.2.0
PyMySQL==1.1.0 PyMySQL==1.1.0
yt-dlp>=2024.04.09
PyNaCl==1.5.0