# 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, 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 guild_queue_dict[interaction.guild_id].count() > 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 ----- # ### DEPRECATED START ### # Play # Plays a song with the given title in wherever the user is # start play ----- @bot.tree.command(name="play", description="Play a song from Jellyfin") @app_commands.describe(title="Song title to play") async def play(interaction: discord.Interaction, title: str): # Makes the reaction visible to everyone await interaction.response.defer() # Checks if not in a voice channel # Checks if in a voice channel try: # A better way of checking status voice_status = await interaction.user.fetch_voice() user_channel = voice_status.channel voice_client = interaction.guild.voice_client # Enables the voice feature for the bot # First check if it is already in a voice channel if voice_client.channel != user_channel: voice_client.disconnect() if voice_client.is_playing(): voice_client.stop() # Finally, connect await user_channel.connect() except discord.errors.NotFound as e: print(f"Error: {e}") await interaction.followup.send("Nope! Not in a voice!") return except discord.errors.Forbidden as e: print(f"Error: {e}") await interaction.followup.send("Not allowed in here!") return # makes a request to the server, and returns the data it acquires data = await make_request(title, interaction) if not data: await interaction.followup.send(f"❌ No song found matching `{title}`.") return # Finds the item, and displays information about the location print(f"Found {len(data)} items matching `{title}`.") # Deconstructing the dict 'data' 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']) # Check if the guild is not known guilds if guild_queue_dict.get(interaction.guild_id) == None: # add it to the playlist dict guild_queue_dict[interaction.guild_id] = list() guild_queue_dict[interaction.guild_id].insert(0, (query_song_id, query_song_title, query_song_artist)) # If someone is already playing something, we just add to their queue. # Probably a better way of piggybacking if voice_client.is_playing(): await interaction.followup.send(f"Queued to front: **{query_song_title}** by *{query_song_artist}*") return await playTrack(interaction) # end play ----- ### DEPRECATED END ### # 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() 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() # 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!") # Play all items in the queue 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)) # end search ----- # 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) # 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']) # Add song information as a tuple 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 = guild_queue_dict[interaction.guild_id].count() # Helpfully tells the user how many tracks until their track is played if tracks_queued > 0: 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() # Add all the items in a queue to a list 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)