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