296 lines
8.1 KiB
Python
296 lines
8.1 KiB
Python
"""
|
|
voice_manager.py - Voice connection, queue, and playback for Discord music.
|
|
|
|
Uses yt-dlp to download bestaudio to disk, then plays via FFmpegPCMAudio.
|
|
Files persist indefinitely in AUDIO_DIR (acts as a cache).
|
|
"""
|
|
|
|
import os
|
|
import asyncio
|
|
import time
|
|
import yt_dlp
|
|
import discord
|
|
|
|
AUDIO_DIR = "/app/audio_cache"
|
|
os.makedirs(AUDIO_DIR, exist_ok=True)
|
|
|
|
connections = {}
|
|
queues = {}
|
|
playing = {}
|
|
download_locks = {}
|
|
retries = {}
|
|
command_channels = {}
|
|
|
|
|
|
def _get_download_lock(guild_id):
|
|
if guild_id not in download_locks:
|
|
download_locks[guild_id] = asyncio.Lock()
|
|
return download_locks[guild_id]
|
|
|
|
|
|
def _format_duration(seconds):
|
|
if not seconds:
|
|
return "??:??"
|
|
m, s = divmod(int(seconds), 60)
|
|
return f"{m}:{s:02d}"
|
|
|
|
|
|
def _get_cached_path(video_id):
|
|
for ext in [".webm", ".opus", ".m4a", ".mp3", ".ogg", ".flac"]:
|
|
path = os.path.join(AUDIO_DIR, f"{video_id}{ext}")
|
|
if os.path.exists(path):
|
|
return path
|
|
return None
|
|
|
|
|
|
def _download_sync(query):
|
|
ydl_opts = {
|
|
"format": "bestaudio",
|
|
"outtmpl": os.path.join(AUDIO_DIR, "%(id)s.%(ext)s"),
|
|
"no-playlist": True,
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"socket_timeout": 30,
|
|
}
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
info = ydl.extract_info(query, download=False)
|
|
video_id = info.get("id")
|
|
title = info.get("title", "Unknown")
|
|
duration = info.get("duration")
|
|
artist = info.get("artist") or info.get("creator") or info.get("channel", "")
|
|
|
|
cached = _get_cached_path(video_id)
|
|
if cached:
|
|
return cached, title, duration, artist
|
|
|
|
ydl.download([query])
|
|
|
|
filepath = _get_cached_path(video_id)
|
|
if not filepath:
|
|
for f in os.listdir(AUDIO_DIR):
|
|
if f.startswith(video_id):
|
|
filepath = os.path.join(AUDIO_DIR, f)
|
|
break
|
|
|
|
if not filepath:
|
|
raise RuntimeError(f"Downloaded file not found for {video_id}")
|
|
|
|
return filepath, title, duration, artist
|
|
|
|
|
|
async def join_voice(message):
|
|
guild_id = message.guild.id
|
|
|
|
if guild_id in connections:
|
|
return connections[guild_id]
|
|
|
|
voice_state = message.author.voice
|
|
if not voice_state:
|
|
return None
|
|
|
|
channel = voice_state.channel
|
|
try:
|
|
vc = await channel.connect()
|
|
connections[guild_id] = vc
|
|
return vc
|
|
except Exception as e:
|
|
print(f"Error joining voice channel: {e}")
|
|
return None
|
|
|
|
|
|
async def resolve_and_enqueue(message, query):
|
|
guild_id = message.guild.id
|
|
command_channels[guild_id] = message.channel
|
|
|
|
if guild_id not in queues:
|
|
queues[guild_id] = []
|
|
|
|
vc = connections.get(guild_id)
|
|
if not vc:
|
|
vc = await join_voice(message)
|
|
if not vc:
|
|
await message.channel.send("You need to be in a voice channel first.")
|
|
return None
|
|
|
|
lock = _get_download_lock(guild_id)
|
|
|
|
announce_msg = await message.channel.send("Preparing...")
|
|
|
|
def do_download():
|
|
return _download_sync(query)
|
|
|
|
try:
|
|
async with lock:
|
|
position = len(queues[guild_id]) + 1
|
|
filepath, title, duration, artist = await asyncio.to_thread(do_download)
|
|
|
|
if guild_id not in retries:
|
|
retries[guild_id] = {}
|
|
retries[guild_id][filepath] = 0
|
|
|
|
queues[guild_id].append(
|
|
{
|
|
"filepath": filepath,
|
|
"title": title,
|
|
"duration": duration,
|
|
"artist": artist,
|
|
}
|
|
)
|
|
|
|
if guild_id not in playing or not playing[guild_id]:
|
|
await announce_msg.delete()
|
|
await play_next(guild_id)
|
|
else:
|
|
await announce_msg.edit(
|
|
content=f'Added "{title}" to queue at position #{position}.'
|
|
)
|
|
|
|
return queues[guild_id][-1]
|
|
|
|
except Exception as e:
|
|
print(f"Download error for '{query}': {e}")
|
|
await message.channel.send(f'Couldn\'t download "{query}".')
|
|
return None
|
|
|
|
|
|
# UNUSED: kept for reference — play_next uses its own inline _after closure
|
|
# def _after_callback(guild_id):
|
|
# playing.pop(guild_id, None)
|
|
#
|
|
# loop = asyncio.get_event_loop()
|
|
# loop.call_soon_threadsafe(lambda: asyncio.ensure_future(_play_next_async(guild_id)))
|
|
#
|
|
#
|
|
# async def _play_next_async(guild_id):
|
|
# await play_next(guild_id)
|
|
|
|
|
|
async def play_next(guild_id):
|
|
vc = connections.get(guild_id)
|
|
if not vc:
|
|
return
|
|
|
|
if not vc.is_connected():
|
|
connections.pop(guild_id, None)
|
|
return
|
|
|
|
if vc.is_playing():
|
|
return
|
|
|
|
queue = queues.get(guild_id, [])
|
|
if not queue:
|
|
return
|
|
|
|
item = queue.pop(0)
|
|
filepath = item["filepath"]
|
|
title = item["title"]
|
|
duration = item["duration"]
|
|
artist = item["artist"]
|
|
|
|
channel = command_channels.get(guild_id)
|
|
|
|
def _after(error):
|
|
if error:
|
|
print(f"Playback error: {error}")
|
|
playing.pop(guild_id, None)
|
|
loop = asyncio.get_event_loop()
|
|
loop.call_soon_threadsafe(lambda: asyncio.ensure_future(play_next(guild_id)))
|
|
|
|
try:
|
|
source = discord.FFmpegPCMAudio(filepath)
|
|
vc.play(source, after=_after)
|
|
playing[guild_id] = {
|
|
"started_at": time.time(),
|
|
"duration": duration,
|
|
"title": title,
|
|
"artist": artist,
|
|
}
|
|
|
|
display = title
|
|
if artist:
|
|
display = f"{title} ({artist})"
|
|
|
|
if channel:
|
|
await channel.send(f"Now playing: {display}")
|
|
|
|
except Exception as e:
|
|
print(f"Playback error for {filepath}: {e}")
|
|
retry_count = retries.get(guild_id, {}).get(filepath, 0)
|
|
if retry_count < 1:
|
|
if guild_id not in retries:
|
|
retries[guild_id] = {}
|
|
retries[guild_id][filepath] = retry_count + 1
|
|
queue.insert(0, item)
|
|
if channel:
|
|
await channel.send(f"Playback failed, retrying: {title}")
|
|
await play_next(guild_id)
|
|
else:
|
|
if guild_id in retries:
|
|
retries[guild_id].pop(filepath, None)
|
|
if channel:
|
|
await channel.send(f'Couldn\'t play "{title}", skipping.')
|
|
await play_next(guild_id)
|
|
|
|
|
|
def skip(guild_id):
|
|
vc = connections.get(guild_id)
|
|
if vc and vc.is_playing():
|
|
vc.stop()
|
|
|
|
|
|
def stop(guild_id):
|
|
vc = connections.pop(guild_id, None)
|
|
if vc:
|
|
vc.stop()
|
|
asyncio.ensure_future(vc.disconnect())
|
|
queues.pop(guild_id, None)
|
|
playing.pop(guild_id, None)
|
|
retries.pop(guild_id, None)
|
|
|
|
|
|
def get_queue(guild_id):
|
|
queue = queues.get(guild_id, [])
|
|
current = playing.get(guild_id)
|
|
|
|
lines = []
|
|
|
|
if current:
|
|
duration = current.get("duration", 0)
|
|
elapsed = time.time() - current.get("started_at", time.time())
|
|
remaining = max(0, duration - elapsed)
|
|
title = current.get("title", "Unknown")
|
|
artist = current.get("artist", "")
|
|
display = f"{title} ({artist})" if artist else title
|
|
lines.append(
|
|
f"**Now playing:** {display} ({_format_duration(remaining)} remaining)"
|
|
)
|
|
else:
|
|
lines.append("Nothing currently playing.")
|
|
|
|
if queue:
|
|
lines.append("")
|
|
lines.append("**Up next:**")
|
|
for i, item in enumerate(queue, 1):
|
|
title = item.get("title", "Unknown")
|
|
artist = item.get("artist", "")
|
|
duration = _format_duration(item.get("duration"))
|
|
display = f"{title} ({artist})" if artist else title
|
|
lines.append(f"{i}. {display} ({duration})")
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
def get_now_playing(guild_id):
|
|
current = playing.get(guild_id)
|
|
if not current:
|
|
return "Nothing currently playing."
|
|
|
|
duration = current.get("duration", 0)
|
|
elapsed = time.time() - current.get("started_at", time.time())
|
|
remaining = max(0, duration - elapsed)
|
|
title = current.get("title", "Unknown")
|
|
artist = current.get("artist", "")
|
|
display = f"{title} ({artist})" if artist else title
|
|
|
|
return f"Currently playing: **{display}**\n{_format_duration(remaining)} remaining"
|