diff --git a/bot.py b/bot.py index d1f97e0..82c07e6 100644 --- a/bot.py +++ b/bot.py @@ -4,23 +4,21 @@ 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 +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 -PLAY_QUERY_LIMIT = 1 -SEARCH_QUERY_LIMIT = 5 - - -# Setting the opus path, since Linux can't read it +QUERY_LIMIT = 10 # Request headers headers= { @@ -35,6 +33,7 @@ intents.voice_states = True # ----- Classes ----- # + # TODO: turn this in to an import in a separate file class JellyfinBot(commands.Bot): def __init__(self): @@ -44,24 +43,35 @@ class JellyfinBot(commands.Bot): 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 - +# Returns an error string or an empty dict # begin makeRequest ----- -async def make_request(url: str, params: dict, interaction: discord.Interaction) -> dict: +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 @@ -78,18 +88,76 @@ async def make_request(url: str, params: dict, interaction: discord.Interaction) return data # end makeRequest ----- -# channelPlay -# does logic for songs in a voice channel + +# 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 ----- # -# Play a song -# Play function +### 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): @@ -124,16 +192,9 @@ async def play(interaction: discord.Interaction, title: str): return - url = f"{JELLYFIN_URL}/Items" - params = { - "searchTerm": title, - "Recursive": True, - "IncludeItemTypes": "Audio", - "Limit": PLAY_QUERY_LIMIT - } # makes a request to the server, and returns the data it acquires - data = await make_request(url, params, interaction) + data = await make_request(title, interaction) if not data: await interaction.followup.send(f"❌ No song found matching `{title}`.") return @@ -142,111 +203,316 @@ async def play(interaction: discord.Interaction, title: str): 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 = 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']) - # 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 # 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].append((query_song_id, query_song_title, query_song_artist)) + 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: **{query_song_title}** by *{query_song_artist}*") + await interaction.followup.send(f"Queued to front: **{query_song_title}** by *{query_song_artist}*") return - headers_str = f"-headers \"X-Emby-Token: {JELLYFIN_API_KEY}\"" - for song in guild_queue_dict[interaction.guild_id]: - try: - # Song information from tuple - song_id = song[0] - song_title = song[1] - song_artist = song[2] - - 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") - # Play the grabbed audio file - 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 way of holding the loop until its done - while voice_client.is_playing(): - continue - - # Then, we get rid of this queued item - 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}") + await playTrack(interaction) # end play ----- +### DEPRECATED END ### -# Search command -# searches for a song -# Search function -# 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): +# 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() - # Builds the url - url = f"{JELLYFIN_URL}/Items" - params = { - "searchTerm": title, - "Recursive": True, - "IncludeItemTypes": "Audio", - "Limit": SEARCH_QUERY_LIMIT - } - - # Get the data - data = await make_request(url, params, interaction) - - if not data: - await interaction.followup.send(f"No song found matching `{title}`.") - return - - lines = [] - - for song in data: - title = song.get("Name") - artist = song.get("AlbumArtist", ["Unknown Artist"]) - lines.append(f"**{title}** by *{artist}*") - await interaction.followup.send("\n".join(lines)) -# end search ----- + return -# Disconnect command! +# Disconnect # Disconnects from the voice channel -@bot.tree.command(name="disconnect", description="Disconnects from current 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 -# Queues -# Enqueue an item +# 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 + + + if interaction.guild.voice_client.is_paused(): + interaction.guild.voice_client.resume() + await interaction.followup.send(f"Resumed!") + return + + + + 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 **{song_title}** by *{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() + + + + # + 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() + + + # Cleanup + await interaction.followup.send(f"Emptied the queue!") + return diff --git a/ffmpeg.exe b/ffmpeg.exe deleted file mode 100644 index 50dd201..0000000 Binary files a/ffmpeg.exe and /dev/null differ