From 68ac8180077a260b787f9cfdaea6340a8ff0d31a Mon Sep 17 00:00:00 2001 From: Devoxin <15076404+Devoxin@users.noreply.github.com> Date: Thu, 18 Aug 2022 11:50:13 +0100 Subject: [PATCH] 4.0.0 (#126) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix dispatching to empty hook lists * Bump version * Bump aiohttp * Update license stuff * Fix CI badge * Fix broken doc ref * support new lavalink exception format (#105) * Making a note of this issue * Bump aiohttp upper version * Bump Lavalink.py to 3.1.5 * Update dev to Lavalink.py 4.0 (#121) * Rename some errors, support Union[int, str] for user_id * Expose AuthenticationError * Ambiguous no more * Update get_tracks returns * Fix dispatching to empty hook lists * Bump aiohttp * Fix slight mistake in example cog. * Readme QoL change. * Use a link reference instead * Start on filter stuff * Update license stuff * Fix CI badge * Fix broken doc ref * Finishing up filter stuff maybe * Specify decode_errors when decoding author too * Exceptions -> Errors, more reliably pull WS close code, handle ConnectionResetError * Didn't mean to push this loool * Add utfm decoding capabilities * Fix equalizer failing to serialize * forgot an await, oops * Reorder player_manager funcs * Fix docs & expand parameter names * Update Lavalink URL * Fix Python 3.5 support not displayed in badge (#109) lol * Update example to use voice client (#116) * Update example to use voice client discord.py 2.0 removed the `on_socket_response` event. This resulted in Lavalink.py not being able to forward the events. At least in the current implementation of the example. We now use the preferred way of using the VoiceProtocol to forward the voice events to Lavalink. * Set player.channel_id to None manually * support new lavalink exception format (#105) * Bump min aiohttp version * Update copyright * who did this * Update license 2 * Making a note of this issue * Bump aiohttp upper version * Slight adjustments to strings * Expose previously unused variables 'position' and 'encoder_version' * Clarify some units in Stats.py * Logging message consistency * Remove superfluous log call in _node_disconnect * Logging consistency * Init node with empty stats object * scheisse * Redundant logic check as this will be zero anyway * int() guild_id in create, add doc link to DefaultPlayer * Doc consistency in playermanager.py * Document player.channel_id attr * More documentation updates * Finalise guild_id -> int change * Lavalink.py v4.0.0 * 0.0 is the default gain * Add timestamp_to_millis helper function. * Linting * Register auto docs for new things * Minor docs changes again * How much other stuff is missing??? * cmon * Fixing docs build error * Add filter limits, allow passing str to Player.X_filter, add filter TODOs * Fix docs formatting * Add missing list call * Avoid handling TrackStartEvent to prevent None being fired * Rotation filter * Low Pass filter * Add channelmix + lint * Add missing __ to init * Add filter command to example cog. * Lavalink appears to have exception handling for this... * Mark set_gain(s) & reset_equalizer as deprecated, add docs for remove_filter. * Enlarge deprecation warning * Move deprecation warning last to ensure func summary still works as intended * Distortion filter * Lint * New year new me xx * doc stuff * forgot to cd out of docs/ * update readme badge * Support volume filter * Enforce 0 < volume < 5 * Support connected in PlayerUpdateEvent * Linting * Small cleanups * new codacy badge * Implement DeferredAudioTrack * Linting * Imports & docs * Custom source support * Implement hash for source * Implement handling for info attribute access in AudioTrack * More docstrings, a little more consistency * Clarify docs. * Adjust player.add parameters, docs * Fix an issue with track needing to be declared, improve compatibility * Oversight * Implement two more docstrings in Client. * Custom Source provider example * If check to avoid returning bogus track on every query. * Enum documentation * missing , * self._raw in AudioTrack * slotssssssss * lets gloss over this ok * Copy-paste RIP * Fix typo in custom_source.py * Add player.destroy shortcut * Reluctantly support passing AudioTrack to AudioTrack * Don't overwrite requester * Remove AudioTrack construction from example cog * Access attributes directly in example cog * Filter descriptions. * an -> a * Support position + sourceName fields * Correctly reflect that track is Optional * Close any existing websockets before connecting * support volume + pause in play op * Fix volume not having an immediate effect * Add @lavalink.listener decorator support * Clarify listener decorator * forgot to cd out of docs again lol * Add player.update_filter, improve docs * Ensure provided filter is a class not instance * Instances passed to issubclass will throw, catch that. * subclass check in other x_filter commands * Add custom source to README * Updating readme * Reducing duplicated code in client * Fixing a slight oversight * Sorting out comment lines a little lol * Remove unused import from example/music.py * Experimental command-line tools * Support SSL on nodes * Use pythonic naming * Add clear_filters() * Only apply endTime if > 0 * On second thoughts, move endTime check AFTER sanity check Co-authored-by: AlexFlipnote Co-authored-by: sh0tx420 <55631215+sh0tx420@users.noreply.github.com> Co-authored-by: Eric Schneider <16943959+tailoric@users.noreply.github.com> Co-authored-by: Rob Wainwright <47543882+apex2504@users.noreply.github.com> * 3 attempts -> max_attempts_str * missing await * Attempt fix for already connected errors * Rearrange README and document custom sources * Link to Lavaplayer repo * Remove use of endpoint as guild.region is deprecated * Fixing literals * Fixing more literals * fix func reference * use voice_client for channel comparison * Make force an optional argument * Update VoiceProtocol for latest discord.py change (#123) * Implement TrackLoadFailedEvent for DAT errors * Add LoadError * Remove this * Single track looping * Experimental fix for double VU dispatching * Don't pop event, compare session IDs * Log event type received * More typings, implement repr on some models * Log the connection error if it's not handled * Switch to logginb.exception to automatically attach the exc * Refactor logging * Remove debugging line * VSC didn't save this one, either * Remove module name from log msg * Correctly label async methods as |coro| * Add plugin support (#124) * Add support for getting plugins * Doc for plugins * remove dataclass for py3.5 and return List[Plugin] * POST is not GET * changed the wrong line 👍👍👍 * repr for plugin * plugin str * cleanups * Move node-specific functions to Node.py * Fix typo in logging format * Expose Plugin in init * Support check_local in node.get_tracks * Update docs * Fix documentation for repeat & loop class attrs * Document DefaultPlayer.loop attr * Fix reconnect attempts logic * Allow returning of connect future * Node.destroy() + docs update * hello vsc save????????? * Allow enabling debug logging for specific submodules * pushed w/o linting? fatherless behaviour * should not be async * fix parameter description * Enforce minimum for lowpass * Enforce a minimum of zero instead * Actually, enforce no minimum to remain consistent with Lavalink server. * Should be fine now. * Remove async from get_filter * Update docs * Avoid logging non-existent player if event of specific type(s) * didn't end up using this lol * Improve documentation * Implement entry point for playing tracks with custom player implementations * Update play docstring * Move requester into AudioTrack.extra * Check start_time & end_time before setting self.current * Fix: return if no_replace to prevent self.current mismatch * Bug fixes in models.py (#125) * Fix bugs Corrects the requester and also fixes start_time and end_time * oops * Fixed bugs Corrected requester and also fixed start_time and end_time * Add node_unavailable() to BasePlayer, pause position clock during node unavailability * Remove misplaced abstractmethod decorator * (AudioTrack) avoid placing source in extra. * Correctly extract sourceName for AudioTracks Co-authored-by: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Co-authored-by: AlexFlipnote Co-authored-by: sh0tx420 <55631215+sh0tx420@users.noreply.github.com> Co-authored-by: Eric Schneider <16943959+tailoric@users.noreply.github.com> Co-authored-by: Danny Co-authored-by: cdwpx <107879563+cdwpx@users.noreply.github.com> --- LICENSE | 2 +- README.md | 35 +- docs/_static/style.css | 20 +- docs/conf.py | 4 +- docs/index.rst | 2 +- docs/lavalink.rst | 82 +++- docs/license.rst | 2 +- examples/custom_source.py | 45 ++ examples/music.py | 92 ++-- lavalink/__init__.py | 86 +++- lavalink/__main__.py | 123 ++++++ lavalink/client.py | 354 ++++++++------- lavalink/datarw.py | 54 ++- lavalink/errors.py | 39 ++ lavalink/events.py | 160 +++++-- lavalink/exceptions.py | 10 - lavalink/filters.py | 518 ++++++++++++++++++++++ lavalink/models.py | 906 ++++++++++++++++++++++++++++++++------ lavalink/node.py | 134 ++++-- lavalink/nodemanager.py | 90 +++- lavalink/playermanager.py | 156 ++++--- lavalink/stats.py | 82 +++- lavalink/utfm_codec.py | 71 +++ lavalink/utils.py | 92 +++- lavalink/websocket.py | 181 +++++--- run_tests.py | 6 +- setup.py | 7 +- 27 files changed, 2726 insertions(+), 627 deletions(-) create mode 100644 examples/custom_source.py create mode 100644 lavalink/__main__.py create mode 100644 lavalink/errors.py delete mode 100644 lavalink/exceptions.py create mode 100644 lavalink/filters.py create mode 100644 lavalink/utfm_codec.py diff --git a/LICENSE b/LICENSE index 11a204d5..fd917378 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Luke & William +Copyright (c) 2017-present Devoxin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 4e83fd08..970011a9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +[Lavalink]: https://github.com/freyacodes/Lavalink +[Lavaplayer]: https://github.com/sedmelluq/lavaplayer +[Documentation]: https://lavalink.readthedocs.io/en/master/ +[Latest Docs]: https://lavalink.readthedocs.io/en/latest/ + # Lavalink.py @@ -5,7 +10,17 @@ Lavalink.py is a wrapper for [Lavalink] which abstracts away most of the code necessary to use Lavalink, allowing for easier integration into your projects, while still promising full API coverage and powerful tools to get the most out of it. -Lavalink.py is a wrapper for [Lavalink](https://github.com/freyacodes/Lavalink) which abstracts away most of the code necessary to use Lavalink, allowing for easier integration into your projects, while still promising full API coverage and powerful tools to get the most out of it. +## Features +- Regions +- Multi-Node Support +- Load Balancing (this includes region-based load balancing) +- Audio Filters +- [Custom Sources](examples/custom_source.py) + + +# What is Lavalink? +Lavalink is standalone audio sending software capable of transmitting audio to Discord, utilising [Lavaplayer] for audio transcoding. It can be configured to work independently, or as part of a cluster depending on needs, which allows it to be highly scalable and performant. Head over to the [Lavalink] repository to find out more. + # Getting Started First you need to run a command to install the library, @@ -19,18 +34,9 @@ Then place an ``application.yml`` file in the same directory. The file should lo Additionally, there is an [example cog](examples). It should be noted that the example cog is oriented towards usage with Discord.py rewrite and Lavalink v3.1+, although backwards compatibility may be possible, it's not encouraged nor is support guaranteed. -## Features -- Regions -- Multi-Node Support -- Load Balancing (this includes region-based load balancing) -- Equalizer - -## Optional Dependencies -*These are used by aiohttp.* - -`aiodns` - Speed up DNS resolving. - -`cchardet` - A faster alternative to `chardet`. +## Custom Sources +As of Lavalink.py 4.0, custom sources can be registered to a client instance to allow searching more audio sources. These aren't "true" sources in the sense that you can play from them (unless you support HTTP playback and are able to retrieve a playable HTTP URL). +This means you can build sources that allow retrieving track metadata from third party services, such as Spotify, whilst the underlying stream is played from elsewhere. This is a popular method for providing support for otherwise unsupported services. You can find an [example source here](examples/custom_source.py). ## Supported Platforms While Lavalink.py supports any platform Python will run on, the same can not be said for the Lavalink server. @@ -40,8 +46,7 @@ It is highly recommended that you invest in a dedicated server or a [VPS](https: ### Exceptions The exception to the "unsupported platforms" rule are ARM-based machines, for example; a Raspberry Pi. While official Lavalink builds do not support the ARM architecture, there are [custom builds by Cog-Creators](https://github.com/Cog-Creators/Lavalink-Jars/releases) that offer ARM support. These are the official builds, with additional native libraries for running on otherwise unsupported platforms. - ## Need Further Help? [Discord Server](https://discord.gg/SbJXU9s) -[Documentation](https://lavalink.readthedocs.io/en/latest/) +[Documentation] or [Latest Docs] diff --git a/docs/_static/style.css b/docs/_static/style.css index b839b440..c2e17954 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -16,4 +16,22 @@ code { padding: 0px !important; -} \ No newline at end of file +} + +div.deprecated { + margin: 20px 0; + padding: 20px; + background-color: #fff; + border-radius: 3px; + color: #333; + overflow: auto; + border-width: 0 0 0 2px; + border-color: #ff5858; + border-style: solid; + padding-right: 0; +} + +span.deprecated { +display: block; +font-size: large; +} diff --git a/docs/conf.py b/docs/conf.py index e373d651..648f62f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,7 +19,7 @@ # -- Project information ----------------------------------------------------- project = 'Lavalink.py' -copyright = '2021, Devoxin' +copyright = '2022, Devoxin' author = 'Devoxin' master_doc = 'index' @@ -41,6 +41,7 @@ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'guzzle_sphinx_theme', + 'enum_tools.autoenum' ] rst_prolog = """ @@ -88,6 +89,7 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +html_css_files = ['style.css'] # html_context = { # 'css_files': ['_static/style.css'] diff --git a/docs/index.rst b/docs/index.rst index c35d1d23..611a1bd3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -24,7 +24,7 @@ Welcome to Lavalink.py's documentation! - Regions - Multi-Node Support - Load Balancing (this includes region-based load balancing) - - Equalizer + - Audio Filters **Support:** diff --git a/docs/lavalink.rst b/docs/lavalink.rst index 9191b701..0254641a 100644 --- a/docs/lavalink.rst +++ b/docs/lavalink.rst @@ -5,6 +5,8 @@ Documentation .. autofunction:: enable_debug_logging +.. autofunction:: listener + .. autofunction:: add_event_hook Client @@ -12,6 +14,14 @@ Client .. autoclass:: Client :members: +Errors +------ +.. autoclass:: NodeError + +.. autoclass:: AuthenticationError + +.. autoclass:: InvalidTrack + Events ------ All Events are derived from :class:`Event` @@ -22,30 +32,70 @@ All Events are derived from :class:`Event` .. autoclass:: TrackStartEvent :members: -.. autoclass:: TrackEndEvent - :members: - .. autoclass:: TrackStuckEvent :members: .. autoclass:: TrackExceptionEvent :members: +.. autoclass:: TrackEndEvent + :members: + +.. autoclass:: TrackLoadFailedEvent + :members: + .. autoclass:: QueueEndEvent :members: -.. autoclass:: NodeConnectedEvent +.. autoclass:: PlayerUpdateEvent :members: -.. autoclass:: NodeChangedEvent +.. autoclass:: NodeConnectedEvent :members: .. autoclass:: NodeDisconnectedEvent :members: +.. autoclass:: NodeChangedEvent + :members: + .. autoclass:: WebSocketClosedEvent :members: +Filters +------- +**All** custom filters must derive from :class:`Filter` + +.. autoclass:: Filter + :members: + +.. autoclass:: Equalizer + :members: + +.. autoclass:: Karaoke + :members: + +.. autoclass:: Timescale + :members: + +.. autoclass:: Tremolo + :members: + +.. autoclass:: Vibrato + :members: + +.. autoclass:: Rotation + :members: + +.. autoclass:: LowPass + :members: + +.. autoclass:: ChannelMix + :members: + +.. autoclass:: Volume + :members: + Models ------ **All** custom players must derive from :class:`BasePlayer` @@ -53,12 +103,30 @@ Models .. autoclass:: AudioTrack :members: +.. autoclass:: DeferredAudioTrack + :members: + +.. autoenum:: LoadType + :members: + +.. autoclass:: PlaylistInfo + :members: + +.. autoclass:: LoadResult + :members: + +.. autoclass:: Source + :members: + .. autoclass:: BasePlayer :members: .. autoclass:: DefaultPlayer :members: +.. autoclass:: Plugin + :members: + Node ---- .. autoclass:: Node @@ -84,8 +152,10 @@ Stats Utilities --------- +.. autofunction:: timestamp_to_millis + .. autofunction:: format_time .. autofunction:: parse_time -.. autofunction:: decode_track \ No newline at end of file +.. autofunction:: decode_track diff --git a/docs/license.rst b/docs/license.rst index e149ad14..cff6314b 100644 --- a/docs/license.rst +++ b/docs/license.rst @@ -4,7 +4,7 @@ Licensed using the `MIT license `_. MIT License - Copyright (c) 2021 Luke & William + Copyright (c) 2022 Devoxin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/examples/custom_source.py b/examples/custom_source.py new file mode 100644 index 00000000..5fab812d --- /dev/null +++ b/examples/custom_source.py @@ -0,0 +1,45 @@ +from lavalink.models import (DeferredAudioTrack, LoadResult, LoadType, + PlaylistInfo, Source) + + +class LoadError(Exception): # We'll raise this if we have trouble loading our track. + pass + + +class CustomAudioTrack(DeferredAudioTrack): + # A DeferredAudioTrack allows us to load metadata now, and a playback URL later. + # This makes the DeferredAudioTrack highly efficient, particularly in cases + # where large playlists are loaded. + + async def load(self, client): # Load our 'actual' playback track using the metadata from this one. + result: LoadResult = await client.get_tracks('ytsearch:{0.title} {0.author}'.format(self)) # Search for our track on YouTube. + + if result.load_type != LoadType.SEARCH or not result.tracks: # We're expecting a 'SEARCH' due to our 'ytsearch' prefix above. + raise LoadError + + first_track = result.tracks[0] # Grab the first track from the results. + base64 = first_track.track # Extract the base64 string from the track. + self.track = base64 # We'll store this for later, as it allows us to save making network requests + # if this track is re-used (e.g. repeat). + + return base64 + + +class CustomSource(Source): + def __init__(self): + super().__init__(name='custom') # Initialising our custom source with the name 'custom'. + + async def load_item(self, client, query: str): + if 'keyword' in query: + # track_metadata = http.get("https://our.provider/api/{}".format(query)) + + track = CustomAudioTrack({ # Create an instance of our CustomAudioTrack. + 'identifier': '27cgqh0VRhVeM61ugTnorD', # Fill it with metadata that we've obtained from our source's provider. + 'isSeekable': True, + 'author': 'DJ Seinfeld', + 'length': 296000, + 'isStream': False, + 'title': 'These Things Will Come To Be', + 'uri': 'https://open.spotify.com/track/27cgqh0VRhVeM61ugTnorD' + }, requester=0) # Init requester with a default value. + return LoadResult(LoadType.TRACK, [track], playlist_info=PlaylistInfo.none()) diff --git a/examples/music.py b/examples/music.py index 6c7755ec..185481cb 100644 --- a/examples/music.py +++ b/examples/music.py @@ -11,6 +11,7 @@ import discord import lavalink from discord.ext import commands +from lavalink.filters import LowPass url_rx = re.compile(r'https?://(?:www\.)?.+') @@ -26,35 +27,36 @@ class LavalinkVoiceClient(discord.VoiceClient): def __init__(self, client: discord.Client, channel: discord.abc.Connectable): self.client = client self.channel = channel - # ensure there exists a client already + # ensure a client already exists if hasattr(self.client, 'lavalink'): self.lavalink = self.client.lavalink else: self.client.lavalink = lavalink.Client(client.user.id) self.client.lavalink.add_node( - 'localhost', - 2333, - 'youshallnotpass', - 'us', - 'default-node') + 'localhost', + 2333, + 'youshallnotpass', + 'us', + 'default-node' + ) self.lavalink = self.client.lavalink async def on_voice_server_update(self, data): # the data needs to be transformed before being handed down to # voice_update_handler lavalink_data = { - 't': 'VOICE_SERVER_UPDATE', - 'd': data - } + 't': 'VOICE_SERVER_UPDATE', + 'd': data + } await self.lavalink.voice_update_handler(lavalink_data) async def on_voice_state_update(self, data): # the data needs to be transformed before being handed down to # voice_update_handler lavalink_data = { - 't': 'VOICE_STATE_UPDATE', - 'd': data - } + 't': 'VOICE_STATE_UPDATE', + 'd': data + } await self.lavalink.voice_update_handler(lavalink_data) async def connect(self, *, timeout: float, reconnect: bool, self_deaf: bool = False, self_mute: bool = False) -> None: @@ -81,9 +83,8 @@ async def disconnect(self, *, force: bool = False) -> None: await self.channel.guild.change_voice_state(channel=None) # update the channel_id of the player to None - # this must be done because the on_voice_state_update that - # would set channel_id to None doesn't get dispatched after the - # disconnect + # this must be done because the on_voice_state_update that would set channel_id + # to None doesn't get dispatched after the disconnect player.channel_id = None self.cleanup() @@ -124,7 +125,7 @@ async def cog_command_error(self, ctx, error): async def ensure_voice(self, ctx): """ This check ensures that the bot and command author are in the same voicechannel. """ - player = self.bot.lavalink.player_manager.create(ctx.guild.id, endpoint=str(ctx.guild.region)) + player = self.bot.lavalink.player_manager.create(ctx.guild.id) # Create returns a player if one exists, otherwise creates. # This line is important because it ensures that a player always exists for a guild. @@ -141,7 +142,8 @@ async def ensure_voice(self, ctx): # execution state of the command goes no further. raise commands.CommandInvokeError('Join a voicechannel first.') - if not player.is_connected: + v_client = ctx.voice_client + if not v_client: if not should_connect: raise commands.CommandInvokeError('Not connected.') @@ -153,7 +155,7 @@ async def ensure_voice(self, ctx): player.store('channel', ctx.channel.id) await ctx.author.voice.channel.connect(cls=LavalinkVoiceClient) else: - if int(player.channel_id) != ctx.author.voice.channel.id: + if v_client.channel.id != ctx.author.voice.channel.id: raise commands.CommandInvokeError('You need to be in my voicechannel.') async def track_hook(self, event): @@ -161,7 +163,7 @@ async def track_hook(self, event): # When this track_hook receives a "QueueEndEvent" from lavalink.py # it indicates that there are no tracks left in the player's queue. # To save on resources, we can tell the bot to disconnect from the voicechannel. - guild_id = int(event.player.guild_id) + guild_id = event.player.guild_id guild = self.bot.get_guild(guild_id) await guild.voice_client.disconnect(force=True) @@ -182,8 +184,8 @@ async def play(self, ctx, *, query: str): results = await player.node.get_tracks(query) # Results could be None if Lavalink returns an invalid response (non-JSON/non-200 (OK)). - # ALternatively, resullts['tracks'] could be an empty array if the query yielded no tracks. - if not results or not results['tracks']: + # ALternatively, resullts.tracks could be an empty array if the query yielded no tracks. + if not results or not results.tracks: return await ctx.send('Nothing found!') embed = discord.Embed(color=discord.Color.blurple()) @@ -194,23 +196,20 @@ async def play(self, ctx, *, query: str): # SEARCH_RESULT - query prefixed with either ytsearch: or scsearch:. # NO_MATCHES - query yielded no results # LOAD_FAILED - most likely, the video encountered an exception during loading. - if results['loadType'] == 'PLAYLIST_LOADED': - tracks = results['tracks'] + if results.load_type == 'PLAYLIST_LOADED': + tracks = results.tracks for track in tracks: # Add all of the tracks from the playlist to the queue. player.add(requester=ctx.author.id, track=track) embed.title = 'Playlist Enqueued!' - embed.description = f'{results["playlistInfo"]["name"]} - {len(tracks)} tracks' + embed.description = f'{results.playlist_info.name} - {len(tracks)} tracks' else: - track = results['tracks'][0] + track = results.tracks[0] embed.title = 'Track Enqueued' - embed.description = f'[{track["info"]["title"]}]({track["info"]["uri"]})' + embed.description = f'[{track.title}]({track.uri})' - # You can attach additional information to audiotracks through kwargs, however this involves - # constructing the AudioTrack class yourself. - track = lavalink.models.AudioTrack(track, ctx.author.id, recommended=True) player.add(requester=ctx.author.id, track=track) await ctx.send(embed=embed) @@ -220,12 +219,45 @@ async def play(self, ctx, *, query: str): if not player.is_playing: await player.play() + @commands.command(aliases=['lp']) + async def lowpass(self, ctx, strength: float): + """ Sets the strength of the low pass filter. """ + # Get the player for this guild from cache. + player = self.bot.lavalink.player_manager.get(ctx.guild.id) + + # This enforces that strength should be a minimum of 0. + # There's no upper limit on this filter. + strength = max(0.0, strength) + + # Even though there's no upper limit, we will enforce one anyway to prevent + # extreme values from being entered. This will enforce a maximum of 100. + strength = min(100, strength) + + embed = discord.Embed(color=discord.Color.blurple(), title='Low Pass Filter') + + # A strength of 0 effectively means this filter won't function, so we can disable it. + if strength == 0.0: + player.remove_filter('lowpass') + embed.description = 'Disabled **Low Pass Filter**' + return await ctx.send(embed=embed) + + # Lets create our filter. + low_pass = LowPass() + low_pass.update(smoothing=strength) # Set the filter strength to the user's desired level. + + # This applies our filter. If the filter is already enabled on the player, then this will + # just overwrite the filter with the new values. + await player.set_filter(low_pass) + + embed.description = f'Set **Low Pass Filter** strength to {strength}.' + await ctx.send(embed=embed) + @commands.command(aliases=['dc']) async def disconnect(self, ctx): """ Disconnects the player from the voice channel and clears its queue. """ player = self.bot.lavalink.player_manager.get(ctx.guild.id) - if not player.is_connected: + if not ctx.voice_client: # We can't disconnect, if we're not connected. return await ctx.send('Not connected.') diff --git a/lavalink/__init__.py b/lavalink/__init__.py index d1f063bd..b9cc981c 100644 --- a/lavalink/__init__.py +++ b/lavalink/__init__.py @@ -3,58 +3,112 @@ __title__ = 'Lavalink' __author__ = 'Devoxin' __license__ = 'MIT' -__copyright__ = 'Copyright 2017-2022 Devoxin' -__version__ = '3.1.7' +__copyright__ = 'Copyright 2017-present Devoxin' +__version__ = '4.0.0' -import logging import inspect +import logging import sys -from .events import Event, TrackStartEvent, TrackStuckEvent, TrackExceptionEvent, TrackEndEvent, QueueEndEvent, \ - NodeConnectedEvent, NodeChangedEvent, NodeDisconnectedEvent, WebSocketClosedEvent -from .models import BasePlayer, DefaultPlayer, AudioTrack -from .utils import format_time, parse_time, decode_track from .client import Client -from .playermanager import PlayerManager -from .exceptions import NodeException, InvalidTrack +from .errors import AuthenticationError, InvalidTrack, LoadError, NodeError +from .events import (Event, NodeChangedEvent, NodeConnectedEvent, + NodeDisconnectedEvent, PlayerUpdateEvent, QueueEndEvent, + TrackEndEvent, TrackExceptionEvent, TrackLoadFailedEvent, + TrackStartEvent, TrackStuckEvent, WebSocketClosedEvent) +from .filters import (ChannelMix, Equalizer, Filter, Karaoke, LowPass, + Rotation, Timescale, Tremolo, Vibrato, Volume) +from .models import (AudioTrack, BasePlayer, DefaultPlayer, DeferredAudioTrack, + LoadResult, LoadType, PlaylistInfo, Plugin, Source) +from .node import Node from .nodemanager import NodeManager +from .playermanager import PlayerManager from .stats import Penalty, Stats +from .utils import decode_track, format_time, parse_time, timestamp_to_millis from .websocket import WebSocket -from .node import Node -def enable_debug_logging(): +def enable_debug_logging(submodule: str = None): """ Sets up a logger to stdout. This solely exists to make things easier for end-users who want to debug issues with Lavalink.py. + + Parameters + ---------- + module: :class:`str` + The module to enable logging for. ``None`` to enable debug logging for + the entirety of Lavalink.py. + + Example: ``lavalink.enable_debug_logging('websocket')`` """ - log = logging.getLogger('lavalink') + module_name = 'lavalink.{}'.format(submodule) if submodule else 'lavalink' + log = logging.getLogger(module_name) fmt = logging.Formatter( - '[%(asctime)s] [lavalink.py] [%(levelname)s] %(message)s', + '[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', # lavalink.py datefmt="%H:%M:%S" ) handler = logging.StreamHandler(sys.stdout) handler.setFormatter(fmt) log.addHandler(handler) - log.setLevel(logging.DEBUG) +def listener(*events: Event): + """ + Marks this function as an event listener for Lavalink.py. + This **must** be used on class methods, and you must ensure that you register + decorated methods by using :func:`Client.add_event_hooks`. + + Example: + + .. code:: python + + @listener() + async def on_lavalink_event(self, event): # Event can be ANY Lavalink event + ... + + @listener(TrackStartEvent) + async def on_track_start(self, event: TrackStartEvent): + ... + + Note + ---- + Track event dispatch order is not guaranteed! + For example, this means you could receive a :class:`TrackStartEvent` before you receive a + :class:`TrackEndEvent` when executing operations such as ``skip()``. + + Parameters + ---------- + events: List[:class:`Event`] + The events to listen for. Leave this empty to listen for all events. + """ + def wrapper(func): + setattr(func, '_lavalink_events', events) + return func + return wrapper + + def add_event_hook(*hooks, event: Event = None): """ Adds an event hook to be dispatched on an event. + Note + ---- + Track event dispatch order is not guaranteed! + For example, this means you could receive a :class:`TrackStartEvent` before you receive a + :class:`TrackEndEvent` when executing operations such as ``skip()``. + Parameters ---------- hooks: :class:`function` The hooks to register for the given event type. - If `event` parameter is left empty, then it will run when any event is dispatched. + If ``event`` parameter is left empty, then it will run when any event is dispatched. event: :class:`Event` The event the hook belongs to. This will dispatch when that specific event is - dispatched. Defaults to `None` which means the hook is dispatched on all events. + dispatched. Defaults to ``None`` which means the hook is dispatched on all events. """ if event is not None and Event not in event.__bases__: raise TypeError('Event parameter is not of type Event or None') diff --git a/lavalink/__main__.py b/lavalink/__main__.py new file mode 100644 index 00000000..179f0f2d --- /dev/null +++ b/lavalink/__main__.py @@ -0,0 +1,123 @@ +import os +import re +import sys +from subprocess import PIPE, Popen + +import requests + +LAVALINK_BASE_URL = 'https://ci.fredboat.com/repository/download/Lavalink_Build/.lastSuccessful/Lavalink.jar?guest=1&branch=refs/heads/{}' +APPLICATION_BASE_URL = 'https://raw.githubusercontent.com/freyacodes/Lavalink/{}/LavalinkServer/application.yml.example' + + +def display_help(): + print(""" +download - Downloads the latest (stable) Lavalink jar. + --fetch-dev Fetches the latest Lavalink development jar. + --no-overwrite Renames an existing lavalink.jar to lavalink.old.jar +config - Downloads a fresh application.yml. + --fetch-dev Fetches the latest application.yml from the development branch. + --no-overwrite Renames an existing application.yml to application.old.yml. +info - Extracts version and build information from an existing Lavalink.jar. + """.strip()) + + +def download(dl_url, path): + res = requests.get(dl_url, stream=True) + + def report_progress(cur, tot): + bar_len = 32 + progress = float(cur) / tot + filled_len = int(round(bar_len * progress)) + percent = round(progress * 100, 2) + + progress_bar = '█' * filled_len + ' ' * (bar_len - filled_len) + sys.stdout.write('Downloading |%s| %0.2f%% (%d/%d)\r' % (progress_bar, percent, cur, tot)) + sys.stdout.flush() + + if cur >= tot: + sys.stdout.write('\n') + + def read_chunk(f, chunk_size=8192): + total_bytes = int(res.headers['Content-Length'].strip()) + current_bytes = 0 + + for chunk in res.iter_content(chunk_size): + f.write(chunk) + current_bytes += len(chunk) + report_progress(min(current_bytes, total_bytes), total_bytes) + + with open(path, 'wb') as f: + read_chunk(f) + + +def main(): # pylint: disable=too-many-locals,too-many-statements + if len(sys.argv) < 2 or sys.argv[1] == '--help' or sys.argv[1] == 'help' or sys.argv[1] == '?': + display_help() + return + + cwd = os.getcwd() + _, action, *arguments = sys.argv + + if action == 'download': + target_branch = 'dev' if '--fetch-dev' in arguments else 'master' + dl_url = LAVALINK_BASE_URL.format(target_branch) + dl_path = os.path.join(cwd, 'lavalink.jar') + + if '--no-overwrite' in arguments and os.path.exists(dl_path): + os.rename(dl_path, os.path.join(cwd, 'lavalink.old.jar')) + + download(dl_url, dl_path) + print('Downloaded to {}'.format(dl_path)) + sys.exit(0) + elif action == 'config': + target_branch = 'dev' if '--fetch-dev' in arguments else 'master' + dl_url = APPLICATION_BASE_URL.format(target_branch) + dl_path = os.path.join(cwd, 'application.yml') + + if '--no-overwrite' in arguments and os.path.exists(dl_path): + os.rename(dl_path, os.path.join(cwd, 'application.old.yml')) + + download(dl_url, dl_path) + print('Downloaded to {}'.format(dl_path)) + sys.exit(0) + elif action == 'info': + check_names = ['lavalink.jar', 'Lavalink.jar', 'LAVALINK.JAR'] + + if arguments: + check_names.extend([arguments[0]]) + + file_name = next((fn for fn in check_names if os.path.exists(fn)), None) + + if not file_name: + print('Unable to display Lavalink server info: No Lavalink file found.') + sys.exit(1) + + proc = Popen(['java', '-jar', file_name, '--version'], stdout=PIPE, stderr=PIPE, text=True) + stdout, stderr = proc.communicate() + + if stderr: + if 'UnsupportedClassVersionError' in stderr: + java_proc = Popen(['java', '-version'], stdout=PIPE, stderr=PIPE, text=True) + j_stdout, j_stderr = java_proc.communicate() + j_ver = re.search(r'java version "([\d._]*)"', j_stdout or j_stderr) + java_version = j_ver.group(1) if j_ver else 'UNKNOWN' + + if java_version.startswith('1.8'): + java_version = f'8/{java_version}' + + print('Unable to display Lavalink server info.\nYour Java version is out of date. (Java {})\n\n' + 'Java 11+ is required to run Lavalink.'.format(java_version)) + sys.exit(1) + + print(stderr) + sys.exit(1) + else: + print(stdout.strip()) + sys.exit(0) + else: + print('Invalid argument \'{}\'. Use --help to show usage.'.format(action)) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/lavalink/client.py b/lavalink/client.py index 504ef00a..9e303864 100644 --- a/lavalink/client.py +++ b/lavalink/client.py @@ -1,29 +1,54 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import asyncio import itertools import logging import random from collections import defaultdict +from inspect import getmembers, ismethod +from typing import Set, Union from urllib.parse import quote import aiohttp +from .errors import AuthenticationError, NodeError from .events import Event -from .exceptions import NodeException, Unauthorized -from .models import DefaultPlayer +from .models import DefaultPlayer, LoadResult, Source from .node import Node from .nodemanager import NodeManager from .playermanager import PlayerManager +_log = logging.getLogger(__name__) + class Client: """ Represents a Lavalink client used to manage nodes and connections. - .. _event loop: https://docs.python.org/3/library/asyncio-eventloop.html - Parameters ---------- - user_id: :class:`int` + user_id: Union[:class:`int`, :class:`str`] The user id of the bot. player: Optional[:class:`BasePlayer`] The class that should be used for the player. Defaults to ``DefaultPlayer``. @@ -31,18 +56,18 @@ class Client: regions: Optional[:class:`dict`] A dictionary representing region -> discord endpoint. You should only change this if you know what you're doing and want more control over - which regions handle specific locations. Defaults to `None`. + which regions handle specific locations. Defaults to ``None``. connect_back: Optional[:class:`bool`] A boolean that determines if a player will connect back to the node it was originally connected to. This is not recommended to do since - the player will most likely be performing better in the new node. Defaults to `False`. + the player will most likely be performing better in the new node. Defaults to ``False``. Warning ------- If this option is enabled and the player's node is changed through `Player.change_node` after the player was moved via the failover mechanism, the player will still move back to the original node when it becomes available. This behaviour can be avoided in custom player implementations by - setting `self._original_node` to `None` in the `change_node` function. + setting ``self._original_node`` to ``None`` in the :func:`BasePlayer.change_node` function. Attributes ---------- @@ -53,31 +78,95 @@ class Client: """ _event_hooks = defaultdict(list) - def __init__(self, user_id: int, player=DefaultPlayer, regions: dict = None, + def __init__(self, user_id: Union[int, str], player=DefaultPlayer, regions: dict = None, connect_back: bool = False): - if not isinstance(user_id, int): - raise TypeError('user_id must be an int (got {}). If the type is None, ' + if not isinstance(user_id, (str, int)) or isinstance(user_id, bool): + # bool has special handling because it subclasses `int`, so will return True for the first isinstance check. + raise TypeError('user_id must be either an int or str (not {}). If the type is None, ' 'ensure your bot has fired "on_ready" before instantiating ' 'the Lavalink client. Alternatively, you can hardcode your user ID.' .format(user_id)) - self._user_id = str(user_id) - self.node_manager = NodeManager(self, regions) - self.player_manager = PlayerManager(self, player) - self._connect_back = connect_back - self._logger = logging.getLogger('lavalink') - - self._session = aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=30) - ) + self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) + self._user_id: str = str(user_id) + self._connect_back: bool = connect_back + self.node_manager: NodeManager = NodeManager(self, regions) + self.player_manager: PlayerManager = PlayerManager(self, player) + self.sources: Set[Source] = set() def add_event_hook(self, hook): + """ + Registers a function to recieve and process Lavalink events. + + Note + ---- + Track event dispatch order is not guaranteed! + For example, this means you could receive a :class:`TrackStartEvent` before you receive a + :class:`TrackEndEvent` when executing operations such as ``skip()``. + + Parameters + ---------- + hook: :class:`function` + The function to register. + """ if hook not in self._event_hooks['Generic']: self._event_hooks['Generic'].append(hook) + def add_event_hooks(self, cls): + """ + Scans the provided class ``cls`` for functions decorated with :func:`listener`, + and sets them up to process Lavalink events. + + Example: + + .. code:: python + + # Inside a class __init__ method + self.client = lavalink.Client(...) + self.client.add_event_hooks(self) + + Note + ---- + Track event dispatch order is not guaranteed! + For example, this means you could receive a :class:`TrackStartEvent` before you receive a + :class:`TrackEndEvent` when executing operations such as ``skip()``. + + Parameters + ---------- + cls: :class:`Class` + An instance of a class. + """ + methods = getmembers(cls, predicate=lambda meth: hasattr(meth, '__name__') + and not meth.__name__.startswith('_') and ismethod(meth) + and hasattr(meth, '_lavalink_events')) + + for _, listener in methods: # _ = meth_name + # wrapped = partial(listener, cls) + events = listener._lavalink_events + + if events: + for event in events: + self._event_hooks[event.__name__].append(listener) + else: + self._event_hooks['Generic'].append(listener) + + def register_source(self, source: Source): + """ + Registers a :class:`Source` that Lavalink.py will use for looking up tracks. + + Parameters + ---------- + source: :class:`Source` + The source to register. + """ + if not isinstance(source, Source): + raise TypeError('source must inherit from Source!') + + self.sources.add(source) + def add_node(self, host: str, port: int, password: str, region: str, resume_key: str = None, resume_timeout: int = 60, name: str = None, - reconnect_attempts: int = 3): + reconnect_attempts: int = 3, filters: bool = True, ssl: bool = False): """ Adds a node to Lavalink's node manager. @@ -93,21 +182,38 @@ def add_node(self, host: str, port: int, password: str, region: str, The region to assign this node to. resume_key: Optional[:class:`str`] A resume key used for resuming a session upon re-establishing a WebSocket connection to Lavalink. - Defaults to `None`. + Defaults to ``None``. resume_timeout: Optional[:class:`int`] How long the node should wait for a connection while disconnected before clearing all players. - Defaults to `60`. + Defaults to ``60``. name: Optional[:class:`str`] - An identifier for the node that will show in logs. Defaults to `None` + An identifier for the node that will show in logs. Defaults to ``None``. reconnect_attempts: Optional[:class:`int`] The amount of times connection with the node will be reattempted before giving up. - Set to `-1` for infinite. Defaults to `3`. + Set to `-1` for infinite. Defaults to ``3``. + filters: Optional[:class:`bool`] + Whether to use the new ``filters`` op instead of the ``equalizer`` op. + If you're running a build without filter support, set this to ``False``. + ssl: Optional[:class:`bool`] + Whether to use SSL for the node. SSL will use ``wss`` and ``https``, instead of ``ws`` and ``http``, + respectively. Your node should support SSL if you intend to enable this, either via reverse proxy or + other methods. Only enable this if you know what you're doing. """ - self.node_manager.add_node(host, port, password, region, resume_key, resume_timeout, name, reconnect_attempts) + self.node_manager.add_node(host, port, password, region, resume_key, resume_timeout, name, reconnect_attempts, + filters, ssl) - async def get_tracks(self, query: str, node: Node = None): + async def get_tracks(self, query: str, node: Node = None, check_local: bool = False) -> LoadResult: """|coro| - Gets all tracks associated with the given query. + + Retrieves a list of results pertaining to the provided query. + + If ``check_local`` is set to ``True`` and any of the sources return a :class:`LoadResult` + then that result will be returned, and Lavalink will not be queried. + + Warning + ------- + Avoid setting ``check_local`` to ``True`` if you call this method from a custom :class:`Source` to avoid + recursion issues! Parameters ---------- @@ -115,40 +221,39 @@ async def get_tracks(self, query: str, node: Node = None): The query to perform a search for. node: Optional[:class:`Node`] The node to use for track lookup. Leave this blank to use a random node. - Defaults to `None` which is a random node. + Defaults to ``None`` which is a random node. + check_local: :class:`bool` + Whether to also search the query on sources registered with this Lavalink client. Returns ------- - :class:`dict` - A dict representing tracks. + :class:`LoadResult` """ - if not self.node_manager.available_nodes: - raise NodeException('No available nodes!') - node = node or random.choice(self.node_manager.available_nodes) - destination = 'http://{}:{}/loadtracks?identifier={}'.format(node.host, node.port, quote(query)) - headers = { - 'Authorization': node.password - } + if check_local: + for source in self.sources: + load_result = await source.load_item(self, query) - async with self._session.get(destination, headers=headers) as res: - if res.status == 200: - return await res.json() + if load_result: + return load_result - if res.status == 401 or res.status == 403: - raise Unauthorized - - return [] + if not self.node_manager.available_nodes: + raise NodeError('No available nodes!') + node = node or random.choice(self.node_manager.available_nodes) + res = await self._get_request('{}/loadtracks?identifier={}'.format(node.http_uri, quote(query)), + headers={'Authorization': node.password}) + return LoadResult.from_dict(res) async def decode_track(self, track: str, node: Node = None): """|coro| + Decodes a base64-encoded track string into a dict. Parameters ---------- track: :class:`str` - The base64-encoded `track` string. + The base64-encoded ``track`` string. node: Optional[:class:`Node`] - The node to use for the query. Defaults to `None` which is a random node. + The node to use for the query. Defaults to ``None`` which is a random node. Returns ------- @@ -156,32 +261,22 @@ async def decode_track(self, track: str, node: Node = None): A dict representing the track's information. """ if not self.node_manager.available_nodes: - raise NodeException('No available nodes!') + raise NodeError('No available nodes!') node = node or random.choice(self.node_manager.available_nodes) - destination = 'http://{}:{}/decodetrack?track={}'.format(node.host, node.port, track) - headers = { - 'Authorization': node.password - } - - async with self._session.get(destination, headers=headers) as res: - if res.status == 200: - return await res.json() - - if res.status == 401 or res.status == 403: - raise Unauthorized - - return None + return await self._get_request('{}/decodetrack?track={}'.format(node.http_uri, track), + headers={'Authorization': node.password}) async def decode_tracks(self, tracks: list, node: Node = None): """|coro| + Decodes a list of base64-encoded track strings into a dict. Parameters ---------- - tracks: list[:class:`str`] - A list of base64-encoded `track` strings. + tracks: List[:class:`str`] + A list of base64-encoded ``track`` strings. node: Optional[:class:`Node`] - The node to use for the query. Defaults to `None` which is a random node. + The node to use for the query. Defaults to ``None`` which is a random node. Returns ------- @@ -189,98 +284,15 @@ async def decode_tracks(self, tracks: list, node: Node = None): A list of dicts representing track information. """ if not self.node_manager.available_nodes: - raise NodeException('No available nodes!') + raise NodeError('No available nodes!') node = node or random.choice(self.node_manager.available_nodes) - destination = 'http://{}:{}/decodetracks'.format(node.host, node.port) - headers = { - 'Authorization': node.password - } - - async with self._session.post(destination, headers=headers, json=tracks) as res: - if res.status == 200: - return await res.json() - - if res.status == 401 or res.status == 403: - raise Unauthorized - return None - - async def routeplanner_status(self, node: Node): - """|coro| - Gets the routeplanner status of the target node. - - Parameters - ---------- - node: :class:`Node` - The node to use for the query. - - Returns - ------- - :class:`dict` - A dict representing the routeplanner information. - """ - destination = 'http://{}:{}/routeplanner/status'.format(node.host, node.port) - headers = { - 'Authorization': node.password - } - - async with self._session.get(destination, headers=headers) as res: - if res.status == 200: - return await res.json() - - if res.status == 401 or res.status == 403: - raise Unauthorized - - return None - - async def routeplanner_free_address(self, node: Node, address: str): - """|coro| - Gets the routeplanner status of the target node. - - Parameters - ---------- - node: :class:`Node` - The node to use for the query. - address: :class:`str` - The address to free. - - Returns - ------- - :class:`bool` - True if the address was freed, False otherwise. - """ - destination = 'http://{}:{}/routeplanner/free/address'.format(node.host, node.port) - headers = { - 'Authorization': node.password - } - - async with self._session.post(destination, headers=headers, json={'address': address}) as res: - return res.status == 204 - - async def routeplanner_free_all_failing(self, node: Node): - """|coro| - Gets the routeplanner status of the target node. - - Parameters - ---------- - node: :class:`Node` - The node to use for the query. - - Returns - ------- - :class:`bool` - True if all failing addresses were freed, False otherwise. - """ - destination = 'http://{}:{}/routeplanner/free/all'.format(node.host, node.port) - headers = { - 'Authorization': node.password - } - - async with self._session.post(destination, headers=headers) as res: - return res.status == 204 + return await self._post_request('{}/decodetracks'.format(node.http_uri), + headers={'Authorization': node.password}, json=tracks) async def voice_update_handler(self, data): """|coro| + This function intercepts websocket data from your Discord library and forwards the relevant information on to Lavalink, which is used to establish a websocket connection and send audio packets to Discord. @@ -315,8 +327,34 @@ async def voice_update_handler(self, data): if player: await player._voice_state_update(data['d']) + async def _get_request(self, url, **kwargs): + async with self._session.get(url, **kwargs) as res: + if res.status == 401 or res.status == 403: + raise AuthenticationError + + if res.status == 200: + return await res.json() + + raise NodeError('An invalid response was received from the node: code={}, body={}' + .format(res.status, await res.text())) + + async def _post_request(self, url, **kwargs): + async with self._session.post(url, **kwargs) as res: + if res.status == 401 or res.status == 403: + raise AuthenticationError + + if 'json' in kwargs: + if res.status == 200: + return await res.json() + + raise NodeError('An invalid response was received from the node: code={}, body={}' + .format(res.status, await res.text())) + + return res.status == 204 + async def _dispatch_event(self, event: Event): """|coro| + Dispatches the given event to all registered hooks. Parameters @@ -334,7 +372,7 @@ async def _hook_wrapper(hook, event): try: await hook(event) except: # noqa: E722 pylint: disable=bare-except - self._logger.exception('Event hook {} encountered an exception!'.format(hook.__name__)) + _log.exception('Event hook \'%s\' encountered an exception!', hook.__name__) # According to https://stackoverflow.com/questions/5191830/how-do-i-log-a-python-error-with-debug-information # the exception information should automatically be attached here. We're just including a message for # clarity. @@ -342,13 +380,7 @@ async def _hook_wrapper(hook, event): tasks = [_hook_wrapper(hook, event) for hook in itertools.chain(generic_hooks, targeted_hooks)] await asyncio.wait(tasks) - self._logger.debug('Dispatched {} to all registered hooks'.format(type(event).__name__)) - -# tasks = [hook(event) for hook in itertools.chain(generic_hooks, targeted_hooks)] -# results = await asyncio.gather(*tasks, return_exceptions=True) - -# for index, result in enumerate(results): -# if isinstance(result, Exception): -# self._logger.warning('Event hook {} encountered an exception!'.format(tasks[index].__name__), result) + _log.debug('Dispatched \'%s\' to all registered hooks', type(event).__name__) -# self._logger.debug('Dispatched {} to all registered hooks'.format(type(event).__name__)) + def __repr__(self): + return ''.format(self._user_id, len(self.node_manager), len(self.player_manager)) diff --git a/lavalink/datarw.py b/lavalink/datarw.py index a7ceee3a..840c07bf 100644 --- a/lavalink/datarw.py +++ b/lavalink/datarw.py @@ -1,14 +1,39 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import struct from base64 import b64decode from io import BytesIO +from .utfm_codec import read_utfm + class DataReader: def __init__(self, ts): self._buf = BytesIO(b64decode(ts)) - def _read(self, n): - return self._buf.read(n) + def _read(self, count): + return self._buf.read(count) def read_byte(self): return self._read(1) @@ -33,6 +58,11 @@ def read_utf(self): text_length = self.read_unsigned_short() return self._read(text_length) + def read_utfm(self): + text_length = self.read_unsigned_short() + utf_string = self._read(text_length) + return read_utfm(text_length, utf_string) + class DataWriter: def __init__(self): @@ -44,24 +74,24 @@ def _write(self, data): def write_byte(self, byte): self._buf.write(byte) - def write_boolean(self, b): - enc = struct.pack('B', 1 if b else 0) + def write_boolean(self, boolean): + enc = struct.pack('B', 1 if boolean else 0) self.write_byte(enc) - def write_unsigned_short(self, s): - enc = struct.pack('>H', s) + def write_unsigned_short(self, short): + enc = struct.pack('>H', short) self._write(enc) - def write_int(self, i): - enc = struct.pack('>i', i) + def write_int(self, integer): + enc = struct.pack('>i', integer) self._write(enc) - def write_long(self, l): - enc = struct.pack('>Q', l) + def write_long(self, long_value): + enc = struct.pack('>Q', long_value) self._write(enc) - def write_utf(self, s): - utf = s.encode('utf8') + def write_utf(self, utf_string): + utf = utf_string.encode('utf8') byte_len = len(utf) if byte_len > 65535: diff --git a/lavalink/errors.py b/lavalink/errors.py new file mode 100644 index 00000000..e4b233dd --- /dev/null +++ b/lavalink/errors.py @@ -0,0 +1,39 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + +class NodeError(Exception): + """ Raised when something went wrong with a node. """ + + +class AuthenticationError(Exception): + """ Raised when a request fails due to invalid authentication. """ + + +class InvalidTrack(Exception): + """ Raised when an invalid track was passed. """ + + +class LoadError(Exception): + """ Raised when a track fails to load. E.g. if a DeferredAudioTrack fails to find an equivalent. """ diff --git a/lavalink/events.py b/lavalink/events.py index 3ee50c13..7210914a 100644 --- a/lavalink/events.py +++ b/lavalink/events.py @@ -1,25 +1,53 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + class Event: """ The base for all Lavalink events. """ -class QueueEndEvent(Event): +class TrackStartEvent(Event): """ - This event is dispatched when there are no more songs in the queue. + This event is emitted when the player starts to play a track. Attributes ---------- player: :class:`BasePlayer` - The player that has no more songs in queue. + The player that started to play a track. + track: :class:`AudioTrack` + The track that started playing. """ - __slots__ = ('player',) + __slots__ = ('player', 'track') - def __init__(self, player): + def __init__(self, player, track): self.player = player + self.track = track class TrackStuckEvent(Event): """ - This event is dispatched when the currently playing track is stuck. + This event is emitted when the currently playing track is stuck. This normally has something to do with the stream you are playing and not Lavalink itself. @@ -42,7 +70,7 @@ def __init__(self, player, track, threshold): class TrackExceptionEvent(Event): """ - This event is dispatched when an exception occurs while playing a track. + This event is emitted when an exception occurs while playing a track. Attributes ---------- @@ -50,27 +78,31 @@ class TrackExceptionEvent(Event): The player that had the exception occur while playing a track. track: :class:`AudioTrack` The track that had the exception while playing. - exception: :class:`Exception` + exception: :class:`str` The type of exception that the track had while playing. + severity: :class:`str` + The level of severity of the exception. """ - __slots__ = ('player', 'track', 'exception') + __slots__ = ('player', 'track', 'exception', 'severity') - def __init__(self, player, track, exception): + def __init__(self, player, track, exception, severity): self.player = player self.track = track self.exception = exception + self.severity = severity class TrackEndEvent(Event): """ - This event is dispatched when the player finished playing a track. + This event is emitted when the player finished playing a track. Attributes ---------- player: :class:`BasePlayer` The player that finished playing a track. - track: :class:`AudioTrack` + track: Optional[:class:`AudioTrack`] The track that finished playing. + This could be ``None`` if Lavalink fails to encode the track. reason: :class:`str` The reason why the track stopped playing. """ @@ -82,86 +114,114 @@ def __init__(self, player, track, reason): self.reason = reason -class TrackStartEvent(Event): +class TrackLoadFailedEvent(Event): """ - This event is dispatched when the player starts to play a track. + This is a custom event, emitted when a deferred audio track fails to + produce a playable track. The player will not do anything by itself, + so it is up to you to skip the broken track. Attributes ---------- player: :class:`BasePlayer` - The player that started to play a track. - track: :class:`AudioTrack` - The track that started playing. + The player responsible for playing the track. + track: :class:`DeferredAudioTrack` + The track that failed to produce a playable track. + original: Optional[:class:`Exception`] + The original error, emitted by the track. + This may be ``None`` if the track did not raise an error, + but rather returned ``None`` in place of a playable track. """ - __slots__ = ('player', 'track') + __slots__ = ('player', 'track', 'original') - def __init__(self, player, track): + def __init__(self, player, track, original): self.player = player self.track = track + self.original = original + + +class QueueEndEvent(Event): + """ + This is a custom event, emitted by the :class:`DefaultPlayer` when + there are no more tracks in the queue. + + Attributes + ---------- + player: :class:`BasePlayer` + The player that has no more songs in queue. + """ + __slots__ = ('player',) + + def __init__(self, player): + self.player = player class PlayerUpdateEvent(Event): """ - This event is dispatched when the player's progress changes. + This event is emitted when a player's progress changes. Attributes ---------- player: :class:`BasePlayer` The player that's progress was updated. position: :class:`int` - The position of the player that was changed to. + The track's elapsed playback time in milliseconds. timestamp: :class:`int` - The timestamp that the player is currently on. + The track's elapsed playback time as an epoch timestamp in milliseconds. + connected: :class:`bool` + Whether or not the player is connected to the voice gateway. """ - __slots__ = ('player', 'position', 'timestamp') + __slots__ = ('player', 'position', 'timestamp', 'connected') - def __init__(self, player, position, timestamp): + def __init__(self, player, raw_state): self.player = player - self.position = position - self.timestamp = timestamp + self.position = raw_state.get('position') + self.timestamp = raw_state.get('time') + self.connected = raw_state.get('connected') -class NodeDisconnectedEvent(Event): +class NodeConnectedEvent(Event): """ - This event is dispatched when a node disconnects and becomes unavailable. + This is a custom event, emitted when a connection to a Lavalink node is + successfully established. Attributes ---------- node: :class:`Node` - The node that was disconnected from. - code: :class:`int` - The status code of the event. - reason: :class:`str` - The reason of why the node was disconnected. + The node that was successfully connected to. """ - __slots__ = ('node', 'code', 'reason') + __slots__ = ('node',) - def __init__(self, node, code, reason): + def __init__(self, node): self.node = node - self.code = code - self.reason = reason -class NodeConnectedEvent(Event): +class NodeDisconnectedEvent(Event): """ - This event is dispatched when Lavalink.py successfully connects to a node. + This is a custom event, emitted when the connection to a Lavalink node drops + and becomes unavailable. Attributes ---------- node: :class:`Node` - The node that was successfully connected to. + The node that was disconnected from. + code: Optional[:class:`int`] + The status code of the event. + reason: Optional[:class:`str`] + The reason of why the node was disconnected. """ - __slots__ = ('node',) + __slots__ = ('node', 'code', 'reason') - def __init__(self, node): + def __init__(self, node, code, reason): self.node = node + self.code = code + self.reason = reason class NodeChangedEvent(Event): """ - This event is dispatched when a player changes to another node. - Keep in mind this event can be dispatched multiple times if a node - disconnects and the load balancer moves players to a new node. + This is a custom event, emitted when a player changes to another Lavalink node. + Keep in mind this event can be emitted multiple times if a node disconnects and + the load balancer moves players to a new node. Attributes ---------- @@ -182,9 +242,13 @@ def __init__(self, player, old_node, new_node): class WebSocketClosedEvent(Event): """ - This event is dispatched when a audio websocket to Discord - is closed. This can happen happen for various reasons like an - expired voice server update. + + This event is emitted when an audio websocket to Discord is closed. This can happen + happen for various reasons, an example being when a channel is deleted. + + Refer to the `Discord Developer docs `_ + for a list of close codes and what they mean. This event primarily exists for debug purposes, + and no special handling of voice connections should take place unless it is absolutely necessary. Attributes ---------- diff --git a/lavalink/exceptions.py b/lavalink/exceptions.py deleted file mode 100644 index 015ec229..00000000 --- a/lavalink/exceptions.py +++ /dev/null @@ -1,10 +0,0 @@ -class NodeException(Exception): - """ Raised when something went wrong with a node. """ - - -class Unauthorized(Exception): - """ Raised when a REST request fails due to an incorrect password. """ - - -class InvalidTrack(Exception): - """ Raised when an invalid track was passed. """ diff --git a/lavalink/filters.py b/lavalink/filters.py new file mode 100644 index 00000000..9c53bbfd --- /dev/null +++ b/lavalink/filters.py @@ -0,0 +1,518 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from typing import Union + + +class Filter: + def __init__(self, values: Union[dict, list, float]): + self.values = values + + def update(self, **kwargs): + """ Updates the internal values to match those provided. """ + raise NotImplementedError + + def serialize(self) -> dict: + """ Transforms the internal values into a dict matching the structure Lavalink expects. """ + raise NotImplementedError + + +class Volume(Filter): + """ + Adjusts the audio output volume. + """ + def __init__(self): + super().__init__(1.0) + + def update(self, **kwargs): + """ + Modifies the player volume. + This uses LavaDSP's volume filter, rather than Lavaplayer's native + volume changer. + + Note + ---- + The limits are: + + 0 ≤ volume ≤ 5 + + Parameters + ---------- + volume: :class:`float` + The new volume of the player. 1.0 means 100%/default. + """ + if 'volume' in kwargs: + volume = float(kwargs.pop('volume')) + + if not 0 <= volume <= 5: + raise ValueError('volume must be bigger than or equal to 0, and less than or equal to 5.') + + self.values = volume + + def serialize(self) -> dict: + return {'volume': self.values} + + +class Equalizer(Filter): + """ + Allows modifying the gain of 15 bands, to boost or reduce the volume of specific frequency ranges. + For example, this could be used to boost the low (bass) frequencies to act as a 'bass boost'. + """ + def __init__(self): + super().__init__([0.0] * 15) + + def update(self, **kwargs): + """ + Modifies the gain of each specified band. + There are 15 total bands (indexes 0 to 14) that can be modified. + The meaningful range of each band is -0.25 (muted) to 1.0. A gain of 0.25 doubles the frequency. + The default gain is 0.0. + Modifying the gain could alter the volume of output. + + The frequencies of each band are as follows: + 25 Hz, 40 Hz, 63 Hz, 100 Hz, 160 Hz, 250 Hz, 400 Hz, 630 Hz, 1k Hz, 1.6k Hz, 2.5k Hz, 4k Hz, 6.3k Hz, 10k Hz, 16k Hz + Leftmost frequency represents band 0, rightmost frequency represents band 14. + + Note + ---- + You can provide either ``bands`` OR ``band`` and ``gain`` for the parameters. + + The limits are: + + 0 ≤ band ≤ 14 + + -0.25 ≤ gain ≤ 1.0 + + Parameters + ---------- + bands: List[Tuple[:class:`int`, :class:`float`]] + The bands to modify, and their respective gains. + band: :class:`int` + The band to modify. + gain: :class:`float` + The new gain of the band. + """ + if 'bands' in kwargs: + bands = kwargs.pop('bands') + + sanity_check = isinstance(bands, list) and all(isinstance(pair, tuple) for pair in bands) and \ + all(isinstance(band, int) and isinstance(gain, float) for band, gain in bands) and \ + all(0 <= band <= 14 and -0.25 <= gain <= 1.0 for band, gain in bands) + + if not sanity_check: + raise ValueError('Bands must be a list of tuple representing (band: int, gain: float) with values between ' + '0 to 14, and -0.25 to 1.0 respectively') + + for band, gain in bands: + self.values[band] = gain + elif 'band' in kwargs and 'gain' in kwargs: + band = int(kwargs.pop('band')) + gain = float(kwargs.pop('gain')) + # don't bother propagating the potential ValueErrors raised by these 2 statements + # The users can handle those. + + if not 0 <= band <= 14: + raise ValueError('Band must be between 0 and 14 (start and end inclusive)') + + if not -0.25 <= gain <= 1.0: + raise ValueError('Gain must be between -0.25 and 1.0 (start and end inclusive)') + + self.values[band] = gain + else: + raise KeyError('Expected parameter bands OR band and gain, but neither were provided') + + def serialize(self) -> dict: + return {'equalizer': [{'band': band, 'gain': gain} for band, gain in enumerate(self.values)]} + + +class Karaoke(Filter): + """ + Allows for isolating a frequency range (commonly, the vocal range). + Useful for 'karaoke'/sing-along. + """ + def __init__(self): + super().__init__({'level': 1.0, 'monoLevel': 1.0, 'filterBand': 220.0, 'filterWidth': 100.0}) + + def update(self, **kwargs): + """ + Parameters + ---------- + level: :class:`float` + The level of the Karaoke effect. + mono_level: :class:`float` + The mono level of the Karaoke effect. + filter_band: :class:`float` + The frequency of the band to filter. + filter_width: :class:`float` + The width of the filter. + """ + if 'level' in kwargs: + self.values['level'] = float(kwargs.pop('level')) + + if 'mono_level' in kwargs: + self.values['monoLevel'] = float(kwargs.pop('mono_level')) + + if 'filter_band' in kwargs: + self.values['filterBand'] = float(kwargs.pop('filter_band')) + + if 'filter_width' in kwargs: + self.values['filterWidth'] = float(kwargs.pop('filter_width')) + + def serialize(self) -> dict: + return {'karaoke': self.values} + + +class Timescale(Filter): + """ + Allows speeding up/slowing down the audio, adjusting the pitch and playback rate. + """ + def __init__(self): + super().__init__({'speed': 1.0, 'pitch': 1.0, 'rate': 1.0}) + + def update(self, **kwargs): + """ + Note + ---- + The limits are: + + 0.1 ≤ speed + + 0.1 ≤ pitch + + 0.1 ≤ rate + + Parameters + ---------- + speed: :class:`float` + The playback speed. + pitch: :class:`float` + The pitch of the audio. + rate: :class:`float` + The playback rate. + """ + if 'speed' in kwargs: + speed = float(kwargs.pop('speed')) + + if speed <= 0: + raise ValueError('Speed must be bigger than 0') + + self.values['speed'] = speed + + if 'pitch' in kwargs: + pitch = float(kwargs.pop('pitch')) + + if pitch <= 0: + raise ValueError('Pitch must be bigger than 0') + + self.values['pitch'] = pitch + + if 'rate' in kwargs: + rate = float(kwargs.pop('rate')) + + if rate <= 0: + raise ValueError('Rate must be bigger than 0') + + self.values['rate'] = rate + + def serialize(self) -> dict: + return {'timescale': self.values} + + +class Tremolo(Filter): + """ + Applies a 'tremble' effect to the audio. + """ + def __init__(self): + super().__init__({'frequency': 2.0, 'depth': 0.5}) + + def update(self, **kwargs): + """ + Note + ---- + The limits are: + + 0 < frequency + + 0 < depth ≤ 1 + + Parameters + ---------- + frequency: :class:`float` + How frequently the effect should occur. + depth: :class:`float` + The "strength" of the effect. + """ + if 'frequency' in kwargs: + frequency = float(kwargs.pop('frequency')) + + if frequency < 0: + raise ValueError('Frequency must be bigger than 0') + + self.values['frequency'] = frequency + + if 'depth' in kwargs: + depth = float(kwargs.pop('depth')) + + if not 0 < depth <= 1: + raise ValueError('Depth must be bigger than 0, and less than or equal to 1.') + + self.values['depth'] = depth + + def serialize(self) -> dict: + return {'tremolo': self.values} + + +class Vibrato(Filter): + """ + Applies a 'wobble' effect to the audio. + """ + def __init__(self): + super().__init__({'frequency': 2.0, 'depth': 0.5}) + + def update(self, **kwargs): + """ + Note + ---- + The limits are: + + 0 < frequency ≤ 14 + + 0 < depth ≤ 1 + + Parameters + ---------- + frequency: :class:`float` + How frequently the effect should occur. + depth: :class:`float` + The "strength" of the effect. + """ + if 'frequency' in kwargs: + frequency = float(kwargs.pop('frequency')) + + if not 0 < frequency <= 14: + raise ValueError('Frequency must be bigger than 0, and less than or equal to 14') + + self.values['frequency'] = frequency + + if 'depth' in kwargs: + depth = float(kwargs.pop('depth')) + + if not 0 < depth <= 1: + raise ValueError('Depth must be bigger than 0, and less than or equal to 1.') + + self.values['depth'] = depth + + def serialize(self) -> dict: + return {'vibrato': self.values} + + +class Rotation(Filter): + """ + Phases the audio in and out of the left and right channels in an alternating manner. + This is commonly used to create the 8D effect. + """ + def __init__(self): + super().__init__({'rotationHz': 0.0}) + + def update(self, **kwargs): + """ + Note + ---- + The limits are: + + 0 ≤ rotation_hz + + Parameters + ---------- + rotation_hz: :class:`float` + How frequently the effect should occur. + """ + if 'rotation_hz' in kwargs: + rotation_hz = float(kwargs.pop('rotation_hz')) + + if rotation_hz < 0: + raise ValueError('rotationHz must be bigger than or equal to 0') + + self.values['rotationHz'] = rotation_hz + + def serialize(self) -> dict: + return {'rotation': self.values} + + +class LowPass(Filter): + """ + Applies a low-pass effect to the audio, whereby only low frequencies can pass, + effectively cutting off high frequencies meaning more emphasis is put on lower frequencies. + """ + def __init__(self): + super().__init__({'smoothing': 20.0}) + + def update(self, **kwargs): + """ + Note + ---- + The limits are: + + 1 < smoothing + + Parameters + ---------- + smoothing: :class:`float` + The strength of the effect. + """ + if 'smoothing' in kwargs: + smoothing = float(kwargs.pop('smoothing')) + + if smoothing <= 1: + raise ValueError('smoothing must be bigger than 1') + + self.values['smoothing'] = smoothing + + def serialize(self) -> dict: + return {'lowPass': self.values} + + +class ChannelMix(Filter): + """ + Allows passing the audio from one channel to the other, or isolating individual + channels. + """ + def __init__(self): + super().__init__({'leftToLeft': 1.0, 'leftToRight': 0.0, 'rightToLeft': 0.0, 'rightToRight': 1.0}) + + def update(self, **kwargs): + """ + Note + ---- + The limits are: + + 0 ≤ leftToLeft ≤ 1.0 + + 0 ≤ leftToRight ≤ 1.0 + + 0 ≤ rightToLeft ≤ 1.0 + + 0 ≤ rightToRight ≤ 1.0 + + Parameters + ---------- + left_to_left: :class:`float` + The volume level of the audio going from the "Left" channel to the "Left" channel. + left_to_right: :class:`float` + The volume level of the audio going from the "Left" channel to the "Right" channel. + right_to_left: :class:`float` + The volume level of the audio going from the "Right" channel to the "Left" channel. + right_to_right: :class:`float` + The volume level of the audio going from the "Right" channel to the "Left" channel. + """ + if 'left_to_left' in kwargs: + left_to_left = float(kwargs.pop('left_to_left')) + + if not 0 <= left_to_left <= 1: + raise ValueError('left_to_left must be bigger than or equal to 0, and less than or equal to 1.') + + self.values['leftToLeft'] = left_to_left + + if 'left_to_right' in kwargs: + left_to_right = float(kwargs.pop('left_to_right')) + + if not 0 <= left_to_right <= 1: + raise ValueError('left_to_right must be bigger than or equal to 0, and less than or equal to 1.') + + self.values['leftToRight'] = left_to_right + + if 'right_to_left' in kwargs: + right_to_left = float(kwargs.pop('right_to_left')) + + if not 0 <= right_to_left <= 1: + raise ValueError('right_to_left must be bigger than or equal to 0, and less than or equal to 1.') + + self.values['rightToLeft'] = right_to_left + + if 'right_to_right' in kwargs: + right_to_right = float(kwargs.pop('right_to_right')) + + if not 0 <= right_to_right <= 1: + raise ValueError('right_to_right must be bigger than or equal to 0, and less than or equal to 1.') + + self.values['rightToRight'] = right_to_right + + def serialize(self) -> dict: + return {'channelMix': self.values} + + +class Distortion(Filter): + """ + As the name suggests, this distorts the audio. + """ + def __init__(self): + super().__init__({'sinOffset': 0.0, 'sinScale': 1.0, 'cosOffset': 0.0, 'cosScale': 1.0, + 'tanOffset': 0.0, 'tanScale': 1.0, 'offset': 0.0, 'scale': 1.0}) + + def update(self, **kwargs): + """ + Parameters + ---------- + sin_offset: :class:`float` + The sin offset. + sin_scale: :class:`float` + The sin scale. + cos_offset: :class:`float` + The sin offset. + cos_scale: :class:`float` + The sin scale. + tan_offset: :class:`float` + The sin offset. + tan_scale: :class:`float` + The sin scale. + offset: :class:`float` + The sin offset. + scale: :class:`float` + The sin scale. + """ + if 'sin_offset' in kwargs: + self.values['sinOffset'] = float(kwargs.pop('sin_offset')) + + if 'sin_scale' in kwargs: + self.values['sinScale'] = float(kwargs.pop('sin_scale')) + + if 'cos_offset' in kwargs: + self.values['cosOffset'] = float(kwargs.pop('cos_offset')) + + if 'cos_scale' in kwargs: + self.values['cosScale'] = float(kwargs.pop('cos_scale')) + + if 'tan_offset' in kwargs: + self.values['tanOffset'] = float(kwargs.pop('tan_offset')) + + if 'tan_scale' in kwargs: + self.values['tanScale'] = float(kwargs.pop('tan_scale')) + + if 'offset' in kwargs: + self.values['offset'] = float(kwargs.pop('offset')) + + if 'scale' in kwargs: + self.values['scale'] = float(kwargs.pop('scale')) + + def serialize(self) -> dict: + return {'distortion': self.values} diff --git a/lavalink/models.py b/lavalink/models.py index 810aedcb..7016105b 100644 --- a/lavalink/models.py +++ b/lavalink/models.py @@ -1,21 +1,47 @@ -import typing +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +# pylint: disable=too-many-lines from abc import ABC, abstractmethod +from enum import Enum from random import randrange from time import time +from typing import Dict, List, Optional, Union -from .events import (NodeChangedEvent, PlayerUpdateEvent, # noqa: F401 - QueueEndEvent, TrackEndEvent, TrackExceptionEvent, +from .errors import InvalidTrack, LoadError +from .events import (NodeChangedEvent, QueueEndEvent, TrackEndEvent, + TrackExceptionEvent, TrackLoadFailedEvent, TrackStartEvent, TrackStuckEvent) -from .exceptions import InvalidTrack +from .filters import Equalizer, Filter class AudioTrack: """ - Represents the AudioTrack sent to Lavalink. + Represents an AudioTrack. Parameters ---------- - data: :class:`dict` + data: Union[:class:`dict`, :class:`AudioTrack`] The data to initialise an AudioTrack from. requester: :class:`any` The requester of the track. @@ -24,8 +50,10 @@ class AudioTrack: Attributes ---------- - track: :class:`str` + track: Optional[:class:`str`] The base64-encoded string representing a Lavalink-readable AudioTrack. + This is marked optional as it could be None when it's not set by a custom :class:`Source`, + which is expected behaviour when the subclass is a :class:`DeferredAudioTrack`. identifier: :class:`str` The track's id. For example, a youtube track's identifier will look like dQw4w9WgXcQ. is_seekable: :class:`bool` @@ -40,52 +68,251 @@ class AudioTrack: The title of the track. uri: :class:`str` The full URL of track. + position: :class:`int` + The playback position of the track, in milliseconds. + This is a read-only property; setting it won't have any effect. + source_name: :class:`str` + The name of the source that this track was created by. + requester: :class:`int` + The ID of the user that requested this track. extra: :class:`dict` Any extra properties given to this AudioTrack will be stored here. """ - __slots__ = ('track', 'identifier', 'is_seekable', 'author', 'duration', 'stream', 'title', 'uri', 'requester', - 'extra') + __slots__ = ('_raw', 'track', 'identifier', 'is_seekable', 'author', 'duration', 'stream', 'title', 'uri', + 'position', 'source_name', 'extra') def __init__(self, data: dict, requester: int, **extra): try: - self.track = data['track'] - self.identifier = data['info']['identifier'] - self.is_seekable = data['info']['isSeekable'] - self.author = data['info']['author'] - self.duration = data['info']['length'] - self.stream = data['info']['isStream'] - self.title = data['info']['title'] - self.uri = data['info']['uri'] - self.requester = requester - self.extra = extra + if isinstance(data, AudioTrack): + extra = data.extra + data = data._raw + + self._raw = data + + info = data.get('info', data) + self.track: Optional[str] = data.get('track') + self.identifier: str = info['identifier'] + self.is_seekable: bool = info['isSeekable'] + self.author: str = info['author'] + self.duration: int = info['length'] + self.stream: bool = info['isStream'] + self.title: str = info['title'] + self.uri: str = info['uri'] + self.position: int = info.get('position', 0) + self.source_name: str = info.get('sourceName', 'unknown') + self.extra: dict = {**extra, 'requester': requester} except KeyError as ke: missing_key, = ke.args raise InvalidTrack('Cannot build a track from partial data! (Missing key: {})'.format(missing_key)) from None def __getitem__(self, name): + if name == 'info': + return self + return super().__getattribute__(name) + @property + def requester(self) -> int: + return self.extra['requester'] + + @requester.setter + def requester(self, requester) -> int: + self.extra['requester'] = requester + def __repr__(self): return ''.format(self) +class DeferredAudioTrack(ABC, AudioTrack): + """ + Similar to an :class:`AudioTrack`, however this track only stores metadata up until it's + played, at which time :func:`load` is called to retrieve a base64 string which is then used for playing. + + Note + ---- + For implementation: The ``track`` field need not be populated as this is done later via + the :func:`load` method. You can optionally set ``self.track`` to the result of :func:`load` + during implementation, as a means of caching the base64 string to avoid fetching it again later. + This should serve the purpose of speeding up subsequent play calls in the event of repeat being enabled, + for example. + """ + @abstractmethod + async def load(self, client): + """|coro| + + Retrieves a base64 string that's playable by Lavalink. + For example, you can use this method to search Lavalink for an identical track from other sources, + which you can then use the base64 string of to play the track on Lavalink. + + Parameters + ---------- + client: :class:`Client` + This will be an instance of the Lavalink client 'linked' to this track. + + Returns + ------- + :class:`str` + A Lavalink-compatible base64 string containing encoded track metadata. + """ + raise NotImplementedError + + +class LoadType(Enum): + TRACK = 'TRACK_LOADED' + PLAYLIST = 'PLAYLIST_LOADED' + SEARCH = 'SEARCH_RESULT' + NO_MATCHES = 'NO_MATCHES' + LOAD_FAILED = 'LOAD_FAILED' + + def __eq__(self, other): + if self.__class__ is other.__class__: + return self.value == other.value # pylint: disable=comparison-with-callable + + if isinstance(other, str): + return self.value == other # pylint: disable=comparison-with-callable + + raise NotImplementedError + + @classmethod + def from_str(cls, other: str): + try: + return cls[other.upper()] + except KeyError: + try: + return cls(other.upper()) + except ValueError as ve: + raise ValueError('{} is not a valid LoadType enum!'.format(other)) from ve + + +class PlaylistInfo: + """ + Attributes + ---------- + name: :class:`str` + The name of the playlist. + selected_track: :class:`int` + The index of the selected/highlighted track. + This will be -1 if there is no selected track. + """ + def __init__(self, name: str, selected_track: int = -1): + self.name: str = name + self.selected_track: int = selected_track + + def __getitem__(self, k): # Exists only for compatibility, don't blame me + if k == 'selectedTrack': + k = 'selected_track' + return self.__getattribute__(k) + + @classmethod + def from_dict(cls, mapping: dict): + return cls(mapping.get('name'), mapping.get('selectedTrack', -1)) + + @classmethod + def none(cls): + return cls('', -1) + + def __repr__(self): + return ''.format(self) + + +class LoadResult: + """ + Attributes + ---------- + load_type: :class:`LoadType` + The load type of this result. + tracks: List[Union[:class:`AudioTrack`, :class:`DeferredAudioTrack`]] + The tracks in this result. + playlist_info: :class:`PlaylistInfo` + The playlist metadata for this result. + The :class:`PlaylistInfo` could contain empty/false data if the :class:`LoadType` + is not :enum:`LoadType.PLAYLIST`. + """ + def __init__(self, load_type: LoadType, tracks: List[Union[AudioTrack, DeferredAudioTrack]], + playlist_info: Optional[PlaylistInfo] = PlaylistInfo.none()): + self.load_type: LoadType = load_type + self.playlist_info: PlaylistInfo = playlist_info + self.tracks: List[Union[AudioTrack, DeferredAudioTrack]] = tracks + + def __getitem__(self, k): # Exists only for compatibility, don't blame me + if k == 'loadType': + k = 'load_type' + elif k == 'playlistInfo': + k = 'playlist_info' + + return self.__getattribute__(k) + + @classmethod + def from_dict(cls, mapping: dict): + load_type = LoadType.from_str(mapping.get('loadType')) + playlist_info = PlaylistInfo.from_dict(mapping.get('playlistInfo')) + tracks = [AudioTrack(track, 0) for track in mapping.get('tracks')] + return cls(load_type, tracks, playlist_info) + + def __repr__(self): + return ''.format(self, len(self.tracks)) + + +class Source(ABC): + def __init__(self, name: str): + self.name: str = name + + def __eq__(self, other): + if self.__class__ is other.__class__: + return self.name == other.name + + raise NotImplementedError + + def __hash__(self): + return hash(self.name) + + @abstractmethod + async def load_item(self, client, query: str) -> Optional[LoadResult]: + """|coro| + + Loads a track with the given query. + + Parameters + ---------- + client: :class:`Client` + The Lavalink client. This could be useful for performing a Lavalink search + for an identical track from other sources, if needed. + query: :class:`str` + The search query that was provided. + + Returns + ------- + Optional[:class:`LoadResult`] + A LoadResult, or None if there were no matches for the provided query. + """ + raise NotImplementedError + + def __repr__(self): + return ''.format(self) + + class BasePlayer(ABC): """ Represents the BasePlayer all players must be inherited from. Attributes ---------- - guild_id: :class:`str` + guild_id: :class:`int` The guild id of the player. node: :class:`Node` The node that the player is connected to. + channel_id: Optional[:class:`int`] + The ID of the voice channel the player is connected to. + This could be None if the player isn't connected. """ def __init__(self, guild_id, node): - self.guild_id = str(guild_id) + self._lavalink = node._manager._lavalink + self.guild_id: int = guild_id + self._internal_id: str = str(guild_id) self.node = node self._original_node = None # This is used internally for failover. self._voice_state = {} - self.channel_id = None + self.channel_id: Optional[int] = None @abstractmethod async def _handle_event(self, event): @@ -95,9 +322,83 @@ async def _handle_event(self, event): async def _update_state(self, state: dict): raise NotImplementedError + async def play_track(self, track: str, start_time: Optional[int] = None, end_time: Optional[int] = None, + no_replace: Optional[bool] = None, volume: Optional[int] = None, pause: Optional[bool] = None): + """|coro| + + Plays the given track. + + Parameters + ---------- + track: :class:`str` + The track to play. This must be the base64 string from a track. + start_time: Optional[:class:`int`] + The number of milliseconds to offset the track by. + If left unspecified or ``None`` is provided, the track will start from the beginning. + end_time: Optional[:class:`int`] + The position at which the track should stop playing. + This is an absolute position, so if you want the track to stop at 1 minute, you would pass 60000. + The default behaviour is to play until no more data is received from the remote server. + If left unspecified or ``None`` is provided, the default behaviour is exhibited. + no_replace: Optional[:class:`bool`] + If set to true, operation will be ignored if a track is already playing or paused. + The default behaviour is to always replace. + If left unspecified or None is provided, the default behaviour is exhibited. + volume: Optional[:class:`int`] + The initial volume to set. This is useful for changing the volume between tracks etc. + If left unspecified or ``None`` is provided, the volume will remain at its current setting. + pause: Optional[:class:`bool`] + Whether to immediately pause the track after loading it. + The default behaviour is to never pause. + If left unspecified or ``None`` is provided, the default behaviour is exhibited. + """ + if track is None or not isinstance(track, str): + raise ValueError('track must be a str') + + options = {} + + if start_time is not None: + if not isinstance(start_time, int) or start_time < 0: + raise ValueError('start_time must be an int with a value equal to, or greater than 0') + options['startTime'] = start_time + + if end_time is not None: + if not isinstance(end_time, int) or not end_time <= 0: + raise ValueError('end_time must be an int with a value equal to, or greater than 0') + + if end_time > 0: + options['endTime'] = end_time + + if no_replace is not None: + if not isinstance(no_replace, bool): + raise TypeError('no_replace must be a bool') + options['noReplace'] = no_replace + + if volume is not None: + if not isinstance(volume, int): + raise TypeError('volume must be an int') + self.volume = max(min(volume, 1000), 0) + options['volume'] = self.volume + + if pause is not None: + if not isinstance(pause, bool): + raise TypeError('pause must be a bool') + options['pause'] = pause + + await self.node._send(op='play', guildId=self._internal_id, track=track, **options) + def cleanup(self): pass + async def destroy(self): + """|coro| + + Destroys the current player instance. + + Shortcut for :func:`PlayerManager.destroy`. + """ + await self._lavalink.player_manager.destroy(self.guild_id) + async def _voice_server_update(self, data): self._voice_state.update({ 'event': data @@ -106,33 +407,61 @@ async def _voice_server_update(self, data): await self._dispatch_voice_update() async def _voice_state_update(self, data): - self._voice_state.update({ - 'sessionId': data['session_id'] - }) - - self.channel_id = data['channel_id'] + raw_channel_id = data['channel_id'] + self.channel_id = int(raw_channel_id) if raw_channel_id else None if not self.channel_id: # We're disconnecting self._voice_state.clear() return - await self._dispatch_voice_update() + if data['session_id'] != self._voice_state.get('sessionId'): + self._voice_state.update({ + 'sessionId': data['session_id'] + }) + + await self._dispatch_voice_update() async def _dispatch_voice_update(self): if {'sessionId', 'event'} == self._voice_state.keys(): - await self.node._send(op='voiceUpdate', guildId=self.guild_id, **self._voice_state) + await self.node._send(op='voiceUpdate', guildId=self._internal_id, **self._voice_state) + + @abstractmethod + async def node_unavailable(self): + """|coro| + + Called when a player's node becomes unavailable. + Useful for changing player state before it's moved to another node. + """ + raise NotImplementedError @abstractmethod async def change_node(self, node): + """|coro| + + Called when a node change is requested for the current player instance. + + Parameters + ---------- + node: :class:`Node` + The new node to switch to. + """ raise NotImplementedError class DefaultPlayer(BasePlayer): """ - The player that Lavalink.py defaults to use. + The player that Lavalink.py uses by default. + + This should be sufficient for most use-cases. Attributes ---------- + LOOP_NONE: :class:`int` + Class attribute. Disables looping entirely. + LOOP_SINGLE: :class:`int` + Class attribute. Enables looping for a single (usually currently playing) track only. + LOOP_QUEUE: :class:`int` + Class attribute. Enables looping for the entire queue. When a track finishes playing, it'll be added to the end of the queue. guild_id: :class:`int` The guild id of the player. node: :class:`Node` @@ -140,54 +469,93 @@ class DefaultPlayer(BasePlayer): paused: :class:`bool` Whether or not a player is paused. position_timestamp: :class:`int` - The position of how far a track has gone. + Returns the track's elapsed playback time as an epoch timestamp. volume: :class:`int` The volume at which the player is playing at. shuffle: :class:`bool` Whether or not to mix the queue up in a random playing order. - repeat: :class:`bool` - Whether or not to continuously to play a track. - equalizer: :class:`list` - The changes to audio frequencies on tracks. - queue: :class:`list` - The order of which tracks are played. - current: :class:`AudioTrack` - The track that is playing currently. + loop: :class:`int` + Whether loop is enabled, and the type of looping. + This is an integer as loop supports multiple states. + + 0 = Loop off. + + 1 = Loop track. + + 2 = Loop queue. + + Example + ------- + .. code:: python + + if player.loop == player.LOOP_NONE: + await ctx.send('Not looping.') + elif player.loop == player.LOOP_SINGLE: + await ctx.send(f'{player.current.title} is looping.') + elif player.loop == player.LOOP_QUEUE: + await ctx.send('This queue never ends!') + filters: Dict[:class:`str`, :class:`Filter`] + A mapping of str to :class:`Filter`, representing currently active filters. + queue: List[:class:`AudioTrack`] + A list of AudioTracks to play. + current: Optional[:class:`AudioTrack`] + The track that is playing currently, if any. """ + LOOP_NONE: int = 0 + LOOP_SINGLE: int = 1 + LOOP_QUEUE: int = 2 + def __init__(self, guild_id, node): super().__init__(guild_id, node) self._user_data = {} - self.paused = False + self.paused: bool = False + self._internal_pause: bool = False # Toggled when player's node becomes unavailable, primarily used for track position tracking. self._last_update = 0 self._last_position = 0 - self.position_timestamp = 0 - self.volume = 100 - self.shuffle = False - self.repeat = False - self.equalizer = [0.0 for x in range(15)] # 0-14, -0.25 - 1.0 + self.position_timestamp: int = 0 + self.volume: int = 100 + self.shuffle: bool = False + self.loop: int = 0 # 0 = off, 1 = single track, 2 = queue + self.filters: Dict[str, Filter] = {} - self.queue = [] - self.current = None + self.queue: List[AudioTrack] = [] + self.current: Optional[AudioTrack] = None + + @property + def repeat(self) -> bool: + """ + Returns the player's loop status. This exists for backwards compatibility, and also as an alias. + + .. deprecated:: 4.0.0 + Use :attr:`loop` instead. + + If ``self.loop`` is 0, the player is NOT looping. + + If ``self.loop`` is 1, the player is looping the single (current) track. + + If ``self.loop`` is 2, the player is looping the entire queue. + """ + return self.loop == 1 or self.loop == 2 @property - def is_playing(self): + def is_playing(self) -> bool: """ Returns the player's track state. """ return self.is_connected and self.current is not None @property - def is_connected(self): + def is_connected(self) -> bool: """ Returns whether the player is connected to a voicechannel or not. """ return self.channel_id is not None @property - def position(self): - """ Returns the position in the track, adjusted for Lavalink's 5-second stats interval. """ + def position(self) -> float: + """ Returns the track's elapsed playback time in milliseconds, adjusted for Lavalink stat interval. """ if not self.is_playing: return 0 - if self.paused: + if self.paused or self._internal_pause: return min(self._last_position, self.current.duration) difference = time() * 1000 - self._last_update @@ -215,11 +583,11 @@ def fetch(self, key: object, default=None): key: :class:`object` The key to fetch. default: Optional[:class:`any`] - The object that should be returned if the key doesn't exist. Defaults to `None`. + The object that should be returned if the key doesn't exist. Defaults to ``None``. Returns ------- - :class:`any` + Optional[:class:`any`] """ return self._user_data.get(key, default) @@ -231,67 +599,105 @@ def delete(self, key: object): ---------- key: :class:`object` The key to delete. + + Raises + ------ + :class:`KeyError` + If the key doesn't exist. """ try: del self._user_data[key] except KeyError: pass - def add(self, requester: int, track: typing.Union[AudioTrack, dict], index: int = None): + def add(self, track: Union[AudioTrack, DeferredAudioTrack, Dict], requester: int = 0, index: int = None): """ Adds a track to the queue. Parameters ---------- - requester: :class:`int` - The ID of the user who requested the track. - track: Union[:class:`AudioTrack`, :class:`dict`] + track: Union[:class:`AudioTrack`, :class:`DeferredAudioTrack`, :class:`dict`] The track to add. Accepts either an AudioTrack or a dict representing a track returned from Lavalink. + requester: :class:`int` + The ID of the user who requested the track. index: Optional[:class:`int`] The index at which to add the track. - If index is left unspecified, the default behaviour is to append the track. Defaults to `None`. + If index is left unspecified, the default behaviour is to append the track. Defaults to ``None``. """ - at = AudioTrack(track, requester) if isinstance(track, dict) else track + at = track + + if isinstance(track, dict): + at = AudioTrack(track, requester) + + if requester != 0: + at.requester = requester if index is None: self.queue.append(at) else: self.queue.insert(index, at) - async def play(self, track: typing.Union[AudioTrack, dict] = None, start_time: int = 0, end_time: int = 0, no_replace: bool = False): - """ + async def play(self, track: Optional[Union[AudioTrack, DeferredAudioTrack, Dict]] = None, start_time: Optional[int] = 0, + end_time: Optional[int] = 0, no_replace: Optional[bool] = False, volume: Optional[int] = None, + pause: Optional[bool] = False): + """|coro| + Plays the given track. Parameters ---------- - track: Optional[Union[:class:`AudioTrack`, :class:`dict`]] + track: Optional[Union[:class:`DeferredAudioTrack`, :class:`AudioTrack`, :class:`dict`]] The track to play. If left unspecified, this will default - to the first track in the queue. Defaults to `None` so plays the next + to the first track in the queue. Defaults to ``None`` so plays the next song in queue. Accepts either an AudioTrack or a dict representing a track returned from Lavalink. start_time: Optional[:class:`int`] - Setting that determines the number of milliseconds to offset the track by. - If left unspecified, it will start the track at its beginning. Defaults to `0`, - which is the normal start time. + The number of milliseconds to offset the track by. + If left unspecified or ``None`` is provided, the track will start from the beginning. end_time: Optional[:class:`int`] - Settings that determines the number of milliseconds the track will stop playing. - By default track plays until it ends as per encoded data. Defaults to `0`, which is - the normal end time. + The position at which the track should stop playing. + This is an absolute position, so if you want the track to stop at 1 minute, you would pass 60000. + The default behaviour is to play until no more data is received from the remote server. + If left unspecified or ``None`` is provided, the default behaviour is exhibited. no_replace: Optional[:class:`bool`] If set to true, operation will be ignored if a track is already playing or paused. - Defaults to `False` + The default behaviour is to always replace. + If left unspecified or None is provided, the default behaviour is exhibited. + volume: Optional[:class:`int`] + The initial volume to set. This is useful for changing the volume between tracks etc. + If left unspecified or ``None`` is provided, the volume will remain at its current setting. + pause: Optional[:class:`bool`] + Whether to immediately pause the track after loading it. + The default behaviour is to never pause. + If left unspecified or ``None`` is provided, the default behaviour is exhibited. + + Raises + ------ + :class:`ValueError` + If invalid values were provided for ``start_time`` or ``end_time``. + :class:`TypeError` + If wrong types were provided for ``no_replace``, ``volume`` or ``pause``. """ + if no_replace and self.is_playing: + return + if track is not None and isinstance(track, dict): track = AudioTrack(track, 0) - if self.repeat and self.current: - self.queue.append(self.current) + if self.loop > 0 and self.current: + if self.loop == 1: + if track is not None: + self.queue.insert(0, self.current) + else: + track = self.current + if self.loop == 2: + self.queue.append(self.current) self._last_update = 0 self._last_position = 0 self.position_timestamp = 0 - self.paused = False + self.paused = pause if not track: if not self.queue: @@ -302,50 +708,89 @@ async def play(self, track: typing.Union[AudioTrack, dict] = None, start_time: i pop_at = randrange(len(self.queue)) if self.shuffle else 0 track = self.queue.pop(pop_at) - options = {} - if start_time is not None: - if not isinstance(start_time, int) or not 0 <= start_time <= track.duration: + if not isinstance(start_time, int) or not 0 <= start_time < track.duration: raise ValueError('start_time must be an int with a value equal to, or greater than 0, and less than the track duration') - options['startTime'] = start_time if end_time is not None: if not isinstance(end_time, int) or not 0 <= end_time <= track.duration: - raise ValueError('end_time must be an int with a value equal to, or greater than 0, and less than the track duration') - options['endTime'] = end_time - - if no_replace is None: - no_replace = False - if not isinstance(no_replace, bool): - raise TypeError('no_replace must be a bool') - options['noReplace'] = no_replace + raise ValueError('end_time must be an int with a value equal to, or greater than 0, and less than, or equal to the track duration') self.current = track - await self.node._send(op='play', guildId=self.guild_id, track=track.track, **options) + playable_track = track.track + + if isinstance(track, DeferredAudioTrack) and playable_track is None: + try: + playable_track = await track.load(self.node._manager._lavalink) + except LoadError as load_error: + await self.node._dispatch_event(TrackLoadFailedEvent(self, track, load_error)) + else: + if playable_track is None: + await self.node._dispatch_event(TrackLoadFailedEvent(self, track, None)) + + if playable_track is None: + return + + await self.play_track(playable_track, start_time, end_time, no_replace, volume, pause) await self.node._dispatch_event(TrackStartEvent(self, track)) + # TODO: Figure out a better solution for the above. Custom player implementations may neglect + # to dispatch TrackStartEvent leading to confusion and poor user experience. async def stop(self): - """ Stops the player. """ - await self.node._send(op='stop', guildId=self.guild_id) + """|coro| + + Stops the player. + """ + await self.node._send(op='stop', guildId=self._internal_id) self.current = None async def skip(self): - """ Plays the next track in the queue, if any. """ + """|coro| + + Plays the next track in the queue, if any. + """ await self.play() def set_repeat(self, repeat: bool): """ - Sets the player's repeat state. + Sets whether tracks should be repeated. + + .. deprecated:: 4.0.0 + Use :func:`set_loop` to repeat instead. + + This only works as a "queue loop". For single-track looping, you should + utilise the :class:`TrackEndEvent` event to feed the track back into + :func:`play`. + + Also known as ``loop``. + Parameters ---------- repeat: :class:`bool` Whether to repeat the player or not. """ - self.repeat = repeat + self.loop = 2 if repeat else 0 + + def set_loop(self, loop: int): + """ + Sets whether the player loops between a single track, queue or none. + + 0 = off, 1 = single track, 2 = queue. + + Parameters + ---------- + loop: :class:`int` + The loop setting. 0 = off, 1 = single track, 2 = queue. + """ + if not 0 <= loop <= 2: + raise ValueError('Loop must be 0, 1 or 2.') + + self.loop = loop def set_shuffle(self, shuffle: bool): """ Sets the player's shuffle state. + Parameters ---------- shuffle: :class:`bool` @@ -354,7 +799,8 @@ def set_shuffle(self, shuffle: bool): self.shuffle = shuffle async def set_pause(self, pause: bool): - """ + """|coro| + Sets the player's paused state. Parameters @@ -362,11 +808,12 @@ async def set_pause(self, pause: bool): pause: :class:`bool` Whether to pause the player or not. """ - await self.node._send(op='pause', guildId=self.guild_id, pause=pause) self.paused = pause + await self.node._send(op='pause', guildId=self._internal_id, pause=pause) async def set_volume(self, vol: int): - """ + """|coro| + Sets the player's volume Note @@ -379,10 +826,11 @@ async def set_volume(self, vol: int): The new volume level. """ self.volume = max(min(vol, 1000), 0) - await self.node._send(op='volume', guildId=self.guild_id, volume=self.volume) + await self.node._send(op='volume', guildId=self._internal_id, volume=self.volume) async def seek(self, position: int): - """ + """|coro| + Seeks to a given position in the track. Parameters @@ -390,12 +838,167 @@ async def seek(self, position: int): position: :class:`int` The new position to seek to in milliseconds. """ - await self.node._send(op='seek', guildId=self.guild_id, position=position) + await self.node._send(op='seek', guildId=self._internal_id, position=position) - async def set_gain(self, band: int, gain: float = 0.0): + async def set_filter(self, _filter: Filter): + """|coro| + + Applies the corresponding filter within Lavalink. + This will overwrite the filter if it's already applied. + + Example + ------- + .. code:: python + + equalizer = Equalizer() + equalizer.update(bands=[(0, 0.2), (1, 0.3), (2, 0.17)]) + player.set_filter(equalizer) + + Parameters + ---------- + _filter: :class:`Filter` + The filter instance to set. + + Raises + ------ + :class:`TypeError` + If the provided ``_filter`` is not of type :class:`Filter`. + """ + if not isinstance(_filter, Filter): + raise TypeError('Expected object of type Filter, not ' + type(_filter).__name__) + + filter_name = type(_filter).__name__.lower() + self.filters[filter_name] = _filter + await self._apply_filters() + + async def update_filter(self, _filter: Filter, **kwargs): + """|coro| + + Updates a filter using the upsert method; + if the filter exists within the player, its values will be updated; + if the filter does not exist, it will be created with the provided values. + + This will not overwrite any values that have not been provided. + + Example + ------- + .. code :: python + + player.update_filter(Timescale, speed=1.5) + # This means that, if the Timescale filter is already applied + # and it already has set values of "speed=1, pitch=1.2", pitch will remain + # the same, however speed will be changed to 1.5 so the result is + # "speed=1.5, pitch=1.2" + + Parameters + ---------- + _filter: :class:`Filter` + The filter class (**not** an instance of, see above example) to upsert. + **kwargs: :class:`any` + The kwargs to pass to the filter. + + Raises + ------ + :class:`TypeError` + If the provided ``_filter`` is not of type :class:`Filter`. + """ + if isinstance(_filter, Filter): + raise TypeError('Expected class of type Filter, not an instance of ' + type(_filter).__name__) + + if not issubclass(_filter, Filter): + raise TypeError('Expected subclass of type Filter, not ' + _filter.__name__) + + filter_name = _filter.__name__.lower() + + filter_instance = self.filters.get(filter_name, _filter()) + filter_instance.update(**kwargs) + self.filters[filter_name] = filter_instance + await self._apply_filters() + + def get_filter(self, _filter: Union[Filter, str]): """ + Returns the corresponding filter, if it's enabled. + + Example + ------- + .. code:: python + + from lavalink.filters import Timescale + timescale = player.get_filter(Timescale) + # or + timescale = player.get_filter('timescale') + + Parameters + ---------- + _filter: Union[:class:`Filter`, :class:`str`] + The filter name, or filter class (**not** an instance of, see above example), to get. + + Returns + ------- + Optional[:class:`Filter`] + """ + if isinstance(_filter, str): + filter_name = _filter + elif isinstance(_filter, Filter): # User passed an instance of. + filter_name = type(_filter).__name__ + else: + if not issubclass(_filter, Filter): + raise TypeError('Expected subclass of type Filter, not ' + _filter.__name__) + + filter_name = _filter.__name__ + + return self.filters.get(filter_name.lower(), None) + + async def remove_filter(self, _filter: Union[Filter, str]): + """|coro| + + Removes a filter from the player, undoing any effects applied to the audio. + + Example + ------- + .. code:: python + + player.remove_filter(Timescale) + # or + player.remove_filter('timescale') + + Parameters + ---------- + _filter: Union[:class:`Filter`, :class:`str`] + The filter name, or filter class (**not** an instance of, see above example), to remove. + """ + if isinstance(_filter, str): + filter_name = _filter + elif isinstance(_filter, Filter): # User passed an instance of. + filter_name = type(_filter).__name__ + else: + if not issubclass(_filter, Filter): + raise TypeError('Expected subclass of type Filter, not ' + _filter.__name__) + + filter_name = _filter.__name__ + + fn_lowered = filter_name.lower() + + if fn_lowered in self.filters: + self.filters.pop(fn_lowered) + await self._apply_filters() + + async def clear_filters(self): + """|coro| + + Clears all currently-enabled filters. + """ + self.filters.clear() + await self._apply_filters() + + async def set_gain(self, band: int, gain: float = 0.0): + """|coro| + Sets the equalizer band gain to the given amount. + .. deprecated:: 4.0.0 + Use :func:`set_filter` to apply the :class:`Equalizer` filter instead. + Parameters ---------- band: :class:`int` @@ -405,35 +1008,40 @@ async def set_gain(self, band: int, gain: float = 0.0): """ await self.set_gains((band, gain)) - async def set_gains(self, *gain_list): - """ + async def set_gains(self, *bands): + """|coro| + Modifies the player's equalizer settings. + .. deprecated:: 4.0.0 + Use :func:`set_filter` to apply the :class:`Equalizer` filter instead. + Parameters ---------- gain_list: :class:`any` - A list of tuples denoting (`band`, `gain`). + A list of tuples denoting (``band``, ``gain``). """ - update_package = [] - for value in gain_list: - if not isinstance(value, tuple): - raise TypeError('gain_list must be a list of tuples') + equalizer = Equalizer() + equalizer.update(bands=bands) + await self.set_filter(equalizer) - band = value[0] - gain = value[1] + async def reset_equalizer(self): + """|coro| + + Resets equalizer to default values. - if not -1 < value[0] < 15: - raise IndexError('{} is an invalid band, must be 0-14'.format(band)) + .. deprecated:: 4.0.0 + Use :func:`remove_filter` to remove the :class:`Equalizer` filter instead. + """ + await self.remove_filter(Equalizer) - gain = max(min(float(gain), 1.0), -0.25) - update_package.append({'band': band, 'gain': gain}) - self.equalizer[band] = gain + async def _apply_filters(self): + payload = {} - await self.node._send(op='equalizer', guildId=self.guild_id, bands=update_package) + for _filter in self.filters.values(): + payload.update(_filter.serialize()) - async def reset_equalizer(self): - """ Resets equalizer to default values. """ - await self.set_gains(*[(x, 0.0) for x in range(15)]) + await self.node._send(op='filters', guildId=self._internal_id, **payload) async def _handle_event(self, event): """ @@ -461,11 +1069,17 @@ async def _update_state(self, state: dict): self._last_position = state.get('position', 0) self.position_timestamp = state.get('time', 0) - event = PlayerUpdateEvent(self, self._last_position, self.position_timestamp) - await self.node._dispatch_event(event) + async def node_unavailable(self): + """|coro| - async def change_node(self, node): + Called when a player's node becomes unavailable. + Useful for changing player state before it's moved to another node. """ + self._internal_pause = True + + async def change_node(self, node): + """|coro| + Changes the player's node Parameters @@ -474,7 +1088,7 @@ async def change_node(self, node): The node the player is changed to. """ if self.node.available: - await self.node._send(op='destroy', guildId=self.guild_id) + await self.node._send(op='destroy', guildId=self._internal_id) old_node = self.node self.node = node @@ -483,17 +1097,55 @@ async def change_node(self, node): await self._dispatch_voice_update() if self.current: - await self.node._send(op='play', guildId=self.guild_id, track=self.current.track, startTime=self.position) + playable_track = self.current.track + + if isinstance(self.current, DeferredAudioTrack) and playable_track is None: + playable_track = await self.current.load(self.node._manager._lavalink) + + await self.node._send(op='play', guildId=self._internal_id, track=playable_track, startTime=self.position) self._last_update = time() * 1000 if self.paused: - await self.node._send(op='pause', guildId=self.guild_id, pause=self.paused) + await self.node._send(op='pause', guildId=self._internal_id, pause=self.paused) + + self._internal_pause = False if self.volume != 100: - await self.node._send(op='volume', guildId=self.guild_id, volume=self.volume) + await self.node._send(op='volume', guildId=self._internal_id, volume=self.volume) - if any(self.equalizer): # If any bands of the equalizer was modified - payload = [{'band': b, 'gain': g} for b, g in enumerate(self.equalizer)] - await self.node._send(op='equalizer', guildId=self.guild_id, bands=payload) + if self.filters: + await self._apply_filters() await self.node._dispatch_event(NodeChangedEvent(self, old_node, node)) + + def __repr__(self): + return ''.format(self) + + +class Plugin: + """ + Represents a Lavalink server plugin. + + Parameters + ---------- + data: :class:`dict` + The data to initialise a Plugin from. + + Attributes + ---------- + name: :class:`str` + The name of the plugin. + version: :class:`str` + The version of the plugin. + """ + __slots__ = ('name', 'version') + + def __init__(self, data: dict): + self.name: str = data['name'] + self.version: str = data['version'] + + def __str__(self): + return '{0.name} v{0.version}'.format(self) + + def __repr__(self): + return ''.format(self) diff --git a/lavalink/node.py b/lavalink/node.py index abdf8ad4..81cc93a8 100644 --- a/lavalink/node.py +++ b/lavalink/node.py @@ -1,4 +1,32 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +from typing import List + +from lavalink.models import Plugin + from .events import Event +from .stats import Stats from .websocket import WebSocket @@ -19,79 +47,116 @@ class Node: The port to use for websocket and REST connections. password: :class:`str` The password used for authentication. + ssl: :class:`bool` + Whether this node uses SSL (wss/https). region: :class:`str` The region to assign this node to. name: :class:`str` The name the :class:`Node` is identified by. + filters: :class:`bool` + Whether or not to use the new ``filters`` op instead of ``equalizer``. + This setting is only used by players. stats: :class:`Stats` The statistics of how the :class:`Node` is performing. """ def __init__(self, manager, host: str, port: int, password: str, region: str, resume_key: str, resume_timeout: int, name: str = None, - reconnect_attempts: int = 3): + reconnect_attempts: int = 3, filters: bool = False, ssl: bool = False): + self._lavalink = manager._lavalink self._manager = manager - self._ws = WebSocket(self, host, port, password, resume_key, resume_timeout, reconnect_attempts) + self._ws = WebSocket(self, host, port, password, ssl, resume_key, resume_timeout, reconnect_attempts) self.host = host self.port = port self.password = password + self.ssl = ssl self.region = region self.name = name or '{}-{}:{}'.format(self.region, self.host, self.port) - self.stats = None + self.filters = filters + self.stats = Stats.empty(self) @property - def available(self): + def available(self) -> bool: """ Returns whether the node is available for requests. """ return self._ws.connected @property def _original_players(self): - """ Returns a list of players that were assigned to this node, but were moved due to failover etc. """ - return [p for p in self._manager._lavalink.player_manager.values() if p._original_node == self] + """ + Returns a list of players that were assigned to this node, but were moved due to failover etc. + + Returns + ------- + List[:class:`BasePlayer`] + """ + return [p for p in self._lavalink.player_manager.values() if p._original_node == self] @property def players(self): - """ Returns a list of all players on this node. """ - return [p for p in self._manager._lavalink.player_manager.values() if p.node == self] + """ + Returns a list of all players on this node. + + Returns + ------- + List[:class:`BasePlayer`] + """ + return [p for p in self._lavalink.player_manager.values() if p.node == self] @property - def penalty(self): + def penalty(self) -> int: """ Returns the load-balancing penalty for this node. """ if not self.available or not self.stats: return 9e30 return self.stats.penalty.total - async def get_tracks(self, query: str): + @property + def http_uri(self) -> str: + """ Returns a 'base' URI pointing to the node's address and port, also factoring in SSL. """ + return '{}://{}:{}'.format('https' if self.ssl else 'http', self.host, self.port) + + async def destroy(self): """|coro| - Gets all tracks associated with the given query. + + Closes the WebSocket connection for this node. No further connection attempts will be made. + """ + await self._ws.destroy() + + async def get_tracks(self, query: str, check_local: bool = False): + """|coro| + + Retrieves a list of results pertaining to the provided query. Parameters ---------- query: :class:`str` The query to perform a search for. + check_local: :class:`bool` + Whether to also search the query on sources registered with this Lavalink client. Returns ------- - :class:`dict` - A dict representing an AudioTrack. + :class:`LoadResult` """ - return await self._manager._lavalink.get_tracks(query, self) + return await self._lavalink.get_tracks(query, self, check_local) async def routeplanner_status(self): """|coro| - Gets the routeplanner status of the target node. + + Retrieves the status of the target node's routeplanner. Returns ------- :class:`dict` A dict representing the routeplanner information. """ - return await self._manager._lavalink.routeplanner_status(self) + return await self._lavalink._get_request('{}/routeplanner/status'.format(self.http_uri), + headers={'Authorization': self.password}) - async def routeplanner_free_address(self, address: str): + async def routeplanner_free_address(self, address: str) -> bool: """|coro| - Gets the routeplanner status of the target node. + + Frees up the provided IP address in the target node's routeplanner. Parameters ---------- @@ -100,24 +165,42 @@ async def routeplanner_free_address(self, address: str): Returns ------- - bool + :class:`bool` True if the address was freed, False otherwise. """ - return await self._manager._lavalink.routeplanner_free_address(self, address) + return await self._lavalink._post_request('{}/routeplanner/free/address'.format(self.http_uri), + headers={'Authorization': self.password}, json={'address': address}) - async def routeplanner_free_all_failing(self): + async def routeplanner_free_all_failing(self) -> bool: """|coro| - Gets the routeplanner status of the target node. + + Frees up all IP addresses in the target node that have been marked as failing. Returns ------- - bool + :class:`bool` True if all failing addresses were freed, False otherwise. """ - return await self._manager._lavalink.routeplanner_free_all_failing(self) + return await self._lavalink._post_request('{}/routeplanner/free/all'.format(self.http_uri), + headers={'Authorization': self.password}) + + async def get_plugins(self) -> List[Plugin]: + """|coro| + + Retrieves a list of plugins active on this node. + + Returns + ------- + List[:class:`Plugin`] + A list of active plugins. + """ + data = await self._lavalink._get_request('{}/plugins'.format(self.http_uri), + headers={'Authorization': self.password}) + return [Plugin(plugin) for plugin in data] async def _dispatch_event(self, event: Event): """|coro| + Dispatches the given event to all registered hooks. Parameters @@ -125,10 +208,11 @@ async def _dispatch_event(self, event: Event): event: :class:`Event` The event to dispatch to the hooks. """ - await self._manager._lavalink._dispatch_event(event) + await self._lavalink._dispatch_event(event) async def _send(self, **data): """|coro| + Sends the passed data to the node via the websocket connection. Parameters diff --git a/lavalink/nodemanager.py b/lavalink/nodemanager.py index 1202d049..17c98511 100644 --- a/lavalink/nodemanager.py +++ b/lavalink/nodemanager.py @@ -1,12 +1,46 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import logging + from .events import NodeConnectedEvent, NodeDisconnectedEvent from .node import Node +_log = logging.getLogger(__name__) +DEFAULT_REGIONS = { + 'asia': ('hongkong', 'singapore', 'sydney', 'japan', 'southafrica', 'india'), + 'eu': ('eu', 'amsterdam', 'frankfurt', 'russia', 'london'), + 'us': ('us', 'brazil') +} + class NodeManager: """Represents the node manager that contains all lavalink nodes. + len(x): + Returns the total number of nodes. iter(x): - Returns an iterator of all the nodes cached. + Returns an iterator of all the stored nodes. Attributes ---------- @@ -18,14 +52,11 @@ class NodeManager: def __init__(self, lavalink, regions: dict): self._lavalink = lavalink self._player_queue = [] - self.nodes = [] + self.regions = regions or DEFAULT_REGIONS - self.regions = regions or { - 'asia': ('hongkong', 'singapore', 'sydney', 'japan', 'southafrica', 'india'), - 'eu': ('eu', 'amsterdam', 'frankfurt', 'russia', 'london'), - 'us': ('us', 'brazil') - } + def __len__(self): + return len(self.nodes) def __iter__(self): for n in self.nodes: @@ -38,7 +69,7 @@ def available_nodes(self): def add_node(self, host: str, port: int, password: str, region: str, resume_key: str = None, resume_timeout: int = 60, name: str = None, - reconnect_attempts: int = 3): + reconnect_attempts: int = 3, filters: bool = False, ssl: bool = False): """ Adds a node to Lavalink's node manager. @@ -54,32 +85,43 @@ def add_node(self, host: str, port: int, password: str, region: str, The region to assign this node to. resume_key: Optional[:class:`str`] A resume key used for resuming a session upon re-establishing a WebSocket connection to Lavalink. - Defaults to `None`. + Defaults to ``None``. resume_timeout: Optional[:class:`int`] How long the node should wait for a connection while disconnected before clearing all players. - Defaults to `60`. + Defaults to ``60``. name: Optional[:class:`str`] - An identifier for the node that will show in logs. Defaults to `None`. + An identifier for the node that will show in logs. Defaults to ``None``. reconnect_attempts: Optional[:class:`int`] The amount of times connection with the node will be reattempted before giving up. - Set to `-1` for infinite. Defaults to `3`. + Set to `-1` for infinite. Defaults to ``3``. + filters: Optional[:class:`bool`] + Whether to use the new ``filters`` op. This setting currently only applies to development + Lavalink builds, where the ``equalizer`` op was swapped out for the broader ``filters`` op which + offers more than just equalizer functionality. Ideally, you should only change this setting if you + know what you're doing, as this can prevent the effects from working. + ssl: Optional[:class:`bool`] + Whether to use SSL for the node. SSL will use ``wss`` and ``https``, instead of ``ws`` and ``http``, + respectively. Your node should support SSL if you intend to enable this, either via reverse proxy or + other methods. Only enable this if you know what you're doing. """ - node = Node(self, host, port, password, region, resume_key, resume_timeout, name, reconnect_attempts) + node = Node(self, host, port, password, region, resume_key, resume_timeout, name, reconnect_attempts, filters, ssl) self.nodes.append(node) - self._lavalink._logger.info('[NODE-{}] Successfully added to Node Manager'.format(node.name)) + _log.info('Added node \'%s\'', node.name) def remove_node(self, node: Node): """ Removes a node. + Make sure you have called :func:`Node.destroy` to close any open WebSocket connection. + Parameters ---------- node: :class:`Node` The node to remove from the list. """ self.nodes.remove(node) - self._lavalink._logger.info('[NODE-{}] Successfully removed Node'.format(node.name)) + _log.info('Removed node \'%s\'', node.name) def get_region(self, endpoint: str): """ @@ -117,7 +159,7 @@ def find_ideal_node(self, region: str = None): Parameters ---------- region: Optional[:class:`str`] - The region to find a node in. Defaults to `None`. + The region to find a node in. Defaults to ``None``. Returns ------- @@ -145,11 +187,10 @@ async def _node_connect(self, node: Node): node: :class:`Node` The node that has just connected. """ - self._lavalink._logger.info('[NODE-{}] Successfully established connection'.format(node.name)) - for player in self._player_queue: await player.change_node(node) - self._lavalink._logger.debug('[NODE-{}] Successfully moved {}'.format(node.name, player.guild_id)) + original_node_name = player._original_node.name if player._original_node else '[no node]' + _log.debug('Moved player %d from node \'%s\' to node \'%s\'', player.guild_id, original_node_name, node.name) if self._lavalink._connect_back: for player in node._original_players: @@ -172,16 +213,19 @@ async def _node_disconnect(self, node: Node, code: int, reason: str): reason: :class:`str` The reason why the node was disconnected. """ - self._lavalink._logger.warning('[NODE-{}] Disconnected with code {} and reason {}'.format(node.name, code, - reason)) + for player in node.players: + try: + await player.node_unavailable() + except: # noqa: E722 pylint: disable=bare-except + _log.exception('An error occurred whilst calling player.node_unavailable()') + await self._lavalink._dispatch_event(NodeDisconnectedEvent(node, code, reason)) best_node = self.find_ideal_node(node.region) if not best_node: self._player_queue.extend(node.players) - self._lavalink._logger.error('Unable to move players, no available nodes! ' - 'Waiting for a node to become available.') + _log.error('Unable to move players, no available nodes! Waiting for a node to become available.') return for player in node.players: diff --git a/lavalink/playermanager.py b/lavalink/playermanager.py index c55da7d6..dc4f62ad 100644 --- a/lavalink/playermanager.py +++ b/lavalink/playermanager.py @@ -1,23 +1,48 @@ -from .exceptions import NodeException +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import logging + +from .errors import NodeError from .models import BasePlayer from .node import Node +_log = logging.getLogger(__name__) + class PlayerManager: """ Represents the player manager that contains all the players. len(x): - Returns the total amount of cached players. + Returns the total number of stored players. iter(x): - Returns an iterator of all the players cached. + Returns an iterator of all the stored players. Attributes ---------- players: :class:`dict` Cache of all the players that Lavalink has created. - default_player: :class:`BasePlayer` - The player that the player manager is initialized with. """ def __init__(self, lavalink, player): @@ -26,8 +51,8 @@ def __init__(self, lavalink, player): 'Player must implement BasePlayer or DefaultPlayer.') self._lavalink = lavalink + self._player_cls = player self.players = {} - self.default_player = player def __len__(self): return len(self.players) @@ -37,34 +62,6 @@ def __iter__(self): for guild_id, player in self.players.items(): yield guild_id, player - async def destroy(self, guild_id: int): - """ - Removes a player from cache, and also Lavalink if applicable. - Ensure you have disconnected the given guild_id from the voicechannel - first, if connected. - - Warning - ------- - This should only be used if you know what you're doing. Players should never be - destroyed unless they have been moved to another :class:`Node`. - - Parameters - ---------- - guild_id: int - The guild_id associated with the player to remove. - """ - if guild_id not in self.players: - return - - player = self.players.pop(guild_id) - - if player.node and player.node.available: - await player.node._send(op='destroy', guildId=player.guild_id) - player.cleanup() - - self._lavalink._logger.debug( - '[NODE-{}] Successfully destroyed player {}'.format(player.node.name, guild_id)) - def values(self): """ Returns an iterator that yields only values. """ for player in self.players.values(): @@ -77,30 +74,19 @@ def find_all(self, predicate=None): Parameters ---------- predicate: Optional[:class:`function`] - A predicate to return specific players. Defaults to `None`. + A predicate to return specific players. Defaults to ``None``. Returns ------- - List[:class:`DefaultPlayer`] + List[:class:`BasePlayer`] + This could be a :class:`DefaultPlayer` if no custom player implementation + was provided. """ if not predicate: return list(self.players.values()) return [p for p in self.players.values() if bool(predicate(p))] - def remove(self, guild_id: int): - """ - Removes a player from the internal cache. - - Parameters - ---------- - guild_id: :class:`int` - The player that will be removed. - """ - if guild_id in self.players: - player = self.players.pop(guild_id) - player.cleanup() - def get(self, guild_id: int): """ Gets a player from cache. @@ -112,11 +98,26 @@ def get(self, guild_id: int): Returns ------- - Optional[:class:`DefaultPlayer`] + Optional[:class:`BasePlayer`] + This could be a :class:`DefaultPlayer` if no custom player implementation + was provided. """ return self.players.get(guild_id) - def create(self, guild_id: int, region: str = 'eu', endpoint: str = None, node: Node = None): + def remove(self, guild_id: int): + """ + Removes a player from the internal cache. + + Parameters + ---------- + guild_id: :class:`int` + The player to remove from cache. + """ + if guild_id in self.players: + player = self.players.pop(guild_id) + player.cleanup() + + def create(self, guild_id: int, region: str = None, endpoint: str = None, node: Node = None): """ Creates a player if one doesn't exist with the given information. @@ -134,16 +135,19 @@ def create(self, guild_id: int, region: str = 'eu', endpoint: str = None, node: ---------- guild_id: :class:`int` The guild_id to associate with the player. - region: :class:`str` - The region to use when selecting a Lavalink node. Defaults to `eu`. - endpoint: :class:`str` - The address of the Discord voice server. Defaults to `None`. - node: :class:`Node` - The node to put the player on. Defaults to `None` and a node with the lowest penalty is chosen. + region: Optional[:class:`str`] + The region to use when selecting a Lavalink node. Defaults to ``None``. + endpoint: Optional[:class:`str`] + The address of the Discord voice server. Defaults to ``None``. + node: Optional[:class:`Node`] + The node to put the player on. Defaults to ``None`` and a node with the lowest penalty is chosen. Returns ------- - :class:`DefaultPlayer` + :class:`BasePlayer` + A class that inherits ``BasePlayer``. By default, the actual class returned will + be :class:`DefaultPlayer`, however if you have specified a custom player implementation, + then this will be different. """ if guild_id in self.players: return self.players[guild_id] @@ -154,9 +158,37 @@ def create(self, guild_id: int, region: str = 'eu', endpoint: str = None, node: best_node = node or self._lavalink.node_manager.find_ideal_node(region) if not best_node: - raise NodeException('No available nodes!') + raise NodeError('No available nodes!') - self.players[guild_id] = player = self.default_player(guild_id, best_node) - self._lavalink._logger.debug( - '[NODE-{}] Successfully created player for {}'.format(best_node.name, guild_id)) + id_int = int(guild_id) + self.players[id_int] = player = self._player_cls(id_int, best_node) + _log.debug('Created player with GuildId %d on node \'%s\'', id_int, best_node.name) return player + + async def destroy(self, guild_id: int): + """|coro| + + Removes a player from cache, and also Lavalink if applicable. + Ensure you have disconnected the given guild_id from the voicechannel + first, if connected. + + Warning + ------- + This should only be used if you know what you're doing. Players should never be + destroyed unless they have been moved to another :class:`Node`. + + Parameters + ---------- + guild_id: int + The guild_id associated with the player to remove. + """ + if guild_id not in self.players: + return + + player = self.players.pop(guild_id) + player.cleanup() + + if player.node: + await player.node._send(op='destroy', guildId=player._internal_id) + + _log.debug('Destroyed player with GuildId %d on node \'%s\'', guild_id, player.node.name if player.node else 'UNASSIGNED') diff --git a/lavalink/stats.py b/lavalink/stats.py index dba01ce8..9d80134f 100644 --- a/lavalink/stats.py +++ b/lavalink/stats.py @@ -1,3 +1,28 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + class Penalty: """ Represents the penalty of the stats of a Node. @@ -5,10 +30,15 @@ class Penalty: Attributes ---------- player_penalty: :class:`int` + The number of playing players. 1 player = 1 penalty point. cpu_penalty: :class:`int` + The penalty incurred from system CPU usage. null_frame_penalty: :class:`int` + The penalty incurred from the average number of null frames per minute. deficit_frame_penalty: :class:`int` + The penalty incurred from the average number of deficit frames per minute. total: :class:`int` + The overall penalty, as a sum of all other penalties. """ __slots__ = ('player_penalty', 'cpu_penalty', 'null_frame_penalty', 'deficit_frame_penalty', 'total') @@ -30,36 +60,41 @@ def __init__(self, stats): class Stats: """ - Represents the stats of Lavalink node. + Encapsulates the 'Statistics' emitted by Lavalink, usually every minute. Attributes ---------- + is_fake: :class:`bool` + Whether or not the stats are accurate. This should only be True when + the node has not yet received any statistics from the Lavalink server. uptime: :class:`int` - How long the node has been running for in milliseconds. + How long the node has been running for, in milliseconds. players: :class:`int` - The amount of players connected to the node. + The number of players connected to the node. playing_players: :class:`int` - The amount of players that are playing in the node. + The number of players that are playing in the node. memory_free: :class:`int` - The amount of memory free to the node. + The amount of memory free to the node, in bytes. memory_used: :class:`int` - The amount of memory that is used by the node. + The amount of memory that is used by the node, in bytes. memory_allocated: :class:`int` - The amount of memory allocated to the node. + The amount of memory allocated to the node, in bytes. memory_reservable: :class:`int` - The amount of memory reservable to the node. + The amount of memory reservable to the node, in bytes. cpu_cores: :class:`int` The amount of cpu cores the system of the node has. system_load: :class:`int` - The overall CPU load of the system. + The overall CPU load of the system. This is a number between 0-1, + but can be multiplied by 100 for the percentage (0-100). lavalink_load: :class:`int` - The CPU load generated by Lavalink. + The CPU load generated by Lavalink This is a number between 0-1, + but can be multiplied by 100 for the percentage (0-100). frames_sent: :class:`int` The number of frames sent to Discord. Warning ------- - Given that audio packets are sent via UDP, this number may not be 100% accurate due to dropped packets. + Given that audio packets are sent via UDP, this number may not be 100% accurate due to packets dropped in transit. frames_nulled: :class:`int` The number of frames that yielded null, rather than actual data. frames_deficit: :class:`int` @@ -68,13 +103,14 @@ class Stats: generating frames as quickly as it should be. penalty: :class:`Penalty` """ - __slots__ = ('_node', 'uptime', 'players', 'playing_players', 'memory_free', 'memory_used', 'memory_allocated', + __slots__ = ('_node', 'is_fake', 'uptime', 'players', 'playing_players', 'memory_free', 'memory_used', 'memory_allocated', 'memory_reservable', 'cpu_cores', 'system_load', 'lavalink_load', 'frames_sent', 'frames_nulled', 'frames_deficit', 'penalty') def __init__(self, node, data): self._node = node + self.is_fake = data.get('isFake', False) self.uptime = data['uptime'] self.players = data['players'] @@ -96,3 +132,25 @@ def __init__(self, node, data): self.frames_nulled = frame_stats.get('nulled', -1) self.frames_deficit = frame_stats.get('deficit', -1) self.penalty = Penalty(self) + + @classmethod + def empty(cls, node): + data = { + 'isFake': True, + 'uptime': 0, + 'players': 0, + 'playingPlayers': 0, + 'memory': { + 'free': 0, + 'used': 0, + 'allocated': 0, + 'reservable': 0 + }, + 'cpu': { + 'cores': 0, + 'systemLoad': 0, + 'lavalinkLoad': 0 + } + } + + return cls(node, data) diff --git a/lavalink/utfm_codec.py b/lavalink/utfm_codec.py new file mode 100644 index 00000000..18453aa3 --- /dev/null +++ b/lavalink/utfm_codec.py @@ -0,0 +1,71 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + + +def read_utfm(utf_len: int, utf_bytes: bytes): + chars = [] + count = 0 + + while count < utf_len: + c = utf_bytes[count] & 0xff + if c > 127: + break + + count += 1 + chars.append(chr(c)) + + while count < utf_len: + c = utf_bytes[count] & 0xff + shift = c >> 4 + + if 0 <= shift <= 7: + count += 1 + chars.append(chr(c)) + elif 12 <= shift <= 13: + count += 2 + if count > utf_len: + raise UnicodeDecodeError('malformed input: partial character at end') + char2 = utf_bytes[count - 1] + if (char2 & 0xC0) != 0x80: + raise UnicodeDecodeError('malformed input around byte ' + count) + + char_shift = ((c & 0x1F) << 6) | (char2 & 0x3F) + chars.append(chr(char_shift)) + elif shift == 14: + count += 3 + if count > utf_len: + raise UnicodeDecodeError('malformed input: partial character at end') + + char2 = utf_bytes[count - 2] + char3 = utf_bytes[count - 1] + + if (char2 & 0xC0) != 0x80 or (char3 & 0xC0) != 0x80: + raise UnicodeDecodeError('malformed input around byte ' + (count - 1)) + + char_shift = ((c & 0x0F) << 12) | ((char2 & 0x3F) << 6) | ((char3 & 0x3F) << 0) + chars.append(chr(char_shift)) + else: + raise UnicodeDecodeError('malformed input around byte ' + count) + + return ''.join(chars).encode('utf-16', 'surrogatepass').decode('utf-16') diff --git a/lavalink/utils.py b/lavalink/utils.py index 845fc07d..59ff63ae 100644 --- a/lavalink/utils.py +++ b/lavalink/utils.py @@ -1,10 +1,81 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import struct +from typing import Tuple from .datarw import DataReader from .models import AudioTrack -def format_time(time): +def timestamp_to_millis(timestamp: str) -> int: + """ + Converts a timestamp such as 03:28 or 02:15:53 to milliseconds. + + Example + ------- + .. code:: python + + await player.play(track, start_time=timestamp_to_millis('01:13')) + + Parameters + ---------- + timestamp: :class:`str` + The timestamp to convert into milliseconds. + + Returns + ------- + :class:`int` + The millisecond value of the timestamp. + """ + try: + sections = list(map(int, timestamp.split(':'))) + except ValueError: + raise ValueError('Timestamp should consist of integers and colons only') + + if not sections: + raise TypeError('An invalid timestamp was provided, a timestamp should look like 1:30') + + if len(sections) > 4: + raise TypeError('Too many segments within the provided timestamp! Provide no more than 4 segments.') + + if len(sections) == 4: + d, h, m, s = map(int, sections) + return (d * 86400000) + (h * 3600000) + (m * 60000) + (s * 1000) + + if len(sections) == 3: + h, m, s = map(int, sections) + return (h * 3600000) + (m * 60000) + (s * 1000) + + if len(sections) == 2: + m, s = map(int, sections) + return (m * 60000) + (s * 1000) + + s, = map(int, sections) + return s * 1000 + + +def format_time(time: int) -> str: """ Formats the given time into HH:MM:SS. @@ -23,7 +94,7 @@ def format_time(time): return '%02d:%02d:%02d' % (hours, minutes, seconds) -def parse_time(time): +def parse_time(time: int) -> Tuple[int, int, int, int]: """ Parses the given time into days, hours, minutes and seconds. Useful for formatting time yourself. @@ -44,7 +115,7 @@ def parse_time(time): return days, hours, minutes, seconds -def decode_track(track, decode_errors='ignore'): +def decode_track(track: str) -> AudioTrack: """ Decodes a base64 track string into an AudioTrack object. @@ -52,8 +123,6 @@ def decode_track(track, decode_errors='ignore'): ---------- track: :class:`str` The base64 track string. - decode_errors: :class:`str` - The action to take upon encountering erroneous characters within track titles. Returns ------- @@ -62,16 +131,16 @@ def decode_track(track, decode_errors='ignore'): reader = DataReader(track) flags = (reader.read_int() & 0xC0000000) >> 30 - version, = struct.unpack('B', reader.read_byte()) if flags & 1 != 0 else 1 # pylint: disable=unused-variable + version = struct.unpack('B', reader.read_byte()) if flags & 1 != 0 else 1 - title = reader.read_utf().decode(errors=decode_errors) - author = reader.read_utf().decode() + title = reader.read_utfm() + author = reader.read_utfm() length = reader.read_long() identifier = reader.read_utf().decode() is_stream = reader.read_boolean() uri = reader.read_utf().decode() if reader.read_boolean() else None source = reader.read_utf().decode() - position = reader.read_long() # noqa: F841 pylint: disable=unused-variable + position = reader.read_long() track_object = { 'track': track, @@ -82,11 +151,12 @@ def decode_track(track, decode_errors='ignore'): 'identifier': identifier, 'isStream': is_stream, 'uri': uri, - 'isSeekable': not is_stream + 'isSeekable': not is_stream, + 'sourceName': source } } - return AudioTrack(track_object, 0, source=source) + return AudioTrack(track_object, 0, position=position, encoder_version=version) # def encode_track(track: dict): diff --git a/lavalink/websocket.py b/lavalink/websocket.py index a1732f37..e9b51dba 100644 --- a/lavalink/websocket.py +++ b/lavalink/websocket.py @@ -1,17 +1,48 @@ +""" +MIT License + +Copyright (c) 2017-present Devoxin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" import asyncio +import logging import aiohttp -from .events import (TrackEndEvent, TrackExceptionEvent, +from .events import (PlayerUpdateEvent, TrackEndEvent, TrackExceptionEvent, TrackStuckEvent, WebSocketClosedEvent) from .stats import Stats from .utils import decode_track +_log = logging.getLogger(__name__) +CLOSE_TYPES = ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.CLOSED +) + class WebSocket: """ Represents the WebSocket connection with Lavalink. """ - def __init__(self, node, host: str, port: int, password: str, resume_key: str, resume_timeout: int, - reconnect_attempts: int): + def __init__(self, node, host: str, port: int, password: str, ssl: bool, resume_key: str, + resume_timeout: int, reconnect_attempts: int): self._node = node self._lavalink = self._node._manager._lavalink @@ -22,77 +53,95 @@ def __init__(self, node, host: str, port: int, password: str, resume_key: str, r self._host = host self._port = port self._password = password + self._ssl = ssl self._max_reconnect_attempts = reconnect_attempts self._resume_key = resume_key self._resume_timeout = resume_timeout self._resuming_configured = False + self._destroyed = False - self._user_id = self._lavalink._user_id - - self._closers = (aiohttp.WSMsgType.CLOSE, - aiohttp.WSMsgType.CLOSING, - aiohttp.WSMsgType.CLOSED) - - asyncio.ensure_future(self.connect()) + self.connect() @property def connected(self): """ Returns whether the websocket is connected to Lavalink. """ return self._ws is not None and not self._ws.closed - async def connect(self): + async def close(self, code=aiohttp.WSCloseCode.OK): + """|coro| + + Shuts down the websocket connection if there is one. + """ + if self._ws: + await self._ws.close(code=code) + self._ws = None + + def connect(self): """ Attempts to establish a connection to Lavalink. """ + return asyncio.ensure_future(self._connect()) + + async def destroy(self): + """|coro| + + Closes the WebSocket gracefully, and stops any further reconnecting. + Useful when needing to remove a node. + """ + self._destroyed = True + await self.close() + + async def _connect(self): + if self._ws: + await self.close() + headers = { 'Authorization': self._password, - 'User-Id': str(self._user_id), + 'User-Id': str(self._lavalink._user_id), 'Client-Name': 'Lavalink.py', 'Num-Shards': '1' # Legacy header that is no longer used. Here for compatibility. - } # soonTM: User-Agent? Also include version in Client-Name as per optional implementation format. + } + # TODO: 'User-Agent': 'Lavalink.py/{} (https://github.com/devoxin/lavalink.py)'.format(__version__) if self._resuming_configured and self._resume_key: headers['Resume-Key'] = self._resume_key is_finite_retry = self._max_reconnect_attempts != -1 - max_attempts_str = 'inf' if is_finite_retry else self._max_reconnect_attempts + max_attempts_str = self._max_reconnect_attempts if is_finite_retry else 'inf' attempt = 0 while not self.connected and (not is_finite_retry or attempt < self._max_reconnect_attempts): attempt += 1 - self._lavalink._logger.info('[NODE-{}] Attempting to establish WebSocket ' - 'connection ({}/{})...'.format(self._node.name, attempt, max_attempts_str)) + _log.info('[Node:%s] Attempting to establish WebSocket connection (%d/%s)...', self._node.name, attempt, max_attempts_str) + protocol = 'wss' if self._ssl else 'ws' try: - self._ws = await self._session.ws_connect('ws://{}:{}'.format(self._host, self._port), headers=headers, - heartbeat=60) + self._ws = await self._session.ws_connect('{}://{}:{}'.format(protocol, self._host, self._port), + headers=headers, heartbeat=60) except (aiohttp.ClientConnectorError, aiohttp.WSServerHandshakeError, aiohttp.ServerDisconnectedError) as ce: if isinstance(ce, aiohttp.ClientConnectorError): - self._lavalink._logger.warning('[NODE-{}] Invalid response received; this may indicate that ' - 'Lavalink is not running, or is running on a port different ' - 'to the one you passed to `add_node`.'.format(self._node.name)) + _log.warning('[Node:%s] Invalid response received; this may indicate that ' + 'Lavalink is not running, or is running on a port different ' + 'to the one you provided to `add_node`.', self._node.name) elif isinstance(ce, aiohttp.WSServerHandshakeError): if ce.status in (401, 403): # Special handling for 401/403 (Unauthorized/Forbidden). - self._lavalink._logger.warning('[NODE-{}] Authentication failed while trying to ' - 'establish a connection to the node.' - .format(self._node.name)) + _log.warning('[Node:%s] Authentication failed while trying to establish a connection to the node.', + self._node.name) # We shouldn't try to establish any more connections as correcting this particular error # would require the cog to be reloaded (or the bot to be rebooted), so further attempts # would be futile, and a waste of resources. return - self._lavalink._logger.warning('[NODE-{}] The remote server returned code {}, ' - 'the expected code was 101. This usually ' - 'indicates that the remote server is a webserver ' - 'and not Lavalink. Check your ports, and try again.' - .format(self._node.name, ce.status)) + _log.warning('[Node:%s] The remote server returned code %d, the expected code was 101. This usually ' + 'indicates that the remote server is a webserver and not Lavalink. Check your ports, ' + 'and try again.', self._node.name, ce.status) else: - self._lavalink._logger.exception('[Node:{}] An unknown error occurred whilst trying to establish ' - 'a connection to Lavalink'.format(self._node.name)) + _log.exception('[Node:%s] An unknown error occurred whilst trying to establish ' + 'a connection to Lavalink', self._node.name) backoff = min(10 * attempt, 60) await asyncio.sleep(backoff) else: + _log.info('[Node:%s] WebSocket connection established', self._node.name) await self._node._manager._node_connect(self._node) - # asyncio.ensure_future(self._listen()) if not self._resuming_configured and self._resume_key \ and (self._resume_timeout and self._resume_timeout > 0): @@ -109,25 +158,24 @@ async def connect(self): # Ensure this loop doesn't proceed if _listen returns control back to this function. return - self._lavalink._logger.warning('[NODE-{}] A WebSocket connection could not be established within 3 ' - 'attempts.'.format(self._node.name)) + _log.warning('[Node:%s] A WebSocket connection could not be established within %s attempts.', self._node.name, max_attempts_str) async def _listen(self): """ Listens for websocket messages. """ async for msg in self._ws: - self._lavalink._logger.debug('[NODE-{}] Received WebSocket message: {}'.format(self._node.name, msg.data)) + _log.debug('[Node:%s] Received WebSocket message: %s', self._node.name, msg.data) if msg.type == aiohttp.WSMsgType.TEXT: await self._handle_message(msg.json()) elif msg.type == aiohttp.WSMsgType.ERROR: exc = self._ws.exception() - self._lavalink._logger.error('[NODE-{}] Exception in WebSocket! {}.'.format(self._node.name, exc)) + _log.error('[Node:%s] Exception in WebSocket!', self._node.name, exc_info=exc) break - elif msg.type in self._closers: - self._lavalink._logger.debug('[NODE-{}] Received close frame with code {}.'.format(self._node.name, msg.data)) + elif msg.type in CLOSE_TYPES: + _log.debug('[Node:%s] Received close frame with code %d.', self._node.name, msg.data) await self._websocket_closed(msg.data, msg.extra) return - await self._websocket_closed() + await self._websocket_closed(self._ws.close_code, 'AsyncIterator loop exited') async def _websocket_closed(self, code: int = None, reason: str = None): """ @@ -135,16 +183,17 @@ async def _websocket_closed(self, code: int = None, reason: str = None): Parameters ---------- - code: :class:`int` + code: Optional[:class:`int`] The response code. - reason: :class:`str` - Reason why the websocket was closed. Defaults to `None` + reason: Optional[:class:`str`] + Reason why the websocket was closed. Defaults to ``None`` """ - self._lavalink._logger.debug('[NODE-{}] WebSocket disconnected with the following: code={} ' - 'reason={}'.format(self._node.name, code, reason)) + _log.warning('[Node:%s] WebSocket disconnected with the following: code=%d reason=%s', self._node.name, code, reason) self._ws = None await self._node._manager._node_disconnect(self._node, code, reason) - await self.connect() + + if not self._destroyed: + await self._connect() async def _handle_message(self, data: dict): """ @@ -160,16 +209,19 @@ async def _handle_message(self, data: dict): if op == 'stats': self._node.stats = Stats(self._node, data) elif op == 'playerUpdate': - player = self._lavalink.player_manager.get(int(data['guildId'])) + player_id = data['guildId'] + player = self._lavalink.player_manager.get(int(player_id)) if not player: + _log.debug('[Node:%s] Received playerUpdate for non-existent player! GuildId: %s', self._node.name, player_id) return await player._update_state(data['state']) + await self._lavalink._dispatch_event(PlayerUpdateEvent(player, data['state'])) elif op == 'event': await self._handle_event(data) else: - self._lavalink._logger.warning('[NODE-{}] Received unknown op: {}'.format(self._node.name, op)) + _log.warning('[Node:%s] Received unknown op: %s', self._node.name, op) async def _handle_event(self, data: dict): """ @@ -181,29 +233,34 @@ async def _handle_event(self, data: dict): The data given from Lavalink. """ player = self._lavalink.player_manager.get(int(data['guildId'])) + event_type = data['type'] if not player: - self._lavalink._logger.warning('[NODE-{}] Received event for non-existent player! GuildId: {}' - .format(self._node.name, data['guildId'])) + if event_type not in ('TrackEndEvent', 'WebSocketClosedEvent'): # Player was most likely destroyed if it's any of these. + _log.warning('[Node:%s] Received event type %s for non-existent player! GuildId: %s', self._node.name, event_type, data['guildId']) return - event_type = data['type'] event = None if event_type == 'TrackEndEvent': - track = decode_track(data['track']) + track = decode_track(data['track']) if data['track'] else None event = TrackEndEvent(player, track, data['reason']) elif event_type == 'TrackExceptionEvent': - event = TrackExceptionEvent(player, player.current, data['error']) - elif event_type == 'TrackStartEvent': - pass + exc_inner = data.get('exception', {}) + exception = data.get('error') or exc_inner.get('cause', 'Unknown exception') + severity = exc_inner.get('severity', 'UNKNOWN') + event = TrackExceptionEvent(player, player.current, exception, severity) + # elif event_type == 'TrackStartEvent': # event = TrackStartEvent(player, player.current) elif event_type == 'TrackStuckEvent': event = TrackStuckEvent(player, player.current, data['thresholdMs']) elif event_type == 'WebSocketClosedEvent': event = WebSocketClosedEvent(player, data['code'], data['reason'], data['byRemote']) else: - self._lavalink._logger.warning('[NODE-{}] Unknown event received: {}'.format(self._node.name, event_type)) + if event_type == 'TrackStartEvent': + return + + _log.warning('[Node:%s] Unknown event received of type \'%s\'', self._node.name, event_type) return await self._lavalink._dispatch_event(event) @@ -220,9 +277,13 @@ async def _send(self, **data): data: :class:`dict` The data sent to Lavalink. """ - if self.connected: - self._lavalink._logger.debug('[NODE-{}] Sending payload {}'.format(self._node.name, str(data))) - await self._ws.send_json(data) - else: - self._lavalink._logger.debug('[NODE-{}] Send called before WebSocket ready!'.format(self._node.name)) + if not self.connected: + _log.debug('[Node:%s] WebSocket not ready; queued outgoing payload', self._node.name) self._message_queue.append(data) + return + + _log.debug('[Node:%s] Sending payload %s', self._node.name, str(data)) + try: + await self._ws.send_json(data) + except ConnectionResetError: + _log.warning('[Node:%s] Failed to send payload due to connection reset!', self._node.name) diff --git a/run_tests.py b/run_tests.py index a7b38eec..7e1e3d8a 100644 --- a/run_tests.py +++ b/run_tests.py @@ -15,12 +15,14 @@ def test_flake8(): if not failed: print('OK') + return failed + def test_pylint(): stdout = StringIO() reporter = text.TextReporter(stdout) - opts = ['--max-line-length=150', '--score=no', '--disable=missing-docstring, wildcard-import, ' - 'attribute-defined-outside-init, too-few-public-methods, ' + opts = ['--max-line-length=150', '--score=no', '--disable=missing-docstring,wildcard-import,' + 'attribute-defined-outside-init,too-few-public-methods,' 'old-style-class,import-error,invalid-name,no-init,' 'too-many-instance-attributes,protected-access,too-many-arguments,' 'too-many-public-methods,logging-format-interpolation,' diff --git a/setup.py b/setup.py index da3b8681..25076712 100644 --- a/setup.py +++ b/setup.py @@ -14,9 +14,10 @@ name='lavalink', packages=['lavalink'], version=version, - description='A lavalink interface built for discord.py', + description='A Lavalink WebSocket & API wrapper built around coverage, reliability and performance.', author='Devoxin', author_email='luke@serux.pro', + entry_points={'console_scripts': ['lavalink = lavalink.__main__:main']}, url='https://github.com/Devoxin/Lavalink.py', download_url='https://github.com/Devoxin/Lavalink.py/archive/{}.tar.gz'.format(version), keywords=['lavalink'], @@ -24,7 +25,9 @@ install_requires=['aiohttp>=3.7.4,<3.9.0'], extras_require={'docs': ['sphinx', 'pygments', - 'guzzle_sphinx_theme'], + 'guzzle_sphinx_theme', + 'enum_tools', + 'sphinx_toolbox'], 'development': ['pylint', 'flake8']} )