aboutsummaryrefslogtreecommitdiff
path: root/main.py
diff options
context:
space:
mode:
Diffstat (limited to 'main.py')
-rw-r--r--main.py188
1 files changed, 188 insertions, 0 deletions
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..62ae849
--- /dev/null
+++ b/main.py
@@ -0,0 +1,188 @@
+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)
+
+
+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.")
+
+
+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()
+
+
+
+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 <paste_your_broken_url>`"
+ )
+ 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.")
+
+
+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!")
+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?")
+
+
+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.")
+
+
+
+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()
+
+
+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)
+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.")
+