Compare commits
6 Commits
bde2d35634
...
bdfec840dd
| Author | SHA1 | Date | |
|---|---|---|---|
| bdfec840dd | |||
| acfe8cdeed | |||
| 91e4dd66d5 | |||
| 7a70bc2c23 | |||
| 3d73b06b12 | |||
| 2304f956b4 |
10
Dockerfile
10
Dockerfile
@ -2,12 +2,7 @@ FROM python:3.12.10-alpine
|
|||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
opus \
|
opus \
|
||||||
opus-dev \
|
opus-dev
|
||||||
build-base \
|
|
||||||
libffi-dev \
|
|
||||||
openssl-dev \
|
|
||||||
musl-dev \
|
|
||||||
python3-dev
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
@ -17,7 +12,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN python -c "import discord.opus as opus; opus.load_opus('/usr/lib/libopus.so.0.10.1'); print('✅ Opus loaded:', opus.is_loaded())"
|
|
||||||
|
|
||||||
CMD python bot.py
|
CMD python bot.py
|
||||||
|
|
||||||
|
|||||||
240
bot.py
240
bot.py
@ -1,26 +1,40 @@
|
|||||||
|
# Imports
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
from discord import app_commands
|
from discord import DiscordException, NotFound, app_commands
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
opus_path = '/usr/lib/libopus.so.0.10.1'
|
|
||||||
discord.opus.load_opus(opus_path)
|
|
||||||
|
|
||||||
|
# Environment vars
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
TOKEN = os.getenv("DISCORD_TOKEN")
|
TOKEN = os.getenv("DISCORD_TOKEN")
|
||||||
JELLYFIN_URL = os.getenv("JELLYFIN_URL")
|
JELLYFIN_URL = os.getenv("JELLYFIN_URL")
|
||||||
JELLYFIN_API_KEY = os.getenv("JELLYFIN_API_KEY")
|
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= {
|
headers= {
|
||||||
"X-Emby-Token": JELLYFIN_API_KEY
|
"X-Emby-Token": JELLYFIN_API_KEY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# discord bot setups
|
||||||
intents = discord.Intents.default()
|
intents = discord.Intents.default()
|
||||||
intents.message_content = True
|
intents.message_content = True
|
||||||
intents.voice_states = True
|
intents.voice_states = True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ----- Classes ----- #
|
||||||
|
# TODO: turn this in to an import in a separate file
|
||||||
class JellyfinBot(commands.Bot):
|
class JellyfinBot(commands.Bot):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__(command_prefix="/", intents=intents)
|
super().__init__(command_prefix="/", intents=intents)
|
||||||
@ -29,96 +43,186 @@ class JellyfinBot(commands.Bot):
|
|||||||
await self.tree.sync()
|
await self.tree.sync()
|
||||||
|
|
||||||
bot = JellyfinBot()
|
bot = JellyfinBot()
|
||||||
|
|
||||||
@bot.event
|
@bot.event
|
||||||
async def on_ready():
|
async def on_ready():
|
||||||
print(f"🪼 Logged in as {bot.user}")
|
print(f"🪼 Logged in as {bot.user}")
|
||||||
|
|
||||||
@bot.tree.command(name="play", description="Play a song from Jellyfin")
|
|
||||||
@app_commands.describe(song="Song title to play")
|
|
||||||
async def play(interaction: discord.Interaction, song: str):
|
|
||||||
await interaction.response.defer(ephemeral=False)
|
|
||||||
|
|
||||||
if not interaction.user.voice:
|
|
||||||
await interaction.response.send_message("You must be in a voice channel.")
|
|
||||||
return
|
|
||||||
|
|
||||||
url = f"{JELLYFIN_URL}/Items"
|
# ----- Helper Functions ----- #
|
||||||
params = {
|
|
||||||
"searchTerm": song,
|
|
||||||
"Recursive": True,
|
|
||||||
"IncludeItemTypes": "Audio",
|
|
||||||
"Limit": 5
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
# 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 = requests.get(url, headers=headers, params=params)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Presets the default value for data if this doesn't work
|
||||||
data = response.json().get("Items", [])
|
data = response.json().get("Items", [])
|
||||||
|
return data
|
||||||
if not data:
|
|
||||||
await interaction.followup.send(f"❌ No song found matching `{song}`.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Found {len(data)} items matching `{song}`.")
|
|
||||||
item = data[0]
|
|
||||||
item_id = item.get("Id")
|
|
||||||
stream_url = f"{JELLYFIN_URL}/Audio/{item_id}/stream?static=True"
|
|
||||||
|
|
||||||
print(f"Stream URL: {stream_url}")
|
|
||||||
channel = interaction.user.voice.channel
|
|
||||||
voice_client = interaction.guild.voice_client
|
|
||||||
if voice_client is None:
|
|
||||||
voice_client = await channel.connect()
|
|
||||||
elif voice_client.channel != channel:
|
|
||||||
await voice_client.move_to(channel)
|
|
||||||
voice_client = await channel.connect()
|
|
||||||
|
|
||||||
if voice_client.is_playing():
|
|
||||||
voice_client.stop()
|
|
||||||
|
|
||||||
headers_str = f"-headers \"X-Emby-Token: {JELLYFIN_API_KEY}\""
|
|
||||||
source = discord.FFmpegPCMAudio(stream_url, before_options=f'{headers_str} -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', options="-vn")
|
|
||||||
voice_client.play(source, after=lambda e: print(f"Finished playing: {e}"))
|
|
||||||
await interaction.followup.send(f"🎶 Now playing: **{item.get('Name')}** by *{item.get('AlbumArtist', ['Unknown Artist'])}*")
|
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
await interaction.followup.send("⚠️ Failed to contact Jellyfin server.")
|
await interaction.followup.send("⚠️ Failed to contact Jellyfin server.")
|
||||||
print(f"Error: {e}")
|
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
|
||||||
|
# If its not connected to a voice channel, then connect to the channel that the caller is in
|
||||||
|
if voice_client is None:
|
||||||
|
voice_client = await user_channel.connect()
|
||||||
|
|
||||||
|
# If the caller is in a channel that its not already in, move there
|
||||||
|
elif voice_client.channel != user_channel:
|
||||||
|
await voice_client.move_to(user_channel)
|
||||||
|
voice_client = await user_channel.connect()
|
||||||
|
|
||||||
|
#Pause the music that is currently playing
|
||||||
|
if voice_client.is_playing():
|
||||||
|
voice_client.stop()
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@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(ephemeral=False)
|
|
||||||
url = f"{JELLYFIN_URL}/Items"
|
url = f"{JELLYFIN_URL}/Items"
|
||||||
params = {
|
params = {
|
||||||
"searchTerm": title,
|
"searchTerm": title,
|
||||||
"Recursive": True,
|
"Recursive": True,
|
||||||
"IncludeItemTypes": "Audio",
|
"IncludeItemTypes": "Audio",
|
||||||
"Limit": 5
|
"Limit": PLAY_QUERY_LIMIT
|
||||||
}
|
}
|
||||||
try:
|
|
||||||
response = requests.get(url, headers=headers, params=params)
|
# makes a request to the server, and returns the data it acquires
|
||||||
response.raise_for_status()
|
data = await make_request(url, params, interaction)
|
||||||
data = response.json()
|
if not data:
|
||||||
items = data.get("Items", [])
|
await interaction.followup.send(f"❌ No song found matching `{title}`.")
|
||||||
if not items:
|
return
|
||||||
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}`.")
|
||||||
lines = []
|
|
||||||
for item in items:
|
# Deconstructing the dict 'data'
|
||||||
title = item.get("Name")
|
song = data[0]
|
||||||
artist = item.get("AlbumArtist", ["Unknown Artist"])
|
song_id = song.get('Id')
|
||||||
lines.append(f"✅ **{title}** by *{artist}*")
|
song_title = song.get('Name')
|
||||||
await interaction.followup.send("\n".join(lines))
|
song_artist = song.get('AlbumArtist', ['Unknown Artist'])
|
||||||
|
|
||||||
except requests.RequestException as e:
|
|
||||||
await interaction.followup.send("⚠️ Failed to contact Jellyfin server.")
|
try:
|
||||||
print(f"Error: {e}")
|
#Setup for ffmpeg
|
||||||
|
audio_stream_url = f"{JELLYFIN_URL}/Audio/{song_id}/stream?static=True"
|
||||||
|
print(f"Stream URL: {audio_stream_url}")
|
||||||
|
headers_str = f"-headers \"X-Emby-Token: {JELLYFIN_API_KEY}\""
|
||||||
|
|
||||||
|
# >>----- start PROBLEMS
|
||||||
|
|
||||||
|
# Here is the problematic library.
|
||||||
|
# It might just be looking for Opus and not finding it
|
||||||
|
source = discord.FFmpegOpusAudio(audio_stream_url,
|
||||||
|
before_options=f'{headers_str} -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5',
|
||||||
|
options="-vn")
|
||||||
|
|
||||||
|
# end PROBLEMS -----<<
|
||||||
|
|
||||||
|
# Play the 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}*")
|
||||||
|
|
||||||
|
|
||||||
|
# If the library does not exist, we fail it
|
||||||
|
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):
|
||||||
|
if interaction.guild.voice_client == None:
|
||||||
|
await interaction.followup.send("I am not in any voice channel!")
|
||||||
|
return
|
||||||
|
await interaction.guild.voice_client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ----- FINALLY ----- #
|
||||||
bot.run(TOKEN)
|
bot.run(TOKEN)
|
||||||
|
|||||||
@ -6,4 +6,4 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- JELLYFIN_URL=
|
- JELLYFIN_URL=
|
||||||
- JELLYFIN_API_KEY=
|
- JELLYFIN_API_KEY=
|
||||||
- JELLYFIN_USER_ID=
|
- DISCORD_TOKEN=
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user