# 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 from dotenv import load_dotenv # 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 # 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() @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 # begin makeRequest ----- async def make_request(url: str, params: dict, interaction: discord.Interaction) -> dict: # Default return value data = dict() 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 ----- # channelPlay # does logic for songs in a voice channel # ----- Commands ----- # # Play a song # Play function # 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 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) 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']) # 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)) # 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}*") 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}") # end play ----- # 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): 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 ----- # Disconnect command! # Disconnects from the voice channel @bot.tree.command(name="disconnect", description="Disconnects from current voice channel!") async def disconnect(interaction: discord.Interaction): # 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 # ----- FINALLY ----- # bot.run(TOKEN)