267 lines
11 KiB
Python
267 lines
11 KiB
Python
''' A player object that handles playback and data for its respective guild '''
|
|
|
|
import asyncio
|
|
import discord
|
|
|
|
import data
|
|
import ui
|
|
import logging
|
|
|
|
from subsonic import Song, APIError, get_random_songs, get_similar_songs, stream, scrobble
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default player data
|
|
_default_data: dict[str, any] = {
|
|
"current-song": None,
|
|
"current-position": 0,
|
|
"queue": [],
|
|
}
|
|
|
|
class Player():
|
|
''' Class that represents an audio player '''
|
|
def __init__(self) -> None:
|
|
self._data = _default_data
|
|
self._player_loop = None
|
|
|
|
@property
|
|
def current_song(self) -> Song:
|
|
'''The current song'''
|
|
return self._data["current-song"]
|
|
|
|
@current_song.setter
|
|
def current_song(self, song: Song) -> None:
|
|
self._data["current-song"] = song
|
|
|
|
@property
|
|
def current_position(self) -> int:
|
|
''' The current position for the current song, in seconds. '''
|
|
return self._data["current-position"]
|
|
|
|
@current_position.setter
|
|
def current_position(self, position: int) -> None:
|
|
''' Set the current position for the current song, in seconds. '''
|
|
self._data["current-position"] = position
|
|
|
|
@property
|
|
def queue(self) -> list[Song]:
|
|
''' The current audio queue. '''
|
|
return self._data["queue"]
|
|
|
|
@queue.setter
|
|
def queue(self, value: list) -> None:
|
|
self._data["queue"] = value
|
|
|
|
@property
|
|
def player_loop(self) -> asyncio.AbstractEventLoop:
|
|
''' The player loop '''
|
|
return self._player_loop
|
|
|
|
@player_loop.setter
|
|
def player_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
|
self._player_loop = loop
|
|
|
|
|
|
|
|
|
|
|
|
async def stream_track(self, interaction: discord.Interaction, song: Song, voice_client: discord.VoiceClient) -> None:
|
|
''' Streams a track from the Subsonic server to a connected voice channel, and updates guild data accordingly '''
|
|
|
|
# Make sure the voice client is available and connected
|
|
if voice_client is None:
|
|
await ui.ErrMsg.bot_not_in_voice_channel(interaction)
|
|
return
|
|
|
|
# Check if the voice client is still connected
|
|
if not voice_client.is_connected():
|
|
logger.error("Voice client is not connected")
|
|
await ui.ErrMsg.msg(interaction, "Voice connection was lost. Please try again.")
|
|
return
|
|
|
|
# Make sure the bot isn't already playing music
|
|
if voice_client.is_playing():
|
|
await ui.ErrMsg.already_playing(interaction)
|
|
return
|
|
|
|
# Get the stream from the Subsonic server, using the provided song's ID
|
|
ffmpeg_options = {"before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
|
|
"options": "-filter:a volume=replaygain=track"}
|
|
try:
|
|
stream_url = await stream(song.song_id)
|
|
if not stream_url:
|
|
logger.error("Failed to get stream URL")
|
|
await ui.ErrMsg.msg(interaction, "Failed to get audio stream. Please try again.")
|
|
return
|
|
|
|
audio_src = discord.FFmpegOpusAudio(stream_url, **ffmpeg_options)
|
|
except APIError as err:
|
|
logger.error(f"API Error streaming song, Code {err.errorcode}: {err.message}")
|
|
await ui.ErrMsg.msg(interaction, f"API error while streaming song: {err.message}")
|
|
return
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error getting audio stream: {e}")
|
|
await ui.ErrMsg.msg(interaction, "An error occurred while preparing the audio. Please try again.")
|
|
return
|
|
|
|
# Begin playing the song
|
|
loop = asyncio.get_event_loop()
|
|
self.player_loop = loop
|
|
|
|
# Handle playback finished
|
|
async def playback_finished(error):
|
|
if error:
|
|
logger.error(f"An error occurred while playing the audio: {error}")
|
|
# Check if the error is related to voice connection
|
|
if "Not connected to voice" in str(error):
|
|
logger.warning("Voice connection was lost during playback")
|
|
# Try to reconnect if possible
|
|
if interaction.user and interaction.user.voice and interaction.user.voice.channel:
|
|
try:
|
|
# Try to reconnect to the voice channel
|
|
if voice_client and not voice_client.is_connected():
|
|
await voice_client.connect(timeout=10.0, reconnect=True)
|
|
logger.info("Successfully reconnected to voice channel")
|
|
except Exception as e:
|
|
logger.error(f"Failed to reconnect to voice channel: {e}")
|
|
return
|
|
|
|
logger.debug("Playback finished.")
|
|
try:
|
|
# Only proceed if voice client is still connected
|
|
if voice_client and voice_client.is_connected():
|
|
future = asyncio.run_coroutine_threadsafe(self.play_audio_queue(interaction, voice_client), loop)
|
|
# Add a callback to handle any exceptions that occur during execution
|
|
future.add_done_callback(lambda f: logger.error(f"Error in play_audio_queue: {f.exception()}") if f.exception() else None)
|
|
else:
|
|
logger.warning("Voice client disconnected, cannot continue queue playback")
|
|
except Exception as e:
|
|
logger.error(f"Failed to schedule play_audio_queue: {e}")
|
|
|
|
# Try to play the audio with retry logic
|
|
max_attempts = 3
|
|
attempt = 0
|
|
|
|
while attempt < max_attempts:
|
|
try:
|
|
# Check again if voice client is still connected before playing
|
|
if not voice_client.is_connected():
|
|
logger.error("Voice client disconnected before playing")
|
|
await ui.ErrMsg.msg(interaction, "Voice connection was lost. Please try again.")
|
|
return
|
|
|
|
voice_client.play(audio_src, after=lambda e: loop.create_task(playback_finished(e)))
|
|
logger.info(f"Started playing: {song.title} by {song.artist}")
|
|
# Fire-and-forget scrobble (now-playing ping with submission=False)
|
|
try:
|
|
asyncio.create_task(scrobble(song.song_id, submission=False))
|
|
except Exception as e:
|
|
logger.error(f"Failed to schedule scrobble for {song.title}: {e}")
|
|
return # Success, exit the function
|
|
except discord.ClientException as e:
|
|
logger.error(f"Discord client exception while playing audio (attempt {attempt+1}): {e}")
|
|
attempt += 1
|
|
if attempt >= max_attempts:
|
|
await ui.ErrMsg.msg(interaction, "Failed to play audio after multiple attempts. Please try again.")
|
|
return
|
|
await asyncio.sleep(1) # Wait before retrying
|
|
except Exception as err:
|
|
logger.error(f"An error occurred while playing the audio: {err}")
|
|
await ui.ErrMsg.msg(interaction, "An error occurred while playing the audio. Please try again.")
|
|
return
|
|
|
|
|
|
async def handle_autoplay(self, interaction: discord.Interaction, prev_song_id: str=None) -> bool:
|
|
''' Handles populating the queue when autoplay is enabled '''
|
|
|
|
autoplay_mode = data.guild_properties(interaction.guild_id).autoplay_mode
|
|
queue = data.guild_data(interaction.guild_id).player.queue
|
|
logger.debug("Handling autoplay...")
|
|
logger.debug(f"Autoplay mode: {autoplay_mode}")
|
|
logger.debug(f"Queue: {queue}")
|
|
# If queue is notempty or autoplay is disabled, don't handle autoplay
|
|
if queue != [] or autoplay_mode is data.AutoplayMode.NONE:
|
|
return False
|
|
|
|
# If there was no previous song provided, we default back to selecting a random song
|
|
if prev_song_id is None:
|
|
autoplay_mode = data.AutoplayMode.RANDOM
|
|
logging.info("No previous song ID provided. Defaulting to random.")
|
|
|
|
songs = []
|
|
|
|
try:
|
|
match autoplay_mode:
|
|
case data.AutoplayMode.RANDOM:
|
|
songs = await get_random_songs(size=1)
|
|
case data.AutoplayMode.SIMILAR:
|
|
logger.debug(f"Prev song ID: {prev_song_id}")
|
|
songs = await get_similar_songs(song_id=prev_song_id, count=1)
|
|
|
|
except APIError as err:
|
|
logging.error(f"API Error fetching song for autoplay, Code {err.errorcode}: {err.message}")
|
|
|
|
logger.debug(f"Autoplay song: {songs}")
|
|
|
|
# If there's no match, throw an error
|
|
if len(songs) == 0:
|
|
await ui.ErrMsg.msg(interaction, "Failed to obtain a song for autoplay.")
|
|
return False
|
|
|
|
self.queue.append(songs[0])
|
|
return True
|
|
|
|
|
|
async def play_audio_queue(self, interaction: discord.Interaction, voice_client: discord.VoiceClient) -> None:
|
|
''' Plays the audio queue '''
|
|
|
|
# Check if the bot is connected to a voice channel; it's the caller's responsibility to open a voice channel
|
|
if voice_client is None:
|
|
await ui.ErrMsg.bot_not_in_voice_channel(interaction)
|
|
return
|
|
|
|
# Check if the bot is already playing something
|
|
if voice_client.is_playing():
|
|
return
|
|
|
|
|
|
# Check if the queue contains songs
|
|
if self.queue != []:
|
|
# Pop the first item from the queue and stream the track
|
|
song = self.queue.pop(0)
|
|
self.current_song = song
|
|
await ui.SysMsg.now_playing(interaction, song)
|
|
await self.stream_track(interaction, song, voice_client)
|
|
else:
|
|
logger.debug("Queue is empty.")
|
|
logger.debug("Current song: %s", self.current_song)
|
|
if self.current_song is not None:
|
|
prev_song_id = self.current_song.song_id
|
|
self.current_song = None
|
|
else:
|
|
prev_song_id = None
|
|
# Handle autoplay if queue is empty
|
|
if await self.handle_autoplay(interaction, prev_song_id=prev_song_id):
|
|
await self.play_audio_queue(interaction, voice_client)
|
|
return
|
|
# If the queue is empty, playback has ended; we should let the user know
|
|
await ui.SysMsg.playback_ended(interaction)
|
|
|
|
|
|
async def skip_track(self, interaction: discord.Interaction, voice_client: discord.VoiceClient) -> None:
|
|
''' Skips the current track and plays the next one in the queue '''
|
|
|
|
# Check if the bot is connected to a voice channel; it's the caller's responsibility to open a voice channel
|
|
if voice_client is None:
|
|
await ui.ErrMsg.bot_not_in_voice_channel(interaction)
|
|
return
|
|
logger.debug("Skipping track...")
|
|
# Check if the bot is already playing something
|
|
if voice_client.is_playing():
|
|
voice_client.stop()
|
|
await ui.SysMsg.skipping(interaction)
|
|
else:
|
|
await ui.ErrMsg.not_playing(interaction)
|
|
|
|
|