Source code for discordSuperUtils.Music

from __future__ import annotations

import asyncio
import re
import time
from enum import Enum
from typing import (
    Optional,
    TYPE_CHECKING,
    Iterable,
    List,
    Union,
    Any
)

import aiohttp
import discord
import youtube_dl

from .Base import EventManager
from .Spotify import SpotifyClient

if TYPE_CHECKING:
    from discord.ext import commands


FFMPEG_OPTIONS = {'before_options': '-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5', 'options': '-vn'}
SPOTIFY_RE = re.compile("^https://open.spotify.com/")
YTDL = youtube_dl.YoutubeDL({
    'format': 'bestaudio/best',
    'restrictfilenames': True,
    'noplaylist': False,
    'nocheckcertificate': True,
    'ignoreerrors': False,
    'logtostderr': False,
    'quiet': True,
    'no_warnings': True,
    'default_search': 'auto'
})


__all__ = (
    "NotPlaying",
    "NotConnected",
    "NotPaused",
    "QueueEmpty",
    "AlreadyConnected",
    "AlreadyPaused",
    "QueueError",
    "SkipError",
    "UserNotConnected",
    "InvalidSkipIndex",
    "Loops",
    "Player",
    "QueueManager",
    "MusicManager"
)


[docs]class NotPlaying(Exception): """Raises error when client is not playing"""
[docs]class NotConnected(Exception): """Raises error when client is not connected to a voice channel"""
[docs]class NotPaused(Exception): """Raises error when player is not paused"""
[docs]class QueueEmpty(Exception): """Raises error when queue is empty"""
[docs]class AlreadyConnected(Exception): """Raises error when client is already connected to voice"""
[docs]class AlreadyPaused(Exception): """Raises error when player is already paused."""
[docs]class QueueError(Exception): """Raises error when something is wrong with the queue"""
[docs]class SkipError(Exception): """Raises error when there is no song to skip to"""
[docs]class UserNotConnected(Exception): """Raises error when user is not connected to channel"""
[docs]class InvalidSkipIndex(Exception): """Raises error when the skip index is < 0"""
[docs]class Loops(Enum): NO_LOOP = 0 LOOP = 1 QUEUE_LOOP = 2
[docs]class Player(discord.PCMVolumeTransformer): """ Represents a music player. """ __slots__ = ("data", "title", "stream_url", "url", "start_timestamp", "last_pause_timestamp", "duration") def __init__(self, source, requester: discord.Member, *, data, volume=0.1): super().__init__(source, volume) self.data = data self.requester = requester self.title = data.get('title') self.stream_url = data.get('url') self.url = data.get('webpage_url') self.start_timestamp = 0 self.last_pause_timestamp = 0 self.duration = data.get('duration') if data.get('duration') != 0 else "LIVE" def __str__(self): return self.title
[docs] @staticmethod async def make_multiple_players(songs: Iterable[str], requester: discord.Member) -> List[Player]: """ |coro| Returns a list of players from a iterable of queries. :param requester: The requester. :type requester: discord.Member :param songs: The queries. :type songs: Iterable[str] :return: The list of created players. :rtype: List[Player] """ tasks = [Player.make_player(song, requester, playlist=False) for song in songs] return [x[0] for x in await asyncio.gather(*tasks) if x]
[docs] @classmethod async def make_player(cls, query: str, requester: discord.Member, playlist: bool = True) -> List[Player]: """ |coro| Returns a list of players from the query. The list will contain the first video incase it is not a playlist. :param requester: The requester. :type requester: discord.Member :param query: The query. :type query: str :param playlist: A bool indicating if the function should fetch playlists or get the first video. :type playlist: bool :return: The list of created players. :rtype: List[Player] """ data = await MusicManager.fetch_data(query) if data is None: return [] if 'entries' in data: if not playlist: data = data['entries'][0] else: return [cls( discord.FFmpegPCMAudio(player['url'], **FFMPEG_OPTIONS), requester, data=player) for player in data['entries']] filename = data['url'] return [cls(discord.FFmpegPCMAudio(filename, **FFMPEG_OPTIONS), requester, data=data)]
[docs]class QueueManager: __slots__ = ("queue", "volume", "history", "loop", "now_playing") def __init__(self, volume: float, queue: List[Player]): self.queue = queue self.volume = volume self.history = [] self.loop = Loops.NO_LOOP self.now_playing = None
[docs] def add(self, player: Player) -> None: """ Adds a player to the queue. :param player: The player to add. :type player: Player :return: None :rtype: None """ self.queue.append(player)
[docs] def clear(self) -> None: """ Clears the queue. :return: None :rtype: None """ self.queue.clear()
[docs] def remove(self, index: int) -> Union[Player, Any]: """ Removes and element from the queue at the specified index, and returns the element's value. :param index: The index. :type index: int :return: The element's value :rtype: Union[Player, Any] """ return self.queue.pop(index)
[docs]class MusicManager(EventManager): """ Represents a MusicManager. """ __slots__ = ("bot", "client_id", "client_secret", "spotify_support", "inactivity_timeout", "queue", "spotify") def __init__(self, bot: commands.Bot, spotify_support: bool = True, inactivity_timeout: int = 60, **kwargs): super().__init__() self.bot = bot self.client_id = kwargs.get('client_id') self.client_secret = kwargs.get('client_secret') self.spotify_support = spotify_support self.inactivity_timeout = inactivity_timeout self.queue = {} if spotify_support: self.spotify = SpotifyClient(client_id=self.client_id, client_secret=self.client_secret)
[docs] async def ensure_activity(self, ctx: commands.Context) -> None: """ |coro| Waits the inactivity timeout and ensures the voice client in ctx is playing a song. If no song is playing, it disconnects and calls the on_inactivity_timeout event. :param ctx: The context. :type ctx: commands.Context :return: None :rtype: None """ if self.inactivity_timeout is None: return await asyncio.sleep(self.inactivity_timeout) if not ctx.voice_client: return if ctx.voice_client.is_connected() and not ctx.voice_client.is_playing(): await ctx.voice_client.disconnect() await self.call_event("on_inactivity_disconnect", ctx)
async def __check_connection(self, ctx: commands.Context, check_playing: bool = False, check_queue: bool = False) -> Optional[bool]: """ |coro| Checks the connection state of the voice client in ctx. :param ctx: The context. :type ctx: commands.Context :param check_playing: A bool indicating if the function should check if a song is playing. :type check_playing: bool :param check_queue: A bool indicating if the function should check if a queue exists. :type check_queue: bool :return: True if all the checks passed. :rtype: bool """ if not ctx.voice_client or not ctx.voice_client.is_connected(): await self.call_event('on_music_error', ctx, NotConnected("Client is not connected to a voice channel")) return if check_playing and not ctx.voice_client.is_playing(): await self.call_event('on_music_error', ctx, NotPlaying("Client is not playing anything currently")) return if check_queue and ctx.guild.id not in self.queue: await self.call_event('on_music_error', ctx, QueueEmpty("Queue is empty")) return return True async def __check_queue(self, ctx: commands.Context) -> None: """ |coro| Plays the next song in the queue, handles looping and queue looping. :param ctx: The context of the voice client. :type ctx: commands.Context :return: None :rtype: None """ try: if not ctx.voice_client or not ctx.voice_client.is_connected(): return if self.queue[ctx.guild.id].loop == Loops.LOOP: song = self.queue[ctx.guild.id].now_playing player = (await Player.make_player(song.url, song.requester, playlist=False))[0] elif self.queue[ctx.guild.id].loop == Loops.QUEUE_LOOP: song = self.queue[ctx.guild.id].remove(0) player = (await Player.make_player(song.url, song.requester, playlist=False))[0] self.queue[ctx.guild.id].add(player) else: player = self.queue[ctx.guild.id].remove(0) self.queue[ctx.guild.id].now_playing = player if player is None or not ctx.voice_client: return player.volume = self.queue[ctx.guild.id].volume ctx.voice_client.play(player, after=lambda x: self.bot.loop.create_task(self.__check_queue(ctx))) player.start_timestamp = time.time() if self.queue[ctx.guild.id].loop == Loops.NO_LOOP: self.queue[ctx.guild.id].history.append(player) await self.call_event('on_play', ctx, player) except (IndexError, KeyError): await self.call_event("on_queue_end", ctx)
[docs] async def get_player_played_duration(self, ctx: commands.Context, player: Player) -> Optional[float]: """ |coro| Returns the played duration of a player. :param ctx: The context. :type ctx: commands.Context :param player: The player. :type player: Player :return: The played duration of the player in seconds. :rtype: Optional[float] """ if not await self.__check_connection(ctx): return start_timestamp = player.start_timestamp if ctx.voice_client.is_paused(): start_timestamp = player.start_timestamp + time.time() - player.last_pause_timestamp time_played = time.time() - start_timestamp return min(time_played, time_played if player.duration == "LIVE" else player.duration)
[docs] @staticmethod async def fetch_data(query: str) -> Optional[dict]: """ |coro| Fetches the YTDL data of the query. :param query: The query. :type query: str :return: The YTDL data. :rtype: Optional[dict] """ try: loop = asyncio.get_event_loop() return await loop.run_in_executor(None, lambda: YTDL.extract_info(query, download=False)) except youtube_dl.utils.DownloadError: return None
[docs] async def create_player(self, query: str, requester: discord.Member) -> List[Player]: """ |coro| Creates a list of players from the query. This function supports Spotify and all YTDL supported links. :param requester: The requester. :type requester: discord.Member :param query: The query. :type query: str :return: The list of players. :rtype: List[Player] """ if SPOTIFY_RE.match(query) and self.spotify_support: return await Player.make_multiple_players( [song for song in await self.spotify.get_songs(query)], requester ) return await Player.make_player(query, requester)
[docs] async def queue_add(self, players: List[Player], ctx: commands.Context) -> Optional[bool]: """ |coro| Adds a list of players to the ctx queue. If a queue does not exist in ctx, it creates one. :param players: The list of players. :type players: List[Player] :param ctx: The context. :type ctx: commands.Context :return: A bool indicating if it was successful :rtype: Optional[bool] """ if not await self.__check_connection(ctx): return if ctx.guild.id in self.queue: self.queue[ctx.guild.id].queue += players else: self.queue[ctx.guild.id] = QueueManager(0.1, players) return True
[docs] async def queue_remove(self, ctx: commands.Context, index: int) -> None: """ |coro| Removes a player from the queue in ctx at the specified index. Calls on_music_error with QueueError if index is invalid. :param ctx: The context. :type ctx: commands.Context :param index: The index. :type index: int :return: None :rtype: None """ if not await self.__check_connection(ctx, check_queue=True): return try: self.queue[ctx.guild.id].remove(index) except IndexError: await self.call_event('on_music_error', ctx, QueueError("Failure when removing player from queue"))
[docs] async def lyrics(self, ctx: commands.Context, query: str = None) -> Optional[str]: """ |coro| Returns the lyrics from the query or the currently playing song. :param ctx: The context. :type ctx: commands.Context :param query: The query. :type query: str :return: The lyrics. :rtype: Optional[str] """ query = await self.now_playing(ctx) if query is None else query url = f"https://some-random-api.ml/lyrics?title={query}" async with aiohttp.ClientSession() as session: request = await session.get(url) request_json = await request.json() return request_json.get('lyrics', None)
[docs] async def play(self, ctx: commands.Context, player: Player = None) -> Optional[bool]: """ |coro| Plays the player or the next song in the queue if the player is not passed. :param ctx: The context. :type ctx: commands.Context :param player: The player. :type player: Player :return: A bool indicating if the play was successful :rtype: Optional[bool] """ if not await self.__check_connection(ctx): return if player is not None: ctx.voice_client.play(player) return True if not ctx.voice_client.is_playing(): await self.__check_queue(ctx) return True
[docs] async def pause(self, ctx: commands.Context) -> Optional[bool]: """ |coro| Pauses the currently playing song in ctx. Calls on_music_error with AlreadyPaused if already paused. :param ctx: The context. :type ctx: commands.Context :return: A bool indicating if the pause was successful :rtype: Optional[bool] """ if not await self.__check_connection(ctx): return if ctx.voice_client.is_paused(): await self.call_event('on_music_error', ctx, AlreadyPaused("Player is already paused.")) return (await self.now_playing(ctx)).last_pause_timestamp = time.time() ctx.voice_client.pause() self.bot.loop.create_task(self.ensure_activity(ctx)) return True
[docs] async def resume(self, ctx: commands.Context) -> Optional[bool]: """ |coro| Resumes the currently paused song in ctx. Calls on_music_error with NotPaused if not paused. :param ctx: The context. :type ctx: commands.Context :return: A bool indicating if the resume was successful :rtype: Optional[bool] """ if not await self.__check_connection(ctx): return if not ctx.voice_client.is_paused(): await self.call_event('on_music_error', ctx, NotPaused("Player is not paused")) return ctx.voice_client.resume() now_playing = await self.now_playing(ctx) now_playing.start_timestamp += time.time() - now_playing.last_pause_timestamp return True
[docs] async def skip(self, ctx: commands.Context, index: int = None) -> Optional[Player]: """ |coro| Skips to the index in ctx. Calls on_music_error with InvalidSkipIndex or SkipError. :param index: The index to skip to. :type index: int :param ctx: The context. :type ctx: commands.Context :return: A bool indicating if the skip was successful :rtype: Optional[Player] """ if not await self.__check_connection(ctx, True, check_queue=True): return # Created duplicate to make sure InvalidSkipIndex isn't raised when the user does pass an index and the queue # is empty. skip_index = 0 if index is None else index - 1 if not -1 < skip_index < len(self.queue[ctx.guild.id].queue): if index: await self.call_event('on_music_error', ctx, InvalidSkipIndex("Skip index invalid.")) return if len(self.queue[ctx.guild.id].queue) <= skip_index: await self.call_event('on_music_error', ctx, SkipError("No song to skip to.")) return if skip_index > 0: removed_songs = self.queue[ctx.guild.id].queue[:skip_index] self.queue[ctx.guild.id].queue = self.queue[ctx.guild.id].queue[skip_index:] if self.queue[ctx.guild.id].loop == Loops.QUEUE_LOOP: self.queue[ctx.guild.id].queue += removed_songs player = self.queue[ctx.guild.id].queue[0] ctx.voice_client.stop() return player
[docs] async def volume(self, ctx: commands.Context, volume: int = None) -> Optional[float]: """ |coro| Sets the volume in ctx. Returns the current volume if volume is None. :param volume: The volume to set. :type volume: int :param ctx: The context. :type ctx: commands.Context :return: The new volume. :rtype: Optional[float] """ if not await self.__check_connection(ctx, True, check_queue=True): return if volume is None: return ctx.voice_client.source.volume * 100 ctx.voice_client.source.volume = volume / 100 self.queue[ctx.guild.id].volume = volume / 100 return ctx.voice_client.source.volume * 100
[docs] async def join(self, ctx: commands.Context) -> Optional[discord.VoiceChannel]: """ |coro| Joins the ctx voice channel. Calls on_music_error with AlreadyConnected or UserNotConnected. :param ctx: The context. :type ctx: commands.Context :return: The voice channel it joined. :rtype: Optional[discord.VoiceChannel] """ if ctx.voice_client and ctx.voice_client.is_connected(): await self.call_event('on_music_error', ctx, AlreadyConnected("Client is already connected to a voice channel")) return if not ctx.author.voice: await self.call_event('on_music_error', ctx, UserNotConnected("User is not connected to a voice channel")) return channel = ctx.author.voice.channel await channel.connect() return channel
[docs] async def leave(self, ctx: commands.Context) -> Optional[discord.VoiceChannel]: """ |coro| Leaves the voice channel in ctx. :param ctx: The context. :type ctx: commands.Context :return: The voice channel it left. :rtype: Optional[discord.VoiceChannel] """ if not await self.__check_connection(ctx): return channel = ctx.voice_client.channel await ctx.voice_client.disconnect() return channel
[docs] async def now_playing(self, ctx: commands.Context) -> Optional[Player]: """ |coro| Returns the currently playing player. :param ctx: The context. :type ctx: commands.Context :return: The currently playing player. :rtype: Optional[Player] """ if not await self.__check_connection(ctx, check_queue=True): return now_playing = self.queue[ctx.guild.id].now_playing if not ctx.voice_client.is_playing() and not ctx.voice_client.is_paused(): await self.call_event('on_music_error', ctx, NotPlaying("Client is not playing anything currently")) return now_playing
[docs] async def queueloop(self, ctx: commands.Context) -> Optional[bool]: """ |coro| Toggles the queue loop. :param ctx: The context :type ctx: commands.Context :return: A bool indicating if the queue loop is now enabled or disabled. :rtype: Optional[bool] """ if not await self.__check_connection(ctx, check_playing=True, check_queue=True): return self.queue[ctx.guild.id].loop = Loops.QUEUE_LOOP if self.queue[ctx.guild.id].loop != Loops.QUEUE_LOOP else \ Loops.NO_LOOP if self.queue[ctx.guild.id].loop == Loops.QUEUE_LOOP: self.queue[ctx.guild.id].add(self.queue[ctx.guild.id].now_playing) return self.queue[ctx.guild.id].loop == Loops.QUEUE_LOOP
[docs] async def loop(self, ctx: commands.Context) -> Optional[bool]: """ |coro| Toggles the loop. :param ctx: The context :type ctx: commands.Context :return: A bool indicating if the loop is now enabled or disabled. :rtype: Optional[bool] """ if not await self.__check_connection(ctx, check_playing=True, check_queue=True): return self.queue[ctx.guild.id].loop = Loops.LOOP if self.queue[ctx.guild.id].loop != Loops.LOOP else Loops.NO_LOOP return self.queue[ctx.guild.id].loop == Loops.LOOP
[docs] async def get_queue(self, ctx: commands.Context) -> Optional[QueueManager]: """ |coro| Returns the queue of ctx. :param ctx: The context. :type ctx: commands.Context :return: The queue. :rtype: Optional[QueueManager] """ if not await self.__check_connection(ctx, check_queue=True): return return self.queue[ctx.guild.id]