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 \
|
RUN apt-get update && apt-get install -y \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
libsodium23 \
|
libnacl-dev \
|
||||||
libopus0 \
|
|
||||||
ffmpeg \
|
|
||||||
python3-dev \
|
python3-dev \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|||||||
240
app.py
240
app.py
@@ -8,8 +8,6 @@ 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
|
||||||
import yt_dlp
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -90,21 +88,6 @@ async def on_ready():
|
|||||||
print(f'Bot ID: {bot.user.id}')
|
print(f'Bot ID: {bot.user.id}')
|
||||||
print(f'Discord.py Version: {discord.__version__}')
|
print(f'Discord.py Version: {discord.__version__}')
|
||||||
print('------')
|
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
|
# Initialize database
|
||||||
await init_database()
|
await init_database()
|
||||||
@@ -1361,229 +1344,6 @@ async def hoi4leaderboard(ctx, game_type: Optional[str] = "standard", limit: Opt
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
await ctx.send(f"❌ Error getting leaderboard: {str(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
|
@bot.event
|
||||||
async def on_command_error(ctx, error):
|
async def on_command_error(ctx, error):
|
||||||
"""Handles command errors"""
|
"""Handles command errors"""
|
||||||
|
|||||||
@@ -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.08.06
|
|
||||||
PyNaCl==1.5.0
|
|
||||||
Reference in New Issue
Block a user