# Imports import os from discord.webhook.async_ import interaction_message_response_params import requests import discord from discord.ext import commands from discord import DiscordException, NotFound, app_commands, guild, user, voice_client from dotenv import load_dotenv # ----- Setups ----- # # Environment vars load_dotenv() TOKEN = os.getenv("DISCORD_TOKEN") JELLYFIN_URL = os.getenv("JELLYFIN_URL") JELLYFIN_API_KEY = os.getenv("JELLYFIN_API_KEY") # Constants QUERY_LIMIT = 10 # Request headers headers= { "X-Emby-Token": JELLYFIN_API_KEY } # discord bot setups intents = discord.Intents.default() intents.message_content = True intents.voice_states = True # ----- Classes ----- # # TODO: turn this in to an import in a separate file class JellyfinBot(commands.Bot): def __init__(self): super().__init__(command_prefix="/", intents=intents) async def setup_hook(self): await self.tree.sync() bot = JellyfinBot() # Events @bot.event async def on_ready(): print(f"🪼 Logged in as {bot.user}") # ----- Helpful Structures ----- # guild_queue_dict = dict() # ----- Helper Functions ----- # # makeRequest: # makes a request to the jellyfin server. # Returns an error string or an empty dict # begin makeRequest ----- async def make_request(title: str, interaction: discord.Interaction) -> dict: # Default return value data = dict() url = f"{JELLYFIN_URL}/Items" params = { "searchTerm": title, "Recursive": True, "IncludeItemTypes": "Audio", "Limit": QUERY_LIMIT } try: # Makes the request response = requests.get(url, headers=headers, params=params) response.raise_for_status() # Presets the default value for data if this doesn't work data = response.json().get("Items", []) return data except requests.RequestException as e: await interaction.followup.send("⚠️ Failed to contact Jellyfin server.") print(f"Error: {e}") return data # end makeRequest ----- # playTrack: # guild wide play function async def playTrack(interaction: discord.Interaction): voice_client = interaction.guild.voice_client # First, we make a dictionary that has a list inside. # Then, we get the name of the guild and put that in the dictionary # then AFTER, we put the song inside the list that the guild points to # on the play command, we add that song to the list headers_str = f"-headers \"X-Emby-Token: {JELLYFIN_API_KEY}\"" try: while len(guild_queue_dict[interaction.guild_id]) > 0: # Get song information song = guild_queue_dict[interaction.guild_id][0] song_id = song[0] song_title = song[1] song_artist = song[2] # Get voice client voice_client = interaction.guild.voice_client # Then fetch this song headers_str = f"-headers \"X-Emby-Token: {JELLYFIN_API_KEY}\"" audio_stream_url = f"{JELLYFIN_URL}/Audio/{song_id}/stream?static=True" source = discord.FFmpegOpusAudio(audio_stream_url, before_options=f'{headers_str} -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', options="-vn") # Then play it on the voice client voice_client.play(source, after=lambda e: print(f"Finished playing: {e}")) await interaction.followup.send(f"Now playing: **{song_title}** by *{song_artist}*") # Cheap wait # TODO: Make a better way of waiting while voice_client.is_playing() or voice_client.is_paused(): continue # Dequeue the array guild_queue_dict[interaction.guild_id].pop(0) except discord.ClientException as e: await interaction.followup.send("Unable to decode your song!") print(f"Potential FFMPEG/OPUS decoding error: {e}") return # clearQueue # clears the queue. useful for a variety of functions where it needs to # interrupt the player def clearQueue(interaction: discord.Interaction): guild_queue_dict[interaction.guild_id].clear() return # ----- Commands ----- # # Connect # Connects to a voice channel @bot.tree.command(name="connect", description="Connects to the current voice channel") async def connect(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() try: voice_status = await interaction.user.fetch_voice() user_channel = voice_status.channel voice_client = interaction.guild.voice_client if voice_client is None: voice_client = await user_channel.connect() elif voice_client.channel != user_channel: await voice_client.disconnect() voice_client = await user_channel.connect() except discord.errors.NotFound as e: print(f"Error: {e}") await interaction.followup.send("You are not in a voice channel!") return except discord.errors.Forbidden as e: print(f"Error: {e}") await interaction.followup.send(f"I am not allowed in that voice channel!") return await interaction.followup.send(f"Connected!") return # Disconnect # Disconnects from the voice channel @bot.tree.command(name="disconnect", description="Disconnects from current voice channel") async def disconnect(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() # Checks if not in a voice channel if interaction.guild.voice_client == None: await interaction.followup.send("I am not in any voice channel!") return # disconnects with a helpful message await interaction.guild.voice_client.disconnect() await interaction.followup.send("Disconnected!") return # Insert # Inserts a song in the player @bot.tree.command(name="insert", description="Insert a song into the queue") async def insert(interaction: discord.Interaction, title: str): # Makes the reaction visible to everyone await interaction.response.defer() await interaction.followup.send(f"Inserted a song!") return # Play # Play the song at the top of the queue @bot.tree.command(name="play", description="Plays a song at the top of the queue") async def play(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() if guild_queue_dict.get(interaction.guild_id) == None: guild_queue_dict[interaction.guild_id] = [] # Some basic checks try: # Check if the bot is connected to a channel if interaction.guild.voice_client.channel != interaction.user.voice.channel: await interaction.followup.send(f"Not connected to your voice channel!") return # After this point, we know that it is in a channel # Check if the queue is empty if len(guild_queue_dict[interaction.guild_id]) == 0: await interaction.followup.send(f"Queue is empty!") return # After this point, we know the queue has something in it except discord.errors.NotFound: await interaction.followup.send(f"You're not connected to a voice channel!") return # Play all items in the queue await playTrack(interaction) return # Stop # Stops the currently playing song, and empties the queue # TODO: Make clear modular @bot.tree.command(name="stop", description="Stops the player and empties the queue") async def stop(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() # Some basic checks try: # Check if the bot is connected to a channel if interaction.guild.voice_client.channel != interaction.user.voice.channel: await interaction.followup.send(f"Not connected to your voice channel!") return # After this point, we know that it is in a channel # Check if the queue is empty if guild_queue_dict[interaction.guild_id].count() == 0: await interaction.followup.send(f"Queue is empty!") return # After this point, we know the queue has something in it except: await interaction.followup.send(f"You're not connected to a voice channel!") #First, empty the queue, so that if some other user is playing, it ends its play clearQueue(interaction) # Then, stop the player interaction.guild.voice_client.stop() await interaction.followup.send(f"Stopped the player!") return # Search command # searches for a song # start search ----- @bot.tree.command(name="search", description="Search for a song on Jellyfin") @app_commands.describe(title="Song title to search for") async def search(interaction: discord.Interaction, title: str): # Makes the reaction visible to everyone await interaction.response.defer() # Get the data data = await make_request(title, interaction) if not data: await interaction.followup.send(f"No song found matching `{title}`.") return result_list = [] for song in data: result_list.append(f"**{song.get("Name")}** by *{song.get("AlbumArtist", ["Unknown Artist"])}*") await interaction.followup.send("\n".join(result_list)) return # Skip # Skips a song in the player @bot.tree.command(name="skip", description="Skip the currently playing song") async def skip(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() # Some basic checks try: # Check if the bot is connected to a channel if interaction.guild.voice_client.channel != interaction.user.voice.channel: await interaction.followup.send(f"Not connected to your voice channel!") return # After this point, we know that it is in a channel # Check if the queue is empty if guild_queue_dict[interaction.guild_id].count() == 0: await interaction.followup.send(f"Queue is empty!") return # After this point, we know the queue has something in it except: await interaction.followup.send(f"You're not connected to a voice channel!") # Since someone else might be playing, we just stop so that the playTrack function # gets the next track # Stops the player to skip interaction.guild.voice_client.stop() # Epilogue await interaction.followup.send(f"Skipped the track!") return # Pause # Pauses the player @bot.tree.command(name="pause", description="Pauses the currently playing song") async def pause(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() interaction.guild.voice_client.pause() await interaction.followup.send(f"Paused!") return # Resume # Resumes the player @bot.tree.command(name="resume", description="Resumes the currently playing song") async def resume(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() # Some basic checks try: # Check if the bot is connected to a channel if interaction.guild.voice_client.channel != interaction.user.voice.channel: await interaction.followup.send(f"Not connected to your voice channel!") return # After this point, we know that it is in a channel if not interaction.guild.voice_client.is_playing(): await interaction.followup.send(f"The player has nothing to resume!") return # After this point, we know that it has something to play except: await interaction.followup.send(f"You're not connected to a voice channel!") return # Ideal function if interaction.guild.voice_client.is_paused(): interaction.guild.voice_client.resume() await interaction.followup.send(f"Resumed!") return # Something that it cannot do! await interaction.followup.send("Something unexpected happened, and I cannot resume!") return # Add # Enqueue an item @bot.tree.command(name="add", description="Queues a song into the list") async def add(interaction: discord.Interaction, title: str): # Makes the reaction visible to everyone await interaction.response.defer() # query the item data = await make_request(title, interaction) # Check if data is bogus if not data: await interaction.followup.send(f"Couldn't find a song matching {title}") return # Gets song information query_song = data[0] query_song_id = query_song.get('Id') query_song_title = query_song.get('Name') query_song_artist = query_song.get('AlbumArtist', ['Unknown Artist']) print(f"Found: {query_song_id}, with the title {query_song_title} and artist {query_song_artist}") # Add song information as a tuple if guild_queue_dict.get(interaction.guild_id) == None: guild_queue_dict[interaction.guild_id] = [] guild_queue_dict[interaction.guild_id].append((query_song_id, query_song_title, query_song_artist)) # Epilogue # Sends informational message await interaction.followup.send(f"Added **{query_song_title}** by *{query_song_artist}*!") tracks_queued = len(guild_queue_dict[interaction.guild_id]) # Helpfully tells the user how many tracks until their track is played if tracks_queued > 1: await interaction.followup.send(f"Plays after **{tracks_queued}** more tracks.") return #Clear #Clears the queue @bot.tree.command(name="clear", description="Clears the queue") async def clear(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() clearQueue(interaction) await interaction.followup.send(f"Cleared the Queue!") return #Queue #Allows the user to view the queue @bot.tree.command(name="queue", description="See the queue") async def queue(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() # Check if queue is uninitialized if guild_queue_dict.get(interaction.guild_id) == None: guild_queue_dict[interaction.guild_id] = [] # Add all the items in a queue to a list if len(guild_queue_dict[interaction.guild_id]) == 0: await interaction.followup.send(f"The queue is empty!") return # The queue has something in it result_list = [] for song in guild_queue_dict[interaction.guild_id]: result_list.append(f"**{song[1]}** by *{song[2]}*") await interaction.followup.send("\n".join(result_list)) return #Current # Display currently playing @bot.tree.command(name="current", description="See whats currently playing") async def current(interaction: discord.Interaction): # Makes the reaction visible to everyone await interaction.response.defer() #Display the current song # Some song information current = guild_queue_dict[interaction.guild_id][0] current_title = current[1] current_artist = current[2] await interaction.followup.send(f"Currently Playing: **{current_title}** by *{current_artist}*") # Cleanup await interaction.followup.send(f"Emptied the queue!") return # ----- FINALLY ----- # bot.run(TOKEN)