import discord from discord.ext import commands import asyncio import os import re import subprocess from dotenv import load_dotenv load_dotenv() TOKEN = os.getenv("DISCORD_TOKEN") PIPE_PATH = os.getenv("PIPE_PATH", "/opt/spotify-bot/spotify_pipe") ENV_DIR = "/etc/librespot" intents = discord.Intents.default() intents.message_content = True intents.voice_states = True bot = commands.Bot(command_prefix="!", intents=intents) @bot.event async def on_ready(): print(f"Bot is online as {bot.user}") if not os.path.exists(ENV_DIR): print(f"Warning: {ENV_DIR} does not exist. Please create it manually.") @bot.event async def on_voice_state_update(member, before, after): vc = member.guild.voice_client if vc and vc.channel: human_members = [m for m in vc.channel.members if not m.bot] if len(human_members) == 0: print(f"No humans left in {vc.channel.name}. Disconnecting...") await vc.disconnect() @bot.command() async def setup(ctx): user_id = str(ctx.author.id) await ctx.send("⏳ **Spinning up your private server speaker...**") subprocess.run(["sudo", "systemctl", "restart", f"librespot@{user_id}"]) subprocess.run(["sudo", "systemctl", "enable", f"librespot@{user_id}"]) await asyncio.sleep(2) log_output = subprocess.run( ["journalctl", "-u", f"librespot@{user_id}", "-n", "20", "--no-pager"], capture_output=True, text=True ).stdout matches = re.findall(r"https://accounts\.spotify\.com/[^\s]+", log_output) if matches: oauth_url = matches[-1] instructions = ( f"πŸ”— **Step 1:** Click this link to log in and authorize your account:\n<{oauth_url}>\n\n" f"❌ **Step 2:** After you click 'Agree', your browser will show a broken page saying **'Unable to connect'** or **'Firefox can’t connect to the server'**.\n" f"**THIS IS NORMAL!** \n\n" f"πŸ“‹ **Step 3:** Look at your browser's top address bar, **copy the entire broken URL** (it starts with `http://127.0.0.1:5588...`), and reply to me here like this:\n" f"`!auth `" ) await ctx.send(instructions) else: await ctx.send( "❌ Hrm, I couldn't extract the authorization link. Ask the server owner to check the system logs.") @bot.command() async def auth(ctx, url: str): # Safety check: Make sure they actually gave us the redirect URL if "127.0.0.1:5588" not in url and "localhost:5588" not in url: await ctx.send( "❌ That doesn't look like the correct redirect link. Make sure you copy the URL from the broken page's address bar!") return await ctx.send("πŸ”„ **Forwarding authentication token to your speaker instance...**") local_url = url.replace("localhost", "127.0.0.1") result = subprocess.run(["curl", "-s", local_url], capture_output=True, text=True) await ctx.send("πŸŽ‰ **Success! Your speaker is authenticated.** Check your Spotify app's 'Devices' menuβ€”your server speaker will appear shortly!") @bot.command() async def speaker_on(ctx): discord_id = str(ctx.author.id) try: subprocess.run(["sudo", "systemctl", "start", f"librespot@{discord_id}"], check=True) await ctx.send("πŸ”Š Your personal speaker is now **Online**.") except subprocess.CalledProcessError: await ctx.send("❌ Failed to start. Have you used `!setup` yet?") @bot.command() async def speaker_off(ctx): discord_id = str(ctx.author.id) try: subprocess.run(["sudo", "systemctl", "stop", f"librespot@{discord_id}"], check=True) await ctx.send("πŸ”‡ Your personal speaker is now **Offline**.") except subprocess.CalledProcessError: await ctx.send("❌ Failed to stop the speaker.") @bot.command() async def join(ctx): if ctx.author.voice: await ctx.author.voice.channel.connect() await ctx.send(f"Joined {ctx.author.voice.channel.name}") else: await ctx.send("You need to be in a voice channel first!") if not hasattr(bot, "active_pipes"): bot.active_pipes = {} if not hasattr(bot, "active_loops"): bot.active_loops = set() @bot.command() async def play(ctx): vc = ctx.voice_client if not vc: return await ctx.send("Use !join first!") discord_id = str(ctx.author.id) user_pipe = f"/opt/spotify-bot/pipes/{discord_id}_pipe" # Make sure their pipe actually exists if not os.path.exists(user_pipe): return await ctx.send("❌ I can't find your audio pipe! Have you run `!setup` or `!speaker_on`?") guild_id = ctx.guild.id bot.active_pipes[guild_id] = user_pipe if vc.is_playing(): await ctx.send(f"πŸ”„ Swapping stream to {ctx.author.display_name}'s Spotify...") vc.stop() return if guild_id not in bot.active_loops: bot.active_loops.add(guild_id) await ctx.send(f"πŸ”Š Hooking into {ctx.author.display_name}'s Spotify stream...") ffmpeg_options = { 'before_options': '-f s16le -ar 44100 -ac 2', 'options': '-loglevel warning' } try: while vc.is_connected(): if not vc.is_playing(): # ALWAYS fetch the latest pipe assigned to this server current_pipe = bot.active_pipes.get(guild_id) if current_pipe and os.path.exists(current_pipe): try: audio_source = discord.FFmpegPCMAudio(current_pipe, **ffmpeg_options) transformed_source = discord.PCMVolumeTransformer(audio_source, volume=1.0) vc.play(transformed_source) print(f"Successfully connected voice client to current target pipe: {current_pipe}") except discord.ClientException: pass except Exception as e: print(f"Pipeline sync error: {e}") await asyncio.sleep(1) finally: bot.active_loops.discard(guild_id) bot.active_pipes.pop(guild_id, None) @bot.command() async def stop(ctx): vc = ctx.voice_client if vc: await vc.disconnect() await ctx.send("Disconnected.") if TOKEN: bot.run(TOKEN) else: print("Error: DISCORD_TOKEN not found in environment variables or .env file.")