Source code for redbot.core.audio

from __future__ import annotations

import logging
import discord
import lavalink
import pathlib
import time
import re
import asyncio

from typing import Optional, Mapping, List

from .audio_utils.errors import (
    AudioError,
    LavalinkNotReady,
    NotConnectedToVoice,
    InvalidQuery,
    NoMatchesFound,
    TrackFetchError,
    SpotifyFetchError,
)
from .audio_utils.audio_dataclasses import Query, _PARTIALLY_SUPPORTED_MUSIC_EXT

from redbot.core import Config
from redbot.core.utils import AsyncIter
from redbot.core.data_manager import cog_data_path
from redbot.core.audio_utils import Lavalink, AudioAPIInterface, ServerManager
from redbot.core.audio_utils.audio_logging import IS_DEBUG

from redbot.core.audio_utils.utils import CacheLevel, PlaylistScope

log = logging.getLogger("red.core.audio")
log_player = logging.getLogger("red.core.audio.Player")

_used_by: list = []
_players: dict = {}
_api_interface: Optional[AudioAPIInterface] = None
_lavalink: Optional[Lavalink] = None
_server_manager: Optional[ServerManager] = None
_config: Optional[Config] = None


[docs]async def initialize( bot, cog_name: str, identifier: int, force_restart_ll_server: bool = False, force_reset_db_conn: bool = False, ): """Initializes the api and establishes all connections Parameters ---------- bot: Red The bot object cog_name: str Cog name used to track api usage identifier: int Unique identifier to counteract similar cog_names force_restart_ll_server: bool Force restarts the lavalink sever if set to True force_reset_db_conn: bool Force resets the database connection""" global _used_by if not _used_by: global _config, _lavalink, _server_manager, _api_interface _config = Config.get_conf( None, identifier=2711759130, cog_name="Audio", force_registration=True ) _default_lavalink_settings = { "host": "localhost", "rest_port": 2333, "ws_port": 2333, "password": "youshallnotpass", } default_global = { "schema_version": 1, "bundled_playlist_version": 0, "owner_notification": 0, "cache_level": CacheLevel.all().value, "cache_age": 365, "daily_playlists": False, "global_db_enabled": False, "global_db_get_timeout": 5, "status": False, "use_external_lavalink": False, "restrict": True, "localpath": str(cog_data_path(raw_name="Audio")), "url_keyword_blacklist": [], "url_keyword_whitelist": [], "java_exc_path": "java", **_default_lavalink_settings, } default_guild = { "auto_play": False, "currently_auto_playing_in": None, "auto_deafen": True, "autoplaylist": { "enabled": True, "id": 42069, "name": "Aikaterna's curated tracks", "scope": PlaylistScope.GLOBAL.value, }, "persist_queue": True, "disconnect": False, "dj_enabled": False, "dj_role": None, "daily_playlists": False, "emptydc_enabled": False, "emptydc_timer": 0, "emptypause_enabled": False, "emptypause_timer": 0, "jukebox": False, "jukebox_price": 0, "maxlength": 0, "max_volume": 150, "notify": False, "prefer_lyrics": False, "repeat": False, "shuffle": False, "shuffle_bumped": True, "thumbnail": False, "volume": 100, "vote_enabled": False, "vote_percent": 0, "room_lock": None, "url_keyword_blacklist": [], "url_keyword_whitelist": [], "country_code": "US", } _playlist: Mapping = dict(id=None, author=None, name=None, playlist_url=None, tracks=[]) _config.init_custom("EQUALIZER", 1) _config.register_custom("EQUALIZER", eq_bands=[], eq_presets={}) _config.init_custom(PlaylistScope.GLOBAL.value, 1) _config.register_custom(PlaylistScope.GLOBAL.value, **_playlist) _config.init_custom(PlaylistScope.GUILD.value, 2) _config.register_custom(PlaylistScope.GUILD.value, **_playlist) _config.init_custom(PlaylistScope.USER.value, 2) _config.register_custom(PlaylistScope.USER.value, **_playlist) _config.register_guild(**default_guild) _config.register_global(**default_global) _config.register_user(country_code=None) _api_interface = AudioAPIInterface(bot, _config) _server_manager = ServerManager(bot, _config) _lavalink = Lavalink(bot, _config, _server_manager, _api_interface) if force_restart_ll_server: if _server_manager.is_running: await _lavalink.shutdown() await _server_manager.shutdown_ll_server() if force_reset_db_conn: if _api_interface.is_connected: await _api_interface.close() if not _lavalink.is_connected: await _lavalink.start() if not _api_interface.is_connected: await _api_interface.initialize() _used_by.append((cog_name, identifier))
[docs]async def shutdown(cog_name: str, identifier: int, force_shutdown: bool = False): """Closes the api connection Parameters ---------- cog_name: str The same cog name used in :py:meth:initialize identifier: int The same identifier used in :py:meth:initialize force_shutdown: bool whether or not the connection should be force closed, even though other cogs are still connected""" global _used_by if force_shutdown: if _lavalink.is_connected: await _lavalink.shutdown() if _server_manager.is_running: await _server_manager.shutdown_ll_server() if _api_interface.is_connected: await _api_interface.run_all_pending_tasks() await _api_interface.close() _used_by = [] else: if not (cog_name, identifier) in _used_by: raise KeyError(f"{cog_name}: {identifier} doesn't match any established connection") _used_by.remove((cog_name, identifier)) if not _used_by: if _lavalink.is_connected: await _lavalink.shutdown() if _server_manager.is_running: await _server_manager.shutdown_ll_server() if _api_interface.is_connected: await _api_interface.run_all_pending_tasks() await _api_interface.close()
async def wait_until_api_ready(): while True: if _used_by: break await asyncio.sleep(0.1)
[docs]async def connect(bot, channel: discord.VoiceChannel, deafen: bool = False): """Connects to a voice channel Parameters ---------- bot: Red The bot object channel: discord.VoiceChannel The channel to connect to deafen: bool Overwrites the configs auto_deafen value if given Returns ------- Player: Player A player object""" if not _lavalink and not _lavalink.is_connected: raise LavalinkNotReady("Connection to lavalink has not yet been established") if not deafen: deafen = await _config.guild(channel.guild).auto_deafen() await lavalink.connect(channel=channel, deafen=deafen) player = Player(bot, channel) _players[channel.guild.id] = player return player
[docs]def get_player(guild_id: int) -> Optional[Player]: """Get the Player object of the given guild Parameters ---------- guild: discord.Guild The guild to get the Player object from Returns ------- Player: Optional[Player] The Player object of this guild""" try: return _players[guild_id] except KeyError: return None
[docs]async def dj_enabled(guild: discord.Guild): """Whether or not DJ controls are enabled Parameters ---------- guild: discord.Guild The guild for which to check Returns ------- bool Whether or not DJ controls are enabled""" if await _config.guild(guild).dj_enabled(): return True return False
[docs]async def is_dj(user: discord.Member): """Whether ot not a user has the DJ role Parameters ---------- user: discord.Member The user to check for Returns ------- bool Whether or not the user has the DJ role""" dj_role = await _config.guild(user.guild).dj_role() if not dj_role: return False dj_role = user.guild.get_role(dj_role) if dj_role in user.roles: return True return False
def _get_ll_player(guild: discord.Guild) -> lavalink.Player: try: return lavalink.get_player(guild.id) except (KeyError, IndexError): raise NotConnectedToVoice("Bot is not currently connected to a voice channel") def all_players(): return list(_players.values())
[docs]class Player: def __init__(self, bot, channel): self._bot = bot self._player = lavalink.get_player(channel.guild.id) self._guild = channel.guild self._config = _config self._local_folder_current_path = None self._lavalink = _lavalink self._api_interface = _api_interface self._server_manager = _server_manager def __repr__(self): return ( "<Player: " f"guild={self.guild.name!r} ({self.guild.id}), " f"channel={self.channel.name!r} ({self.channel.id}), " f"playing={self.is_playing}, paused={self.paused}, volume={self.volume}, " f"queue_size={len(self.queue)}, current={self.current!r}, " f"position={self.position}, " f"length={self.current.length if self.current else 0}>" ) @property def bot(self): return self._bot @property def channel(self): """discord.VoiceChannel: The current voice channel""" return self._player.channel @property def current(self): """lavalink.Track: The current track""" return self._player.current @property def guild(self): """discord.Guild: The player's guild""" return self._guild @property def is_playing(self): """Whether or not the player is playing :type: bool""" return self._player.is_playing @property def paused(self): """bool: Whether or not the player is paused""" return self._player.paused @property def _ll_player(self): """lavalink.Player: The lavalink.Player behind this object""" return self._player @property def position(self): """float: Position of the current song""" return self._player.position @property def queue(self): """list: The player's queue""" return self._player.queue @queue.setter def queue(self, q: List): self._player.queue = q @property def repeat(self): """bool: Whether or not the queue repeats""" return self._player.repeat @repeat.setter def repeat(self, r: bool): self._player.repeat = r @property def shuffle(self): """bool: Whether or not the queue is shuffled""" return self._player.shuffle @shuffle.setter def shuffle(self, s: bool): self._player.shuffle = s @property def shuffle_bumped(self): return self._player.shuffle_bumped @shuffle_bumped.setter def shuffle_bumped(self, sb: bool): self._player.shuffle_bumped = sb @property def volume(self): """The player's volume :type: int""" return self._player.volume async def _set_player_settings(self) -> None: guild_data = await self._config.guild(self._guild).all() self.repeat = guild_data["repeat"] self.shuffle = guild_data["shuffle"] self.shuffle_bumped = guild_data["shuffle_bumped"] volume = guild_data["volume"] if self.volume != volume: await self.set_volume(volume) async def _add_to_queue( self, requester: discord.Member, track: lavalink.Track, bump: bool = False, bump_and_skip: bool = False, ): track.requester = requester if not bump and not bump_and_skip: self.queue.append(track) elif bump or bump_and_skip: self.queue.insert(0, track) if bump_and_skip: if self.current: await self.skip(requester) async def _enqueue_tracks( self, requester: discord.Member, query: Optional[str], track: Optional[lavalink.Track], local_folder: Optional[pathlib.Path], bump: bool = False, bump_and_skip: bool = False, enqueue: bool = True, ): settings = await self._config.guild(self._guild).all() settings["maxlength"] = 0 if not "maxlength" in settings.keys() else settings["maxlength"] first_track_only = False index = None seek = 0 if track: if len(self.queue) >= 10000: log_player.debug("Queue limit reached") if seek and seek > 0: track.start_timestamp = seek * 1000 if settings["maxlength"] > 0: await self._add_to_queue(requester, track, bump, bump_and_skip) self._bot.dispatch("red_audio_track_enqueue", self._guild, track, requester) self.maybe_shuffle() else: track.extras.update( { "enqueue_time": int(time.time()), "vc": self.channel.id, "requester": requester.id, } ) await self._add_to_queue(requester, track, bump, bump_and_skip) self._bot.dispatch("red_audio_track_enqueue", self._guild, track, requester) self.maybe_shuffle() if not self.current: await self._player.play() return track, None query = Query.process_input(query, local_folder) if query.is_spotify: return await self._enqueue_spotify_tracks(requester, query, bump, bump_and_skip) if not isinstance(query, list): if query.single_track: first_track_only = True index = query.track_index if query.start_time: seek = query.start_time try: result, called_api = await self._api_interface.fetch_track( requester.id, self._player, query ) except KeyError: raise NoMatchesFound("No matches could be found for the given query") tracks = result.tracks playlist_data = result.playlist_info if not enqueue: return tracks if not tracks: if IS_DEBUG: if result.exception_message: if "Status Code" in result.exception_message: log_player.debug(result.exception_message[:2000]) else: log_player.debug(result.exception_message[:2000].replace("\n", "")) if await self._config.use_external_lavalink() and query.is_local: log_player.debug("Local track") elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT: log_player.debug( "Semi supported file extension: Track might not be fully playable" ) raise TrackFetchError("Fetching tracks failed") else: tracks = query if not tracks: return if not first_track_only and len(tracks) > 1: if len(self.queue) >= 10000: log_player.debug("Queue limit reached") track_len = 0 empty_queue = not self.queue async for track in AsyncIter(tracks): if len(self.queue) >= 10000: log_player.debug("Queue limit reached") break if settings["maxlength"] > 0: track_len += 1 track.extras.update( { "enqueue_time": int(time.time()), "vc": self.channel.id, "requester": requester.id, } ) await self._add_to_queue(requester, track, bump, bump_and_skip) self._bot.dispatch("red_audio_track_enqueue", self._guild, track, requester) else: track_len += 1 track.extras.update( { "enqueue_time": int(time.time()), "vc": self.channel.id, "requester": requester.id, } ) await self._add_to_queue(requester, track, bump, bump_and_skip) self._bot.dispatch("red_audio_track_enqueue", self._guild, track, requester) self.maybe_shuffle(0 if empty_queue else 1) if len(tracks) > track_len: log_player.debug(f"{len(tracks) - track_len} tracks cannot be enqueued") if not self.current: await self._player.play() return tracks, playlist_data else: try: if len(self.queue) >= 10000: log_player.debug("Queue limit reached") single_track = ( tracks if isinstance(tracks, lavalink.rest_api.Track) else tracks[index] if index else tracks[0] ) if seek and seek > 0: single_track.start_timestamp = seek * 1000 if settings["maxlength"] > 0: await self._add_to_queue(requester, single_track, bump, bump_and_skip) self._bot.dispatch( "red_audio_track_enqueue", self._guild, single_track, requester ) self.maybe_shuffle() else: single_track.extras.update( { "enqueue_time": int(time.time()), "vc": self.channel.id, "requester": requester.id, } ) await self._add_to_queue(requester, single_track, bump, bump_and_skip) self._bot.dispatch( "red_audio_track_enqueue", self._guild, single_track, requester ) self.maybe_shuffle() except IndexError: raise InvalidQuery(f"No results found for {query}") except Exception as e: raise e if not self.current: log_player.debug("Starting player") await self._player.play() return single_track, None async def _enqueue_spotify_tracks( self, requester: discord.Member, query: Query, bump: bool = False, bump_and_skip: bool = False, ): if query.single_track: try: res = await self._api_interface.spotify_query( requester.id, "track", query.id, skip_youtube=True ) if not res: raise NoMatchesFound("No tracks which match your query found on spotify") except Exception as e: raise e new_query = Query.process_input(res[0], None) new_query.start_time = query.start_time return await self._enqueue_tracks( requester, query=new_query, track=None, local_folder=None, bump=bump, bump_and_skip=bump_and_skip, ) elif query.is_album or query.is_playlist: # ToDo fetch spotify playlists: utilities/player.py L#336 pass async def _skip( self, player: lavalink.Player, requester: discord.Member, skip_to_track: int = None ) -> None: autoplay = await self._config.guild(self._guild).auto_play() if not player.current: raise AudioError("Nothing playing") elif autoplay and not player.queue: await player.skip() return queue_to_append = [] if skip_to_track and skip_to_track != 1: if skip_to_track > len(player.queue): raise IndexError("skip_to_track cannot be larger than the total queue length") if player.repeat: queue_to_append = player.queue[0 : min(skip_to_track - 1, len(player.queue) - 1)] player.queue = player.queue[ min(skip_to_track - 1, len(player.queue) - 1) : len(player.queue) ] self._bot.dispatch("red_audio_track_skip", self._guild, player.current, requester) await player.play() player.queue += queue_to_append
[docs] async def disconnect(self) -> None: """Disconnects the player""" global _players await self.stop() await self._player.disconnect() self._bot.dispatch("red_audio_audio_disconnect", self._guild) if IS_DEBUG: log_player.debug(f"Disconnected from {self.channel} in {self._guild}") del _players[self._guild.id] del self
[docs] async def get_tracks(self, query: str, local_folder: pathlib.Path = None): """Queries the local apis for a track and falls back to lavalink if none was found Note: falling back to lavalink is only possible if the bot is connected to a voice channel Parameters ---------- query: str The query to search for. Supports everything core audio supports local_folder: pathlib.Path Local folder if track is a local track Returns ------- List[lavalink.Track], playlist_data """ query = Query.process_input(query, local_folder) if not query.valid: raise InvalidQuery(f"No results found for {query}") try: result, called_api = await self._api_interface.fetch_track(self._guild.id, self, query) except KeyError: raise NoMatchesFound("No matches could be found for the given query") tracks = result.tracks playlist_data = result.playlist_info if not tracks: if result.exception_message: if IS_DEBUG: if "Status Code" in result.exception_message: log_player.debug(result.exception_message[:2000]) else: log_player.debug(result.exception_message[:2000].replace("\n", "")) raise TrackFetchError("Fetching tracks failed") if await self._config.use_external_lavalink() and query.is_local: log_player.debug("Local track") elif query.is_local and query.suffix in _PARTIALLY_SUPPORTED_MUSIC_EXT: log_player.debug( "Semi supported file extension: Track might not be fully playable" ) return tracks, playlist_data
[docs] async def get_local_tracks(self, query: pathlib.Path): """Converts a localtrack into a lavalink.Track object which can then be used in Player.play Parameters ---------- query: pathlib.Path path to the music file Returns ------- List[lavalink.Track], playlist_data """ load_result = await self._player.load_tracks(str(query)) if load_result.tracks: return load_result.tracks, load_result.playlist_info else: if load_result.exception_message: log.exception(load_result.exception_message) else: raise AudioError("Track lookup failed.")
[docs] def fetch(self, key, default=None): """Returns a stored metadata value. Parameters ---------- key Key used to store metadata. default Optional, used if the key doesn't exist. """ return self._player._metadata.get(key, default)
[docs] async def is_requester(self, member: discord.Member) -> bool: """Whether a user is the requester of the current song Parameters ---------- member: discord.Member Member to check Returns ------- bool """ if self.current: if self.current.requester.id == member.id: return True return False
[docs] def maybe_shuffle(self, sticky_songs: int = 1): """Shuffles the queue if shuffle is True""" if self.shuffle and self.queue: self._player.force_shuffle(sticky_songs)
[docs] async def move_to(self, channel: discord.VoiceChannel, deafen: bool = True) -> None: """Move the player to another voice channel Parameters ---------- channel: discord.VoiceChannel The channel to move the player to deafen: bool Whether or not the player deafens """ if channel != self.channel: player = self._player await player.move_to(channel)
# player._last_channel_id = self.channel.id # player.channel = channel # # await player.connect(True)
[docs] async def pause(self) -> None: """Pauses the player in a guild""" player = self._player if not player.paused: await player.pause() self._bot.dispatch("red_audio_audio_paused", self._guild, True)
[docs] async def play( self, requester: discord.Member, query: Optional[str] = None, track: Optional[lavalink.Track] = None, local_folder: pathlib.Path = None, bump: bool = False, bump_and_skip: bool = False, ): """Plays a track Parameters ---------- requester: discord.Member The requester of the song query: str The query to search for. Supports everything core audio supports track: lavalink.Track A track object to be enqueued local_folder: pathlib.Path Local folder if track is a local track bump: bool Puts the song at queue position 0 if True bump_and_skip: bool acts like bump but also skips the current song Returns ------- List[lavalink.Track], playlist_data """ if not query and not track: raise AttributeError("Either a query string or a track object is required") await self._set_player_settings() tracks, playlist_data = await self._enqueue_tracks( requester, query=query, track=track, local_folder=local_folder, bump=bump, bump_and_skip=bump_and_skip, ) await self._api_interface.run_tasks(requester.id) return tracks, playlist_data
[docs] async def resume(self) -> None: """Resumes the player in a guild""" if self.paused: await self._player.pause(False) self._bot.dispatch("red_audio_audio_paused", self._guild, False)
[docs] async def seek(self, seconds: int = None, timestamp: str = None): """Skip to a given position or skips a given amount of seconds Parameters ---------- seconds: int Seconds to skip. Can be a negative number to go backwards timestamp: str timestamp to skip to Expected format: hh:mm:ss -> 00:10:15 for example""" if self.current: if self.current.is_stream: raise AudioError("Cannot seek streams") if not self.current.seekable: raise AudioError("Track is not seekable") if seconds: if seconds * 1000 > self.current.length - self.position: raise ValueError( "Cannot seek for more time than the current track has remaining" ) seek = self.position + seconds * 1000 await self._player.seek(seek) return elif timestamp: match = (re.compile(r"(?:(\d+):)?([0-5]?[0-9]):([0-5][0-9])")).match(seconds) if match is not None: hr = int(match.group(1)) if match.group(1) else 0 mn = int(match.group(2)) if match.group(2) else 0 sec = int(match.group(3)) if match.group(3) else 0 seek = sec + (mn * 60) + (hr * 3600) else: try: seek = int(seconds) except ValueError: seek = 0 await self._player.seek(seek) else: raise AttributeError("Either seconds or timestamp has to be given")
[docs] async def set_volume(self, vol: int) -> int: """Set the player's volume Returns ------- int the volume after change """ max_vol = await self._config.guild(self.guild).max_volume() if vol < 0 or vol > max_vol: raise ValueError("Volume is not within the allowed range") await self._config.guild(self._guild).volume.set(vol) await self._player.set_volume(vol)
[docs] async def skip(self, requester: discord.Member, skip_to_track: int = None) -> lavalink.Track: """Skips a track Parameters ---------- requester: discord.Member The requester of the skip skip_to_track: int Amount of tracks you want to skip Returns ------- lavalink.Track The current track """ player = self._player await self._skip(player, requester, skip_to_track)
[docs] async def stop(self) -> None: """Stop the playback""" self.queue = [] self.store("playing_song", None) self.store("prev_requester", None) self.store("prev_song", None) self.store("requester", None) self.store("autoplay_notified", False) await self._player.stop() self._bot.dispatch("red_audio_audio_stop", self._guild)
[docs] def store(self, key, value) -> None: """Stores a metadata value by key.""" self._player._metadata[key] = value