From f7db07e7ace6782f390259fd0fb2c72540cb407d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 14 Jul 2023 19:11:51 +1000 Subject: [PATCH 001/132] Add .gitignore. --- .gitignore | 160 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..68bc17f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ From bae62cbccdb5b54f0b5abf6f92c9e6c54c1422f2 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 14 Jul 2023 19:15:31 +1000 Subject: [PATCH 002/132] Remove Spotify Ext and Example (Need to be rewritten) --- examples/simple.py | 75 ----- examples/spotify_autoplay.py | 90 ------ wavelink/ext/spotify/__init__.py | 511 ------------------------------- wavelink/ext/spotify/utils.py | 171 ----------- 4 files changed, 847 deletions(-) delete mode 100644 examples/simple.py delete mode 100644 examples/spotify_autoplay.py delete mode 100644 wavelink/ext/spotify/__init__.py delete mode 100644 wavelink/ext/spotify/utils.py diff --git a/examples/simple.py b/examples/simple.py deleted file mode 100644 index 237e248b..00000000 --- a/examples/simple.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 discord -import wavelink -from discord.ext import commands - - -class Bot(commands.Bot): - - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True - - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node]) - - -bot = Bot() - - -@bot.command() -async def play(ctx: commands.Context, *, search: str) -> None: - """Simple play command.""" - - if not ctx.voice_client: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - else: - vc: wavelink.Player = ctx.voice_client - - tracks: list[wavelink.YouTubeTrack] = await wavelink.YouTubeTrack.search(search) - if not tracks: - await ctx.send(f'Sorry I could not find any songs with search: `{search}`') - return - - track: wavelink.YouTubeTrack = tracks[0] - await vc.play(track) - - -@bot.command() -async def disconnect(ctx: commands.Context) -> None: - """Simple disconnect command. - - This command assumes there is a currently connected Player. - """ - vc: wavelink.Player = ctx.voice_client - await vc.disconnect() diff --git a/examples/spotify_autoplay.py b/examples/spotify_autoplay.py deleted file mode 100644 index f2fa569b..00000000 --- a/examples/spotify_autoplay.py +++ /dev/null @@ -1,90 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 discord -import wavelink -from discord.ext import commands -from wavelink.ext import spotify - - -class Bot(commands.Bot): - - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True - - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - # Fill your Spotify API details and pass it to connect. - sc = spotify.SpotifyClient( - client_id='CLIENT_ID', - client_secret='SECRET' - ) - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node], spotify=sc) - - -bot = Bot() - - -@bot.command() -async def play(ctx: commands.Context, *, search: str) -> None: - """Simple play command that accepts a Spotify song URL. - - This command enables AutoPlay. AutoPlay finds songs automatically when used with Spotify. - Tracks added to the Queue will be played in front (Before) of those added by AutoPlay. - """ - - if not ctx.voice_client: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - else: - vc: wavelink.Player = ctx.voice_client - - # Check the search to see if it matches a valid Spotify URL... - decoded = spotify.decode_url(search) - if not decoded or decoded['type'] is not spotify.SpotifySearchType.track: - await ctx.send('Only Spotify Track URLs are valid.') - return - - # Set autoplay to True. This can be disabled at anytime... - vc.autoplay = True - - tracks: list[spotify.SpotifyTrack] = await spotify.SpotifyTrack.search(search) - if not tracks: - await ctx.send('This does not appear to be a valid Spotify URL.') - return - - track: spotify.SpotifyTrack = tracks[0] - - # IF the player is not playing immediately play the song... - # otherwise put it in the queue... - if not vc.is_playing(): - await vc.play(track, populate=True) - else: - await vc.queue.put_wait(track) diff --git a/wavelink/ext/spotify/__init__.py b/wavelink/ext/spotify/__init__.py deleted file mode 100644 index 4a6a4c2c..00000000 --- a/wavelink/ext/spotify/__init__.py +++ /dev/null @@ -1,511 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 __future__ import annotations - -import asyncio -import base64 -import logging -import time -from typing import Any, List, Optional, Type, TypeVar, Union, TYPE_CHECKING - -import aiohttp -from discord.ext import commands - -import wavelink -from wavelink import Node, NodePool - -from .utils import * - - -if TYPE_CHECKING: - from wavelink import Player, Playable - - -__all__ = ( - 'SpotifySearchType', - 'SpotifyClient', - 'SpotifyTrack', - 'SpotifyRequestError', - 'decode_url', - 'SpotifyDecodePayload' -) - - -logger: logging.Logger = logging.getLogger(__name__) - - -ST = TypeVar("ST", bound="Playable") - - -class SpotifyAsyncIterator: - - def __init__(self, *, query: str, limit: int, type: SpotifySearchType, node: Node): - self._query = query - self._limit = limit - self._type = type - self._node = node - - self._first = True - self._count = 0 - self._queue = asyncio.Queue() - - def __aiter__(self): - return self - - async def fill_queue(self): - tracks = await self._node._spotify._search(query=self._query, iterator=True, type=self._type) - - for track in tracks: - await self._queue.put(track) - - async def __anext__(self): - if self._first: - await self.fill_queue() - self._first = False - - if self._limit is not None and self._count == self._limit: - raise StopAsyncIteration - - try: - track = self._queue.get_nowait() - except asyncio.QueueEmpty as e: - raise StopAsyncIteration from e - - if track is None: - return await self.__anext__() - - track = SpotifyTrack(track) - - self._count += 1 - return track - - -class SpotifyRequestError(Exception): - """Base error for Spotify requests. - - Attributes - ---------- - status: int - The status code returned from the request. - reason: Optional[str] - The reason the request failed. Could be None. - """ - - def __init__(self, status: int, reason: Optional[str] = None): - self.status = status - self.reason = reason - - -class SpotifyTrack: - """A track retrieved via Spotify. - - - .. container:: operations - - .. describe:: str(track) - - Returns a string representing this SpotifyTrack's name and artists. - - .. describe:: repr(track) - - Returns an official string representation of this SpotifyTrack. - - .. describe:: track == other_track - - Check whether a track is equal to another. Tracks are equal if they both have the same Spotify ID. - - - Attributes - ---------- - raw: dict[str, Any] - The raw payload from Spotify for this track. - album: str - The album name this track belongs to. - images: list[str] - A list of URLs to images associated with this track. - artists: list[str] - A list of artists for this track. - genres: list[str] - A list of genres associated with this tracks artist. - name: str - The track name. - title: str - An alias to name. - uri: str - The URI for this spotify track. - id: str - The spotify ID for this track. - isrc: str | None - The International Standard Recording Code associated with this track. - length: int - The track length in milliseconds. - duration: int - Alias to length. - explicit: bool - Whether this track is explicit or not. - """ - - __slots__ = ( - 'raw', - 'album', - 'images', - 'artists', - 'name', - 'title', - 'uri', - 'id', - 'length', - 'duration', - 'explicit', - 'isrc', - '__dict__' - ) - - def __init__(self, data: dict[str, Any]) -> None: - self.raw: dict[str, Any] = data - - album = data['album'] - self.album: str = album['name'] - self.images: list[str] = [i['url'] for i in album['images']] - - artists = data['artists'] - self.artists: list[str] = [a['name'] for a in artists] - # self.genres: list[str] = [a['genres'] for a in artists] - - self.name: str = data['name'] - self.title: str = self.name - self.uri: str = data['uri'] - self.id: str = data['id'] - self.length: int = data['duration_ms'] - self.duration: int = self.length - self.isrc: str | None = data.get("external_ids", {}).get('irsc') - self.explicit: bool = data.get('explicit', False) in {"true", True} - - def __str__(self) -> str: - return f'{self.name} - {self.artists[0]}' - - def __repr__(self) -> str: - return f'SpotifyTrack(id={self.id}, isrc={self.isrc}, name={self.name}, duration={self.duration})' - - def __eq__(self, other) -> bool: - if isinstance(other, SpotifyTrack): - return self.id == other.id - raise NotImplemented - - @classmethod - async def search( - cls, - query: str, - *, - node: Node | None = None, - ) -> list['SpotifyTrack']: - """|coro| - - Search for tracks with the given query. - - Parameters - ---------- - query: str - The Spotify URL to query for. - node: Optional[:class:`wavelink.Node`] - An optional Node to use to make the search with. - - Returns - ------- - List[:class:`SpotifyTrack`] - - Examples - -------- - Searching for a singular tack to play... - - .. code:: python3 - - tracks: list[spotify.SpotifyTrack] = await spotify.SpotifyTrack.search(query=SPOTIFY_URL) - if not tracks: - # No tracks found, do something? - return - - track: spotify.SpotifyTrack = tracks[0] - - - Searching for all tracks in an album... - - .. code:: python3 - - tracks: list[spotify.SpotifyTrack] = await spotify.SpotifyTrack.search(query=SPOTIFY_URL) - if not tracks: - # No tracks found, do something? - return - - - .. versionchanged:: 2.6.0 - - This method no longer takes in the ``type`` parameter. The query provided will be automatically decoded, - if the ``type`` returned by :func:`decode_url` is unusable, this method will return an empty :class:`list` - """ - if node is None: - node: Node = NodePool.get_connected_node() - - decoded: SpotifyDecodePayload = decode_url(query) - - if not decoded or decoded.type is SpotifySearchType.unusable: - logger.debug(f'Spotify search handled an unusable search type for query: "{query}".') - return [] - - return await node._spotify._search(query=query, type=decoded.type) - - @classmethod - def iterator(cls, - *, - query: str, - limit: int | None = None, - node: Node | None = None, - ): - """An async iterator version of search. - - This can be useful when searching for large playlists or albums with Spotify. - - Parameters - ---------- - query: str - The Spotify URL to search for. Must be of type Playlist or Album. - limit: Optional[int] - Limit the amount of tracks returned. - node: Optional[:class:`wavelink.Node`] - An optional node to use when querying for tracks. Defaults to the best available. - - Examples - -------- - - .. code:: python3 - - async for track in spotify.SpotifyTrack.iterator(query=...): - ... - - - .. versionchanged:: 2.6.0 - - This method no longer takes in the ``type`` parameter. The query provided will be automatically decoded, - if the ``type`` returned by :func:`decode_url` is not either ``album`` or ``playlist`` this method will - raise a :exc:`TypeError`. - """ - decoded: SpotifyDecodePayload = decode_url(query) - - if not decoded or decoded.type is not SpotifySearchType.album and decoded.type is not SpotifySearchType.playlist: - raise TypeError('Spotify iterator query must be either a valid Spotify album or playlist URL.') - - if node is None: - node = NodePool.get_connected_node() - - return SpotifyAsyncIterator(query=query, limit=limit, node=node, type=decoded.type) - - @classmethod - async def convert(cls: Type[ST], ctx: commands.Context, argument: str) -> ST: - """Converter which searches for and returns the first track. - - Used as a type hint in a - `discord.py command `_. - """ - results = await cls.search(argument) - - if not results: - raise commands.BadArgument(f"Could not find any songs matching query: {argument}") - - return results[0] - - async def fulfill(self, *, player: Player, cls: Playable, populate: bool) -> Playable: - """Used to fulfill the :class:`wavelink.Player` Auto Play Queue. - - .. warning:: - - Usually you would not call this directly. Instead you would set :attr:`wavelink.Player.autoplay` to true, - and allow the player to fulfill this request automatically. - - - Parameters - ---------- - player: :class:`wavelink.player.Player` - If :attr:`wavelink.Player.autoplay` is enabled, this search will fill the AutoPlay Queue with more tracks. - cls - The class to convert this Spotify Track to. - """ - - if not self.isrc: - tracks: list[cls] = await cls.search(f'{self.name} - {self.artists[0]}') - else: - tracks: list[cls] = await cls.search(f'"{self.isrc}"') - if not tracks: - tracks: list[cls] = await cls.search(f'{self.name} - {self.artists[0]}') - - if not player.autoplay or not populate: - return tracks[0] - - node: Node = player.current_node - sc: SpotifyClient | None = node._spotify - - if not sc: - raise RuntimeError(f"There is no spotify client associated with <{node:!r}>") - - if sc.is_token_expired(): - await sc._get_bearer_token() - - if len(player._track_seeds) == 5: - player._track_seeds.pop(0) - - player._track_seeds.append(self.id) - - url: str = RECURL.format(tracks=','.join(player._track_seeds)) - async with node._session.get(url=url, headers=sc.bearer_headers) as resp: - if resp.status != 200: - raise SpotifyRequestError(resp.status, resp.reason) - - data = await resp.json() - - recos = [SpotifyTrack(t) for t in data['tracks']] - for reco in recos: - if reco in player.auto_queue or reco in player.auto_queue.history: - continue - - await player.auto_queue.put_wait(reco) - - return tracks[0] - - -class SpotifyClient: - """Spotify client passed to :class:`wavelink.Node` for searching via Spotify. - - Parameters - ---------- - client_id: str - Your spotify application client ID. - client_secret: str - Your spotify application secret. - """ - - def __init__(self, *, client_id: str, client_secret: str): - self._client_id = client_id - self._client_secret = client_secret - - self.session = aiohttp.ClientSession() - - self._bearer_token: str | None = None - self._expiry: int = 0 - - @property - def grant_headers(self) -> dict: - authbytes = f'{self._client_id}:{self._client_secret}'.encode() - return {'Authorization': f'Basic {base64.b64encode(authbytes).decode()}', - 'Content-Type': 'application/x-www-form-urlencoded'} - - @property - def bearer_headers(self) -> dict: - return {'Authorization': f'Bearer {self._bearer_token}'} - - async def _get_bearer_token(self) -> None: - async with self.session.post(GRANTURL, headers=self.grant_headers) as resp: - if resp.status != 200: - raise SpotifyRequestError(resp.status, resp.reason) - - data = await resp.json() - self._bearer_token = data['access_token'] - self._expiry = time.time() + (int(data['expires_in']) - 10) - - def is_token_expired(self) -> bool: - return time.time() >= self._expiry - - async def _search(self, - query: str, - type: SpotifySearchType = SpotifySearchType.track, - iterator: bool = False, - ) -> list[SpotifyTrack]: - - if self.is_token_expired(): - await self._get_bearer_token() - - regex_result = URLREGEX.match(query) - - url = ( - BASEURL.format( - entity=regex_result['type'], identifier=regex_result['id'] - ) - if regex_result - else BASEURL.format(entity=type.name, identifier=query) - ) - - async with self.session.get(url, headers=self.bearer_headers) as resp: - if resp.status == 400: - return [] - - elif resp.status != 200: - raise SpotifyRequestError(resp.status, resp.reason) - - data = await resp.json() - - if data['type'] == 'track': - return [SpotifyTrack(data)] - - elif data['type'] == 'album': - album_data: dict[str, Any] = { - 'album_type': data['album_type'], - 'artists': data['artists'], - 'available_markets': data['available_markets'], - 'external_urls': data['external_urls'], - 'href': data['href'], - 'id': data['id'], - 'images': data['images'], - 'name': data['name'], - 'release_date': data['release_date'], - 'release_date_precision': data['release_date_precision'], - 'total_tracks': data['total_tracks'], - 'type': data['type'], - 'uri': data['uri'], - } - tracks = [] - for track in data['tracks']['items']: - track['album'] = album_data - if iterator: - tracks.append(track) - else: - tracks.append(SpotifyTrack(track)) - - return tracks - - elif data['type'] == 'playlist': - if not iterator: - return [SpotifyTrack(t['track']) for t in data['tracks']['items']] - if not data['tracks']['next']: - return [t['track'] for t in data['tracks']['items']] - - url = data['tracks']['next'] - - items = [t['track'] for t in data['tracks']['items']] - while True: - async with self.session.get(url, headers=self.bearer_headers) as resp: - data = await resp.json() - - items.extend([t['track'] for t in data['items']]) - if not data['next']: - return items - - url = data['next'] diff --git a/wavelink/ext/spotify/utils.py b/wavelink/ext/spotify/utils.py deleted file mode 100644 index 9576f285..00000000 --- a/wavelink/ext/spotify/utils.py +++ /dev/null @@ -1,171 +0,0 @@ -""" -MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 enum -import re -from typing import Any, Optional - - -__all__ = ( - 'GRANTURL', - 'URLREGEX', - 'BASEURL', - 'RECURL', - 'SpotifyDecodePayload', - 'decode_url', - 'SpotifySearchType' -) - - -GRANTURL = 'https://accounts.spotify.com/api/token?grant_type=client_credentials' -URLREGEX = re.compile(r'(https?://open.)?(spotify)(.com/|:)(.*[/:])?' - r'(?Palbum|playlist|track|artist|show|episode)([/:])' - r'(?P[a-zA-Z0-9]+)(\?si=[a-zA-Z0-9]+)?(&dl_branch=[0-9]+)?') -BASEURL = 'https://api.spotify.com/v1/{entity}s/{identifier}' -RECURL = 'https://api.spotify.com/v1/recommendations?seed_tracks={tracks}' - - -class SpotifySearchType(enum.Enum): - """An enum specifying which type to search for with a given Spotify ID. - - Attributes - ---------- - track - Track search type. - album - Album search type. - playlist - Playlist search type. - unusable - Unusable type. This type is assigned when Wavelink can not be used to play this track. - """ - track = 0 - album = 1 - playlist = 2 - unusable = 3 - - -class SpotifyDecodePayload: - """The SpotifyDecodePayload received when using :func:`decode_url`. - - .. container:: operations - - .. describe:: repr(payload) - - Returns an official string representation of this payload. - - .. describe:: payload['attr'] - - Allows this object to be accessed like a dictionary. - - - Attributes - ---------- - type: :class:`SpotifySearchType` - Either track, album or playlist. - If type is not track, album or playlist, a special unusable type is assigned. - id: str - The Spotify ID associated with the decoded URL. - - - .. note:: - - To keep backwards compatibility with previous versions of :func:`decode_url`, you can access this object like - a dictionary. - - - .. warning:: - - You do not instantiate this object manually. Instead you receive it via :func:`decode_url`. - - - .. versionadded:: 2.6.0 - """ - - def __init__(self, *, type_: SpotifySearchType, id_: str) -> None: - self.__type = type_ - self.__id = id_ - - def __repr__(self) -> str: - return f'SpotifyDecodePayload(type={self.type}, id={self.id})' - - @property - def type(self) -> SpotifySearchType: - return self.__type - - @property - def id(self) -> str: - return self.__id - - def __getitem__(self, item: Any) -> SpotifySearchType | str: - valid: list[str] = ['type', 'id'] - - if item not in valid: - raise KeyError(f'SpotifyDecodePayload object has no key {item}. Valid keys are "{valid}".') - - return getattr(self, item) - - -def decode_url(url: str) -> SpotifyDecodePayload | None: - """Check whether the given URL is a valid Spotify URL and return a :class:`SpotifyDecodePayload` if this URL - is valid, or ``None`` if this URL is invalid. - - Parameters - ---------- - url: str - The URL to check. - - Returns - ------- - Optional[:class:`SpotifyDecodePayload`] - The decode payload containing the Spotify :class:`SpotifySearchType` and Spotify ID. - - Could return ``None`` if the URL is invalid. - - Examples - -------- - - .. code:: python3 - - from wavelink.ext import spotify - - - decoded = spotify.decode_url("https://open.spotify.com/track/6BDLcvvtyJD2vnXRDi1IjQ?si=e2e5bd7aaf3d4a2a") - - - .. versionchanged:: 2.6.0 - - This function now returns :class:`SpotifyDecodePayload`. For backwards compatibility you can access this - payload like a dictionary. - """ - match = URLREGEX.match(url) - if match: - try: - type_ = SpotifySearchType[match['type']] - except KeyError: - type_ = SpotifySearchType.unusable - - return SpotifyDecodePayload(type_=type_, id_=match['id']) - - return None - From fa68514630eafee7c6a4e778cd96564f416de38d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 14 Jul 2023 19:17:00 +1000 Subject: [PATCH 003/132] Initial 3.0 commit. --- requirements.txt | 2 +- wavelink/__init__.py | 13 +- wavelink/__main__.py | 19 +- wavelink/backoff.py | 6 +- wavelink/enums.py | 61 +- wavelink/exceptions.py | 82 +- wavelink/node.py | 621 ++++---------- wavelink/payloads.py | 164 ++-- wavelink/player.py | 800 ++---------------- wavelink/tracks.py | 375 ++------ wavelink/types/events.py | 36 - wavelink/types/request.py | 41 +- wavelink/types/response.py | 125 +++ wavelink/types/state.py | 53 +- .../connecting.py => wavelink/types/stats.py | 30 +- wavelink/types/track.py | 19 - wavelink/types/tracks.py | 56 ++ wavelink/types/websocket.py | 111 +++ wavelink/websocket.py | 311 +++---- 19 files changed, 985 insertions(+), 1940 deletions(-) delete mode 100644 wavelink/types/events.py create mode 100644 wavelink/types/response.py rename examples/connecting.py => wavelink/types/stats.py (59%) delete mode 100644 wavelink/types/track.py create mode 100644 wavelink/types/tracks.py create mode 100644 wavelink/types/websocket.py diff --git a/requirements.txt b/requirements.txt index b880f057..baea3836 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ aiohttp>=3.7.4,<4 discord.py>=2.0.1 -yarl~=1.8.2 \ No newline at end of file +yarl==1.9.2 \ No newline at end of file diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 071e158c..692c8276 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,13 +25,10 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "2.6.0" +__version__ = "3.0.0b1" -from .enums import * + +from .enums import NodeStatus from .exceptions import * -from .node import * -from .payloads import * -from .player import Player as Player -from .tracks import * -from .queue import * -from .filters import * +from .node import Node, Pool +from .player import Player diff --git a/wavelink/__main__.py b/wavelink/__main__.py index 4e1d4717..041507ab 100644 --- a/wavelink/__main__.py +++ b/wavelink/__main__.py @@ -4,34 +4,30 @@ import sys import aiohttp -import discord import wavelink - -parser = argparse.ArgumentParser(prog='wavelink') -parser.add_argument('--version', action='store_true', help='Get version and debug information for wavelink.') +parser = argparse.ArgumentParser(prog="wavelink") +parser.add_argument("--version", action="store_true", help="Get version and debug information for wavelink.") args = parser.parse_args() def get_debug_info() -> None: - python_info = '\n'.join(sys.version.split('\n')) - java_version = subprocess.check_output(['java', '-version'], stderr=subprocess.STDOUT) - java_version = f'\n{" " * 8}- '.join(v for v in java_version.decode().split('\r\n') if v) + python_info = "\n".join(sys.version.split("\n")) + java_version = subprocess.check_output(["java", "-version"], stderr=subprocess.STDOUT) + java_version = f'\n{" " * 8}- '.join(v for v in java_version.decode().split("\r\n") if v) info: str = f""" + wavelink: {wavelink.__version__} + Python: - {python_info} System: - {platform.platform()} Java: - {java_version or "Version Not Found"} - Libraries: - - wavelink : v{wavelink.__version__} - - discord.py : v{discord.__version__} - - aiohttp : v{aiohttp.__version__} """ print(info) @@ -39,4 +35,3 @@ def get_debug_info() -> None: if args.version: get_debug_info() - diff --git a/wavelink/backoff.py b/wavelink/backoff.py index 606719a9..5fac35bc 100644 --- a/wavelink/backoff.py +++ b/wavelink/backoff.py @@ -1,14 +1,18 @@ """ The MIT License (MIT) + Copyright (c) 2021-Present PythonistaGuild, EvieePy, Rapptz + 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 @@ -49,7 +53,7 @@ def __init__(self, *, base: int = 1, maximum_time: float = 30.0, maximum_tries: self._last_wait: float = 0 def calculate(self) -> float: - exponent = min((self._retries ** 2), self._maximum_time) + exponent = min((self._retries**2), self._maximum_time) wait = self._rand(0, (self._base * 2) * exponent) if wait <= self._last_wait: diff --git a/wavelink/enums.py b/wavelink/enums.py index 4d750b4a..a43e313e 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -21,13 +21,13 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from discord.enums import Enum +import enum -__all__ = ('NodeStatus', 'TrackSource', 'LoadType', 'TrackEventType', 'DiscordVoiceCloseType') +__all__ = ("NodeStatus", "TrackSource", "DiscordVoiceCloseType") -class NodeStatus(Enum): - """Enum representing the current status of a Node. +class NodeStatus(enum.Enum): + """Enum representing the connection status of a Node. Attributes ---------- @@ -44,8 +44,8 @@ class NodeStatus(Enum): CONNECTED = 2 -class TrackSource(Enum): - """Enum representing the Track Source Type. +class TrackSource(enum.Enum): + """Enum representing a :class:`Playable` source. Attributes ---------- @@ -55,58 +55,14 @@ class TrackSource(Enum): 1 SoundCloud 2 - Local - 3 - Unknown - 4 """ YouTube = 0 YouTubeMusic = 1 SoundCloud = 2 - Local = 3 - Unknown = 4 -class LoadType(Enum): - """Enum representing the Tracks Load Type. - - Attributes - ---------- - track_loaded - "TRACK_LOADED" - playlist_loaded - "PLAYLIST_LOADED" - search_result - "SEARCH_RESULT" - no_matches - "NO_MATCHES" - load_failed - "LOAD_FAILED" - """ - track_loaded = "TRACK_LOADED" - playlist_loaded = "PLAYLIST_LOADED" - search_result = "SEARCH_RESULT" - no_matches = "NO_MATCHES" - load_failed = "LOAD_FAILED" - - -class TrackEventType(Enum): - """Enum representing the TrackEvent types. - - Attributes - ---------- - START - "TrackStartEvent" - END - "TrackEndEvent" - """ - - START = 'TrackStartEvent' - END = 'TrackEndEvent' - - -class DiscordVoiceCloseType(Enum): +class DiscordVoiceCloseType(enum.Enum): """Enum representing the various Discord Voice Websocket Close Codes. Attributes @@ -138,6 +94,7 @@ class DiscordVoiceCloseType(Enum): UNKNOWN_ENCRYPTION_MODE 4016 """ + CLOSE_NORMAL = 1000 # Not Discord but standard websocket UNKNOWN_OPCODE = 4001 FAILED_DECODE_PAYLOAD = 4002 diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py index c615b1e4..2622d272 100644 --- a/wavelink/exceptions.py +++ b/wavelink/exceptions.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -21,74 +21,60 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from __future__ import annotations -from typing import Any __all__ = ( - 'WavelinkException', - 'AuthorizationFailed', - 'InvalidNode', - 'InvalidLavalinkVersion', - 'InvalidLavalinkResponse', - 'NoTracksError', - 'QueueEmpty', - 'InvalidChannelStateError', - 'InvalidChannelPermissions', + "WavelinkException", + "InvalidClientException", + "AuthorizationFailedException", + "InvalidNodeException", + "LavalinkException", + "InvalidChannelStateException", ) class WavelinkException(Exception): - """Base wavelink exception.""" + """Base wavelink Exception class. - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args) + All wavelink exceptions derive from this exception. + """ -class AuthorizationFailed(WavelinkException): - """Exception raised when password authorization failed for this Lavalink node.""" - pass +class InvalidClientException(WavelinkException): + """Exception raised when an invalid :class:`discord.Client` + is provided while connecting a :class:`wavelink.Node`. + """ -class InvalidNode(WavelinkException): - pass +class AuthorizationFailedException(WavelinkException): + """Exception raised when Lavalink fails to authenticate a :class:`~wavelink.Node`, with the provided password.""" -class InvalidLavalinkVersion(WavelinkException): - """Exception raised when you try to use wavelink 2 with a Lavalink version under 3.7.""" - pass +class InvalidNodeException(WavelinkException): + """Exception raised when a :class:`Node` is tried to be retrieved from the + :class:`Pool` without existing, or the ``Pool`` is empty. + """ -class InvalidLavalinkResponse(WavelinkException): - """Exception raised when wavelink receives an invalid response from Lavalink. +class LavalinkException(WavelinkException): + """Exception raised when Lavalink returns an invalid response. Attributes ---------- - status: int | None - The status code. Could be None. + status: int + The response status code. + reason: str | None + The response reason. Could be ``None`` if no reason was provided. """ - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args) - self.status: int | None = kwargs.get('status') - - -class NoTracksError(WavelinkException): - """Exception raised when no tracks could be found.""" - pass + def __init__(self, message: str, /, *, status: int, reason: str | None) -> None: + self.status = status + self.reason = reason + super().__init__(message) -class QueueEmpty(WavelinkException): - """Exception raised when you try to retrieve from an empty queue.""" - pass - -class InvalidChannelStateError(WavelinkException): - """Base exception raised when an error occurs trying to connect to a :class:`discord.VoiceChannel`.""" - - -class InvalidChannelPermissions(InvalidChannelStateError): - """Exception raised when the client does not have correct permissions to join the channel. - - Could also be raised when there are too many users already in a user limited channel. - """ \ No newline at end of file +class InvalidChannelStateException(WavelinkException): + """Exception raised when a :class:`~wavelink.Player` tries to connect to an invalid channel or + has invalid permissions to use this channel. + """ diff --git a/wavelink/node.py b/wavelink/node.py index 5261c2cc..c73c3242 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -24,551 +24,306 @@ from __future__ import annotations import logging -import random -import re -import string -from typing import TYPE_CHECKING, Any, TypeVar +import secrets +from typing import TYPE_CHECKING, Iterable, Union import aiohttp import discord -from discord.enums import try_enum -from discord.utils import MISSING, classproperty -import urllib.parse +from discord.utils import classproperty from . import __version__ -from .enums import LoadType, NodeStatus -from .exceptions import * +from .enums import NodeStatus +from .exceptions import (AuthorizationFailedException, InvalidClientException, + InvalidNodeException, LavalinkException) from .websocket import Websocket if TYPE_CHECKING: from .player import Player - from .tracks import * + from .tracks import Playable, Playlist from .types.request import Request - from .ext import spotify as spotify_ + from .types.response import (EmptyLoadedResponse, ErrorLoadedResponse, + ErrorResponse, InfoResponse, PlayerResponse, + PlaylistLoadedResponse, SearchLoadedResponse, + StatsResponse, TrackLoadedResponse, + UpdateResponse) + from .types.tracks import PlaylistPayload, TrackPayload - PlayableT = TypeVar('PlayableT', bound=Playable) - - -__all__ = ('Node', 'NodePool') + LoadedResponse = Union[ + TrackLoadedResponse, SearchLoadedResponse, PlaylistLoadedResponse, EmptyLoadedResponse, ErrorLoadedResponse + ] logger: logging.Logger = logging.getLogger(__name__) -# noinspection PyShadowingBuiltins class Node: - """The base Wavelink Node. - - The Node is responsible for keeping the Websocket alive, tracking the state of Players - and fetching/decoding Tracks and Playlists. - - .. note:: - - The Node class should only be created once per Lavalink connection. - To retrieve a Node use the appropriate :class:`NodePool` methods instead. - - .. warning:: - - The Node will not be connected until passed to :meth:`NodePool.connect`. - - - .. container:: operations - - .. describe:: repr(node) - - Returns an official string representation of this Node. - - - Parameters - ---------- - id: Optional[str] - The unique identifier for this Node. If not passed, one will be generated randomly. - uri: str - The uri to connect to your Lavalink server. E.g ``http://localhost:2333``. - password: str - The password used to connect to your Lavalink server. - secure: Optional[bool] - Whether the connection should use https/wss. - use_http: Optional[bool] - Whether to use http:// over ws:// when connecting to the Lavalink websocket. Defaults to False. - session: Optional[aiohttp.ClientSession] - The session to use for this Node. If no session is passed a default will be used. - heartbeat: float - The time in seconds to send a heartbeat ack. Defaults to 15.0. - retries: Optional[int] - The amount of times this Node will try to reconnect after a disconnect. - If not set the Node will try unlimited times. - - Attributes - ---------- - heartbeat: float - The time in seconds to send a heartbeat ack. Defaults to 15.0. - client: :class:`discord.Client` - The discord client used to connect this Node. Could be None if this Node has not been connected. - """ - def __init__( - self, - *, - id: str | None = None, - uri: str, - password: str, - secure: bool = False, - use_http: bool = False, - session: aiohttp.ClientSession = MISSING, - heartbeat: float = 15.0, - retries: int | None = None, + self, + *, + identifier: str | None = None, + uri: str, + password: str, + session: aiohttp.ClientSession | None = None, + heartbeat: float = 15.0, + retries: int | None = None, + client: discord.Client | None = None, ) -> None: - if id is None: - id = ''.join(random.sample(string.ascii_letters + string.digits, 12)) - - self._id: str = id - self._uri: str = uri - self._secure: bool = secure - self._use_http: bool = use_http - host: str = re.sub(r'(?:http|ws)s?://', '', self._uri) - self._host: str = f'{"https://" if secure else "http://"}{host}' - self._password: str = password - - self._session: aiohttp.ClientSession = session - self.heartbeat: float = heartbeat - self._retries: int | None = retries - - self.client: discord.Client | None = None - self._websocket: Websocket = MISSING - self._session_id: str | None = None - - self._players: dict[int, Player] = {} - self._invalidated: dict[int, Player] = {} + self._identifier = identifier or secrets.token_urlsafe(12) + self._uri = uri.removesuffix("/") + self._password = password + self._session = session or aiohttp.ClientSession() + self._heartbeat = heartbeat + self._retries = retries + self._client = client self._status: NodeStatus = NodeStatus.DISCONNECTED - self._major_version: int | None = None + self._session_id: str | None = None - self._spotify: spotify_.SpotifyClient | None = None + self._players: dict[int, Player] = {} def __repr__(self) -> str: - return f'Node(id="{self._id}", uri="{self.uri}", status={self.status})' + return f"Node(identifier={self.identifier}, uri={self.uri}, status={self.status}, players={len(self.players)})" def __eq__(self, other: object) -> bool: - return self.id == other.id if isinstance(other, Node) else NotImplemented + if not isinstance(other, Node): + raise NotImplemented - @property - def id(self) -> str: - """The Nodes unique identifier.""" - return self._id + return other.identifier == self.identifier @property - def uri(self) -> str: - """The URI used to connect this Node to Lavalink.""" - return self._host + def headers(self) -> dict[str, str]: + assert self.client is not None + assert self.client.user is not None - @property - def password(self) -> str: - """The password used to connect this Node to Lavalink.""" - return self._password + data = { + "Authorization": self.password, + "User-Id": str(self.client.user.id), + "Client-Name": f"Wavelink/{__version__}", + } - @property - def players(self) -> dict[int, Player]: - """A mapping of Guild ID to Player.""" - return self._players + return data @property - def status(self) -> NodeStatus: - """The connection status of this Node. + def identifier(self) -> str: + """The unique identifier for this :class:`Node`. - DISCONNECTED - CONNECTING - CONNECTED - """ - return self._status - - def get_player(self, guild_id: int, /) -> Player | None: - """Return the :class:`player.Player` associated with the provided guild ID. - - If no :class:`player.Player` is found, returns None. - Parameters - ---------- - guild_id: int - The Guild ID to return a Player for. - - Returns - ------- - Optional[:class:`player.Player`] + .. versionchanged:: 3.0.0 + This property was previously known as ``id``. """ - return self._players.get(guild_id, None) - - async def _connect(self, client: discord.Client) -> None: - if client.user is None: - raise RuntimeError('') - - if not self._session: - self._session = aiohttp.ClientSession(headers={'Authorization': self._password}) - - self.client = client - - self._websocket = Websocket(node=self) - - await self._websocket.connect() - - async with self._session.get(f'{self._host}/version') as resp: - version: str = await resp.text() - - if version.endswith('-SNAPSHOT'): - self._major_version = 3 - return - - try: - version_tuple = tuple(int(v) for v in version.split('.')) - except ValueError: - logging.warning(f'Lavalink "{version}" is unknown and may not be compatible with: ' - f'Wavelink "{__version__}". Wavelink is assuming the Lavalink version.') - - self._major_version = 3 - return - - if version_tuple[0] < 3: - raise InvalidLavalinkVersion(f'Wavelink "{__version__}" is not compatible with Lavalink "{version}".') - - if version_tuple[0] == 3 and version_tuple[1] < 7: - raise InvalidLavalinkVersion(f'Wavelink "{__version__}" is not compatible with ' - f'Lavalink versions under "3.7".') - - self._major_version = version_tuple[0] - logger.info(f'Lavalink version "{version}" connected for Node: {self.id}') - - async def _send(self, - *, - method: str, - path: str, - guild_id: int | str | None = None, - query: str | None = None, - data: Request | None = None, - ) -> dict[str, Any] | None: - - uri: str = f'{self._host}/' \ - f'v{self._major_version}/' \ - f'{path}' \ - f'{f"/{guild_id}" if guild_id else ""}' \ - f'{f"?{query}" if query else ""}' - - logger.debug(f'Node {self} is sending payload to [{method}] "{uri}" with payload: {data}') - - async with self._session.request(method=method, url=uri, json=data or {}) as resp: - rdata: dict[str | int, Any] | None = None + return self._identifier - if resp.content_type == 'application/json': - rdata = await resp.json() - - logger.debug(f'Node {self} received payload from Lavalink after sending to "{uri}" with response: ' - f'') - - if resp.status >= 300: - raise InvalidLavalinkResponse(f'An error occurred when attempting to reach: "{uri}".', - status=resp.status) - - if resp.status == 204: - return + @property + def uri(self) -> str: + """The URI used to connect this :class:`Node` to Lavalink.""" + return self._uri - return rdata + @property + def status(self) -> NodeStatus: + """The current :class:`Node` status. - async def get_tracks(self, cls: type[PlayableT], query: str) -> list[PlayableT]: - """|coro| + Refer to: :class:`~wavelink.NodeStatus` + """ + return self._status - Search for and retrieve Tracks based on the query and cls provided. + @property + def players(self) -> dict[int, Player]: + """A mapping of :attr:`discord.Guild.id` to :class:`~wavelink.Player`.""" + return self._players - .. note:: + @property + def client(self) -> discord.Client | None: + """The :class:`discord.Client` associated with this :class:`Node`. - If the query is not a Local search or direct URL, you will need to provide a search prefix. - E.g. ``ytsearch:`` for a YouTube search. + Could be ``None`` if it has not been set yet. - Parameters - ---------- - cls: type[PlayableT] - The type of Playable tracks that should be returned. - query: str - The query to search for and return tracks. - Returns - ------- - list[PlayableT] - A list of found tracks converted to the provided cls. + .. versionadded:: 3.0.0 """ - logger.debug(f'Node {self} is requesting tracks with query "{query}".') + return self._client - data = await self._send(method='GET', path='loadtracks', query=f'identifier={query}') - load_type = try_enum(LoadType, data.get("loadType")) + @property + def password(self) -> str: + """The password used to connect this :class:`Node` to Lavalink. - if load_type is LoadType.load_failed: - # TODO - Proper Exception... + .. versionadded:: 3.0.0 + """ + return self._password - raise ValueError('Track Failed to load.') + @property + def heartbeat(self) -> float: + """The duration in seconds that the :class:`Node` websocket should send a heartbeat. - if load_type is LoadType.no_matches: - return [] + .. versionadded:: 3.0.0 + """ + return self._heartbeat - if load_type is LoadType.track_loaded: - track_data = data["tracks"][0] - return [cls(track_data)] + @property + def session_id(self) -> str | None: + """The Lavalink session ID. Could be None if this :class:`Node` has not connected yet. - if load_type is not LoadType.search_result: - # TODO - Proper Exception... - raise ValueError('Track Failed to load.') + .. versionadded:: 3.0.0 + """ + return self._session_id - return [cls(track_data) for track_data in data["tracks"]] + async def _connect(self, *, client: discord.Client | None) -> None: + client_ = self._client or client - async def get_playlist(self, cls: Playlist, query: str): - """|coro| + if not client_: + raise InvalidClientException(f"Unable to connect {self!r} as you have not provided a valid discord.Client.") - Search for and return a :class:`tracks.Playlist` given an identifier. + self._client = client_ + websocket: Websocket = Websocket(node=self) + await websocket.connect() - Parameters - ---------- - cls: Type[:class:`tracks.Playlist`] - The type of which playlist should be returned, this must subclass :class:`tracks.Playlist`. - query: str - The playlist's identifier. This may be a YouTube playlist URL for example. + async def _fetch_players(self) -> list[PlayerResponse]: + ... - Returns - ------- - Optional[:class:`tracks.Playlist`]: - The related wavelink track object or ``None`` if none was found. + async def _fetch_player(self) -> PlayerResponse: + ... - Raises - ------ - ValueError - Loading the playlist failed. - WavelinkException - An unspecified error occurred when loading the playlist. - """ - logger.debug(f'Node {self} is requesting a playlist with query "{query}".') + async def _update_player(self, guild_id: int, /, *, data: Request, replace: bool = False) -> PlayerResponse: + no_replace: bool = not replace - encoded_query = urllib.parse.quote(query) - data = await self._send(method='GET', path='loadtracks', query=f'identifier={encoded_query}') + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players/{guild_id}?noReplace={no_replace}" - load_type = try_enum(LoadType, data.get("loadType")) + async with self._session.patch(url=uri, json=data, headers=self.headers) as resp: + if resp.status == 200: + resp_data: PlayerResponse = await resp.json() + return resp_data - if load_type is LoadType.load_failed: - # TODO Proper exception... - raise ValueError('Tracks failed to Load.') + raise LavalinkException( + f"Failed to fulfill request to Lavalink: status={resp.status}, reason={resp.reason}", + status=resp.status, + reason=resp.reason, + ) - if load_type is LoadType.no_matches: - return None + async def _destroy_player(self) -> None: + ... - if load_type is not LoadType.playlist_loaded: - raise WavelinkException("Track failed to load.") + async def _update_session(self) -> UpdateResponse: + ... - return cls(data) + async def _fetch_tracks(self) -> LoadedResponse: + ... - async def build_track(self, *, cls: type[PlayableT], encoded: str) -> PlayableT: - """|coro| + async def _decode_track(self) -> TrackPayload: + ... - Build a track from the provided encoded string with the given Track class. - - Parameters - ---------- - cls: type[PlayableT] - The type of Playable track that should be returned. - encoded: str - The Tracks unique encoded string. - """ - encoded_query = urllib.parse.quote(encoded) - data = await self._send(method='GET', path='decodetrack', query=f'encodedTrack={encoded_query}') - - logger.debug(f'Node {self} built encoded track with encoding "{encoded}". Response data: {data}') - return cls(data=data) + async def _decode_tracks(self) -> list[TrackPayload]: + ... + async def _fetch_info(self) -> InfoResponse: + ... -# noinspection PyShadowingBuiltins -class NodePool: - """The Wavelink NodePool is responsible for keeping track of all :class:`Node`. + async def _fetch_stats(self) -> StatsResponse: + ... - Attributes - ---------- - nodes: dict[str, :class:`Node`] - A mapping of :class:`Node` identifier to :class:`Node`. + async def _fetch_version(self) -> str: + ... + def get_player(self, guild_id: int, /) -> Player | None: + return self._players.get(guild_id, None) - .. warning:: - - This class should never be initialised. All methods are class methods. - """ +class Pool: __nodes: dict[str, Node] = {} @classmethod - async def connect( - cls, - *, - client: discord.Client, - nodes: list[Node], - spotify: spotify_.SpotifyClient | None = None - ) -> dict[str, Node]: + async def connect(cls, *, nodes: Iterable[Node], client: discord.Client | None = None) -> dict[str, Node]: """|coro| - Connect a list of Nodes. + Connect the provided Iterable[:class:`Node`] to Lavalink. Parameters ---------- - client: :class:`discord.Client` - The discord Client or Bot used to connect the Nodes. - nodes: list[:class:`Node`] - A list of Nodes to connect. - spotify: Optional[:class:`ext.spotify.SpotifyClient`] - The spotify Client to use when searching for Spotify Tracks. + nodes: Iterable[:class:`Node`] + The :class:`Node`'s to connect to Lavalink. + client: :class:`discord.Client` | None + The :class:`discord.Client` to use to connect the :class:`Node`. If the Node already has a client + set, this method will *not* override it. Defaults to None. Returns ------- dict[str, :class:`Node`] - A mapping of :class:`Node` identifier to :class:`Node`. - """ - if client.user is None: - raise RuntimeError('') + A mapping of :attr:`Node.identifier` to :class:`Node` associated with the :class:`Pool`. + + .. versionchanged:: 3.0.0 + The ``client`` parameter is no longer required. + """ for node in nodes: + client_ = node.client or client - if spotify: - node._spotify = spotify + if node.identifier in cls.__nodes: + msg: str = f'Unable to connect {node!r} as you already have a node with identifier "{node.identifier}"' + logger.error(msg) - if node.id in cls.__nodes: - logger.error(f'A Node with the ID "{node.id}" already exists on the NodePool. Disregarding.') + continue + + if node.status in (NodeStatus.CONNECTING, NodeStatus.CONNECTED): + logger.error(f"Unable to connect {node!r} as it is already in a connecting or connected state.") continue try: - await node._connect(client) - except AuthorizationFailed: - logger.error(f'The Node <{node!r}> failed to authenticate properly. ' - f'Please check your password and try again.') + await node._connect(client=client_) + except InvalidClientException as e: + logger.error(e) + except AuthorizationFailedException: + logger.error(f"Failed to authenticate {node!r} on Lavalink with the provided password.") else: - cls.__nodes[node.id] = node + cls.__nodes[node.identifier] = node return cls.nodes @classproperty def nodes(cls) -> dict[str, Node]: - """A mapping of :class:`Node` identifier to :class:`Node`.""" - return cls.__nodes + """A mapping of :attr:`Node.identifier` to :class:`Node` that have previously been successfully connected. + + + .. versionchanged:: 3.0.0 + This property now returns a copy. + """ + nodes = cls.__nodes.copy() + return nodes @classmethod - def get_node(cls, id: str | None = None) -> Node: - """Retrieve a :class:`Node` with the given ID or best, if no ID was passed. + def get_node(cls, identifier: str | None = None, /) -> Node: + """Retrieve a :class:`Node` from the :class:`Pool` with the given identifier. + + If no identifier is provided, this method returns the ``best`` node. Parameters ---------- - id: Optional[str] - The unique identifier of the :class:`Node` to retrieve. If not passed the best :class:`Node` - will be fetched. - - Returns - ------- - :class:`Node` + identifier: str | None + An optional identifier to retrieve a :class:`Node`. Raises ------ - InvalidNode - The given id does nto resolve to a :class:`Node` or no :class:`Node` has been connected. - """ - if id: - if id not in cls.__nodes: - raise InvalidNode(f'A Node with ID "{id}" does not exist on the Wavelink NodePool.') + InvalidNodeException + Raised when a Node can not be found, or no :class:`Node` exists on the :class:`Pool`. - return cls.__nodes[id] + .. versionchanged:: 3.0.0 + The ``id`` parameter was changed to ``identifier``. + """ if not cls.__nodes: - raise InvalidNode('No Node currently exists on the Wavelink NodePool.') - - nodes = cls.__nodes.values() - return sorted(nodes, key=lambda n: len(n.players))[0] - - @classmethod - def get_connected_node(cls) -> Node: - """Get the best available connected :class:`Node`. - - Returns - ------- - :class:`Node` - The best available connected Node. + raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool.") - Raises - ------ - InvalidNode - No Nodes are currently in the connected state. - """ + if identifier: + if identifier not in cls.__nodes: + raise InvalidNodeException(f'A Node with the identifier "{identifier}" does not exist.') - nodes: list[Node] = [n for n in cls.__nodes.values() if n.status is NodeStatus.CONNECTED] - if not nodes: - raise InvalidNode('There are no Nodes on the Wavelink NodePool that are currently in the connected state.') + return cls.__nodes[identifier] + nodes: list[Node] = list(cls.__nodes.values()) return sorted(nodes, key=lambda n: len(n.players))[0] @classmethod - async def get_tracks(cls_, # type: ignore - query: str, - /, - *, - cls: type[PlayableT], - node: Node | None = None - ) -> list[PlayableT]: - """|coro| - - Helper method to retrieve tracks from the NodePool without fetching a :class:`Node`. - - Parameters - ---------- - query: str - The query to search for and return tracks. - cls: type[PlayableT] - The type of Playable tracks that should be returned. - node: Optional[:class:`Node`] - The node to use for retrieving tracks. If not passed, the best :class:`Node` will be used. - Defaults to None. - - Returns - ------- - list[PlayableT] - A list of found tracks converted to the provided cls. - """ - if not node: - node = cls_.get_connected_node() - - return await node.get_tracks(cls=cls, query=query) + async def _fetch_tracks(cls, query: str, /, cls_: type[Playable]) -> list[Playable]: + ... @classmethod - async def get_playlist(cls_, # type: ignore - query: str, - /, - *, - cls: Playlist, - node: Node | None = None - ) -> Playlist: - """|coro| - - Helper method to retrieve a playlist from the NodePool without fetching a :class:`Node`. - - - .. warning:: - - The only playlists currently supported are :class:`tracks.YouTubePlaylist` and - :class:`tracks.YouTubePlaylist`. - - - Parameters - ---------- - query: str - The query to search for and return a playlist. - cls: type[PlayableT] - The type of Playlist that should be returned. - node: Optional[:class:`Node`] - The node to use for retrieving tracks. If not passed, the best :class:`Node` will be used. - Defaults to None. - - Returns - ------- - Playlist - A Playlist with its tracks. - """ - if not node: - node = cls_.get_connected_node() - - return await node.get_playlist(cls=cls, query=query) + async def _fetch_playlist(cls, query: str, /, cls_: type[Playlist]) -> Playlist: + ... diff --git a/wavelink/payloads.py b/wavelink/payloads.py index 662b670d..8547cf0f 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,73 +23,101 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from discord.enums import try_enum - -from .enums import TrackEventType, DiscordVoiceCloseType +from .enums import DiscordVoiceCloseType +from .player import Player +from .tracks import Playable if TYPE_CHECKING: - from .player import Player - from .tracks import Playable - from .types.events import EventOp - -__all__ = ('TrackEventPayload', 'WebsocketClosedPayload') - - -class TrackEventPayload: - """The Wavelink Track Event Payload. - - .. warning:: - - This class should not be created manually, instead you will receive it from the - various wavelink track events. - - Attributes - ---------- - event: :class:`TrackEventType` - An enum of the type of event. - track: :class:`Playable` - The track associated with this event. - original: Optional[:class:`Playable`] - The original requested track before conversion. Could be None. - player: :class:`player.Player` - The player associated with this event. - reason: Optional[str] - The reason this event was fired. - """ - - def __init__(self, *, data: EventOp, track: Playable, original: Playable | None, player: Player) -> None: - self.event: TrackEventType = try_enum(TrackEventType, data['type']) - self.track: Playable = track - self.original: Playable | None = original - self.player: Player = player - - self.reason: str = data.get('reason') - - -class WebsocketClosedPayload: - """The Wavelink WebsocketClosed Event Payload. - - .. warning:: - - This class should not be created manually, instead you will receive it from the - wavelink `on_wavelink_websocket_closed` event. - - Attributes - ---------- - code: :class:`DiscordVoiceCloseType` - An Enum representing the close code from Discord. - reason: Optional[str] - The reason the Websocket was closed. - by_discord: bool - Whether the websocket was closed by Discord. - player: :class:`player.Player` - The player associated with this event. - """ - - def __init__(self, *, data: dict[str, Any], player: Player) -> None: - self.code: DiscordVoiceCloseType = try_enum(DiscordVoiceCloseType, data['code']) - self.reason: str = data.get('reason') - self.by_discord: bool = data.get('byRemote') - self.player: Player = player + from .types.state import PlayerState + from .types.stats import CPUStats, FrameStats, MemoryStats + from .types.websocket import StatsOP, TrackExceptionPayload + + +__all__ = ( + "TrackStartEventPayload", + "TrackEndEventPayload", + "TrackExceptionEventPayload", + "TrackStuckEventPayload", + "WebsocketClosedEventPayload", + "PlayerUpdateEventPayload", + "StatsEventPayload", +) + + +class TrackStartEventPayload: + def __init__(self, player: Player | None, track: Playable) -> None: + self.player = player + self.track = track + + +class TrackEndEventPayload: + def __init__(self, player: Player | None, track: Playable, reason: str) -> None: + self.player = player + self.track = track + self.reason = reason + + +class TrackExceptionEventPayload: + def __init__(self, player: Player | None, track: Playable, exception: TrackExceptionPayload) -> None: + self.player = player + self.track = track + self.exception = exception + + +class TrackStuckEventPayload: + def __init__(self, player: Player | None, track: Playable, threshold: int) -> None: + self.player = player + self.track = track + self.threshold = threshold + + +class WebsocketClosedEventPayload: + def __init__(self, player: Player | None, code: int, reason: str, by_remote: bool) -> None: + self.player = player + self.code: DiscordVoiceCloseType = DiscordVoiceCloseType(code) + self.reason = reason + self.by_remote = by_remote + + +class PlayerUpdateEventPayload: + def __init__(self, player: Player | None, state: PlayerState) -> None: + self.player = player + self.time: int = state["time"] + self.position: int = state["position"] + self.connected: bool = state["connected"] + self.ping: int = state["ping"] + + +class StatsEventMemory: + def __init__(self, data: MemoryStats) -> None: + self.free: int = data["free"] + self.used: int = data["used"] + self.allocated: int = data["allocated"] + self.reservable: int = data["reservable"] + + +class StatsEventCPU: + def __init__(self, data: CPUStats) -> None: + self.cores: int = data["cores"] + self.system_load: float = data["systemLoad"] + self.lavalink_load: float = data["lavalinkLoad"] + + +class StatsEventFrames: + def __init__(self, data: FrameStats) -> None: + self.sent: int = data["sent"] + self.nulled: int = data["nulled"] + self.deficit: int = data["deficit"] + + +class StatsEventPayload: + def __init__(self, data: StatsOP) -> None: + self.players: int = data["players"] + self.playing: int = data["playingPlayers"] + self.uptime: int = data["uptime"] + + self.memory: StatsEventMemory = StatsEventMemory(data=data["memory"]) + self.cpu: StatsEventCPU = StatsEventCPU(data=data["cpu"]) + self.frames: StatsEventFrames = StatsEventFrames(data=data["frameStats"]) diff --git a/wavelink/player.py b/wavelink/player.py index 9d0dc981..aa23b9e7 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,803 +23,161 @@ """ from __future__ import annotations -import datetime import logging from typing import TYPE_CHECKING, Any, Union import discord from discord.utils import MISSING -from .enums import * -from .exceptions import * -from .ext import spotify -from .filters import Filter -from .node import Node, NodePool -from .payloads import TrackEventPayload -from .queue import Queue -from .tracks import * - +from .exceptions import InvalidChannelStateException +from .node import Pool if TYPE_CHECKING: - from discord.types.voice import GuildVoiceState, VoiceServerUpdate + from discord.types.voice import GuildVoiceState as GuildVoiceStatePayload + from discord.types.voice import \ + VoiceServerUpdate as VoiceServerUpdatePayload from typing_extensions import Self - from .types.events import PlayerState, PlayerUpdateOp - from .types.request import EncodedTrackRequest, Request - from .types.state import DiscordVoiceState - -__all__ = ("Player",) + from .node import Node + from .types.request import Request as RequestPayload + from .types.response import PlayerResponse + from .types.state import PlayerVoiceState, VoiceState logger: logging.Logger = logging.getLogger(__name__) -VoiceChannel = Union[ - discord.VoiceChannel, discord.StageChannel -] # todo: VocalGuildChannel? +VoiceChannel = Union[discord.VoiceChannel, discord.StageChannel] class Player(discord.VoiceProtocol): - """Wavelink Player class. - - This class is used as a :class:`~discord.VoiceProtocol` and inherits all its members. - - You must pass this class to :meth:`discord.VoiceChannel.connect` with ``cls=...``. This ensures the player is - set up correctly and put into the discord.py voice client cache. - - You **can** make an instance of this class *before* passing it to - :meth:`discord.VoiceChannel.connect` with ``cls=...``, but you **must** still pass it. - - Once you have connected this class you do not need to store it anywhere as it will be stored on the - :class:`~wavelink.Node` and in the discord.py voice client cache. Meaning you can access this player where you - have access to a :class:`~wavelink.NodePool`, the specific :class:`~wavelink.Node` or a :class:`~discord.Guild` - including in :class:`~discord.ext.commands.Context` and :class:`~discord.Interaction`. - - Examples - -------- - - .. code:: python3 - - # Connect the player... - player: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - - # Retrieve the player later... - player: wavelink.Player = ctx.guild.voice_client - - - .. note:: - - The Player class comes with an in-built queue. See :class:`queue.Queue` for more information on how this queue - works. - - Parameters - ---------- - nodes: Optional[list[:class:`node.Node`]] - An optional list of :class:`node.Node` to use with this Player. If no Nodes are provided - the best connected Node will be used. - swap_node_on_disconnect: bool - If a list of :class:`node.Node` is provided the Player will swap Nodes on Node disconnect. - Defaults to True. + """ Attributes ---------- - client: :class:`discord.Client` - The discord Client or Bot associated with this Player. - channel: :class:`discord.VoiceChannel` - The channel this player is currently connected to. - nodes: list[:class:`node.Node`] - The list of Nodes this player is currently using. - current_node: :class:`node.Node` - The Node this player is currently using. - queue: :class:`queue.Queue` - The wavelink built in Queue. See :class:`queue.Queue`. This queue always takes precedence over the auto_queue. - Meaning any songs in this queue will be played before auto_queue songs. - auto_queue: :class:`queue.Queue` - The built-in AutoPlay Queue. This queue keeps track of recommended songs only. - When a song is retrieved from this queue in the AutoPlay event, - it is added to the main :attr:`wavelink.Queue.history` queue. + client: discord.Client + The :class:`discord.Client` associated with this :class:`Player`. + channel: discord.abc.Connectable | None + The currently connected :class:`discord.VoiceChannel`. + Could be None if this :class:`Player` has not been connected or has previously been disconnected. + """ def __call__(self, client: discord.Client, channel: VoiceChannel) -> Self: self.client = client self.channel = channel + self._guild = channel.guild return self def __init__( - self, - client: discord.Client = MISSING, - channel: VoiceChannel = MISSING, - *, - nodes: list[Node] | None = None, - swap_node_on_disconnect: bool = True + self, client: discord.Client = MISSING, channel: VoiceChannel = MISSING, *, nodes: list[Node] | None = None ) -> None: self.client: discord.Client = client - self.channel: VoiceChannel | None = channel - - self.nodes: list[Node] - self.current_node: Node - - if swap_node_on_disconnect and not nodes: - nodes = list(NodePool.nodes.values()) - self.nodes = sorted(nodes, key=lambda n: len(n.players)) - self.current_node = self.nodes[0] - elif nodes: - nodes = sorted(nodes, key=lambda n: len(n.players)) - self.current_node = nodes[0] - self.nodes = nodes - else: - self.current_node = NodePool.get_connected_node() - self.nodes = [self.current_node] - - if not self.client: - if self.current_node.client is None: - raise RuntimeError('') - self.client = self.current_node.client - + self.channel: VoiceChannel = channel self._guild: discord.Guild | None = None - self._voice_state: DiscordVoiceState = {} - self._player_state: dict[str, Any] = {} - - self.swap_on_disconnect: bool = swap_node_on_disconnect - self.last_update: datetime.datetime | None = None - self.last_position: int = 0 + self._voice_state: PlayerVoiceState = {"voice": {}} - self._ping: int = 0 - - self.queue: Queue = Queue() - self._current: Playable | None = None - self._original: Playable | None = None - - self._volume: int = 50 - self._paused: bool = False - - self._track_seeds: list[str] = [] - self._autoplay: bool = False - self.auto_queue: Queue = Queue() - self._auto_threshold: int = 100 - self._filter: Filter | None = None - - self._destroyed: bool = False - - async def _auto_play_event(self, payload: TrackEventPayload) -> None: - logger.debug(f'Player {self.guild.id} entered autoplay event.') - - if not self.autoplay: - logger.debug(f'Player {self.guild.id} autoplay is set to False. Exiting autoplay event.') - return - - if payload.reason == 'REPLACED': - logger.debug(f'Player {self.guild.id} autoplay reason is REPLACED. Exiting autoplay event.') - return - - if self.queue.loop: - logger.debug(f'Player {self.guild.id} autoplay default queue.loop is set to True.') - - try: - track = self.queue.get() - except QueueEmpty: - logger.debug(f'Player {self.guild.id} autoplay default queue.loop is set to True ' - f'but no track was available. Exiting autoplay event.') - return - - logger.debug(f'Player {self.guild.id} autoplay default queue.loop is set to True. Looping track "{track}"') - await self.play(track) - return - - if self.queue: - track = self.queue.get() - - populate = len(self.auto_queue) < self._auto_threshold - await self.play(track, populate=populate) - - logger.debug(f'Player {self.guild.id} autoplay found track in default queue, populate={populate}.') - return - - if self.queue.loop_all: - await self.play(self.queue.get()) - return - - if not self.auto_queue: - logger.debug(f'Player {self.guild.id} has no auto queue. Exiting autoplay event.') - return - - await self.queue.put_wait(await self.auto_queue.get_wait()) - populate = self.auto_queue.is_empty + self._node: Node + if not nodes: + self._node = Pool.get_node() + else: + self._node = sorted(nodes, key=lambda n: len(n.players))[0] - track = await self.queue.get_wait() - await self.play(track, populate=populate) + if self.client is MISSING and self.node.client: + self.client = self.node.client - logger.debug(f'Player {self.guild.id} playing track "{track}" from autoplay with populate={populate}.') + super().__init__(client=self.client, channel=self.channel) @property - def autoplay(self) -> bool: - """Returns a ``bool`` indicating whether the :class:`~Player` is in AutoPlay mode or not. - - This property can be set to ``True`` or ``False``. - - When ``autoplay`` is ``True``, the player will automatically handle fetching and playing the next track from - the queue. It also searches tracks in the ``auto_queue``, a special queue populated with recommended tracks, - from the Spotify API or YouTube Recommendations. - - - .. note:: - - You can still use the :func:`~wavelink.on_wavelink_track_end` event when ``autoplay`` is ``True``, - but it is recommended to **not** do any queue logic or invoking play from this event. - - Most users are able to use ``autoplay`` and :func:`~wavelink.on_wavelink_track_start` together to handle - their logic. E.g. sending a message when a track starts playing. - - - .. note:: - - The ``auto_queue`` will be populated when you play a :class:`~wavelink.ext.spotify.SpotifyTrack` or - :class:`~wavelink.YouTubeTrack`, and have initially set ``populate`` to ``True`` in - :meth:`~wavelink.Player.play`. See :meth:`~wavelink.Player.play` for more info. - - - .. versionadded:: 2.0 + def node(self) -> Node: + """The :class:`Player`'s currently selected :class:`Node`. - .. versionchanged:: 2.6.0 - - The autoplay event now populates the ``auto_queue`` when playing :class:`~wavelink.YouTubeTrack` **or** - :class:`~wavelink.ext.spotify.SpotifyTrack`. + ..versionchanged:: 3.0.0 + This property was previously known as ``current_node``. """ - return self._autoplay - - @autoplay.setter - def autoplay(self, value: bool) -> None: - """Set AutoPlay to True or False.""" - self._autoplay = value - - def is_connected(self) -> bool: - """Whether the player is connected to a voice channel.""" - return self.channel is not None and self.channel is not MISSING - - def is_playing(self) -> bool: - """Whether the Player is currently playing a track.""" - return self.current is not None - - def is_paused(self) -> bool: - """Whether the Player is currently paused.""" - return self._paused - - @property - def volume(self) -> int: - """The current volume of the Player.""" - return self._volume + return self._node @property def guild(self) -> discord.Guild | None: - """The discord Guild associated with the Player.""" - return self._guild - - @property - def position(self) -> float: - """The position of the currently playing track in milliseconds.""" - - if not self.is_playing(): - return 0 - - if self.is_paused(): - return min(self.last_position, self.current.duration) # type: ignore - - delta = (datetime.datetime.now(datetime.timezone.utc) - self.last_update).total_seconds() * 1000 - position = self.last_position + delta - - return min(position, self.current.duration) + """Returns the :class:`Player`'s associated :class:`discord.Guild`. - @property - def ping(self) -> int: - """The ping to the discord endpoint in milliseconds. - - .. versionadded:: 2.0 + Could be None if this :class:`Player` has not been connected. """ - return self._ping - - @property - def current(self) -> Playable | None: - """The currently playing Track if there is one. - - Could be ``None`` if no Track is playing. - """ - return self._current - - @property - def filter(self) -> dict[str, Any]: - """The currently applied filter.""" - return self._filter._payload - - async def _update_event(self, data: PlayerUpdateOp | None) -> None: - assert self._guild is not None - - if data is None: - if self.swap_on_disconnect: - - if len(self.nodes) < 2: - return - - new: Node = [n for n in self.nodes if n != self.current_node and n.status is NodeStatus.CONNECTED][0] - del self.current_node._players[self._guild.id] - - if not new: - return - - self.current_node: Node = new - new._players[self._guild.id] = self - - await self._dispatch_voice_update() - await self._swap_state() - return - - data.pop('op') # type: ignore - self._player_state.update(**data) - - state: PlayerState = data['state'] - self.last_update = datetime.datetime.fromtimestamp(state.get("time", 0) / 1000, datetime.timezone.utc) - self.last_position = state.get('position', 0) - - self._ping = state['ping'] - - async def on_voice_server_update(self, data: VoiceServerUpdate) -> None: - """|coro| - - An abstract method that is called when initially connecting to voice. This corresponds to VOICE_SERVER_UPDATE. - - .. warning:: - - Do not override this method. - """ - self._voice_state['token'] = data['token'] - self._voice_state['endpoint'] = data['endpoint'] - - await self._dispatch_voice_update() - - async def on_voice_state_update(self, data: GuildVoiceState) -> None: - """|coro| - - An abstract method that is called when the client’s voice state has changed. - This corresponds to VOICE_STATE_UPDATE. - - .. warning:: - - Do not override this method. - """ - assert self._guild is not None + return self._guild + async def on_voice_state_update(self, data: GuildVoiceStatePayload, /) -> None: channel_id = data["channel_id"] if not channel_id: await self._destroy() return - self._voice_state['session_id'] = data['session_id'] + self._voice_state["voice"]["session_id"] = data["session_id"] self.channel = self.client.get_channel(int(channel_id)) # type: ignore - if not self._guild: - self._guild = self.channel.guild # type: ignore - assert self._guild is not None - self.current_node._players[self._guild.id] = self + async def on_voice_server_update(self, data: VoiceServerUpdatePayload, /) -> None: + self._voice_state["voice"]["token"] = data["token"] + self._voice_state["voice"]["endpoint"] = data["endpoint"] - async def _dispatch_voice_update(self, data: DiscordVoiceState | None = None) -> None: - assert self._guild is not None + await self._dispatch_voice_update() - data = data or self._voice_state + async def _dispatch_voice_update(self) -> None: + assert self.guild is not None + data: VoiceState = self._voice_state["voice"] try: - session_id: str = data['session_id'] - token: str = data['token'] - endpoint: str = data['endpoint'] + session_id: str = data["session_id"] + token: str = data["token"] except KeyError: return - voice: Request = {'voice': {'sessionId': session_id, 'token': token, 'endpoint': endpoint}} - self._player_state.update(**voice) - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=voice) - - logger.debug(f'Player {self.guild.id} is dispatching VOICE_UPDATE: {resp}') - - def _connection_check(self, channel: VoiceChannel) -> None: - if channel.permissions_for(channel.guild.me).administrator: + endpoint: str | None = data.get("endpoint", None) + if not endpoint: return - if not channel.permissions_for(channel.guild.me).connect: - logger.debug(f'Player tried connecting to channel "{channel.id}", but does not have correct permissions.') - - raise InvalidChannelPermissions('You do not have connect permissions to join this channel.') + request: RequestPayload = {"voice": {"sessionId": session_id, "token": token, "endpoint": endpoint}} + resp: PlayerResponse = await self.node._update_player(self.guild.id, data=request) - limit: int = channel.user_limit - total: int = len(channel.members) + # warning: print + print(f"RESPONSE WHEN UPDATING STATE: {resp}") - if limit == 0: - pass + logger.debug(f"Player {self.guild.id} is dispatching VOICE_UPDATE.") - elif total >= limit: - msg: str = f'There are currently too many users in this channel. <{total}/{limit}>' - logger.debug(f'Player tried connecting to channel "{channel.id}", but the it is full. <{total}/{limit}>') - - raise InvalidChannelPermissions(msg) - - async def connect(self, *, timeout: float, reconnect: bool, **kwargs: Any) -> None: + async def connect( + self, *, timeout: float, reconnect: bool, self_deaf: bool = False, self_mute: bool = False + ) -> None: if self.channel is None: - self._invalidate(before_connect=True) - - msg: str = 'Please use the method "discord.VoiceChannel.connect" and pass this player to cls=' - logger.debug(f'Player tried connecting without a channel. {msg}') - - raise InvalidChannelStateError(msg) + msg: str = 'Please use "discord.VoiceChannel.connect(cls=...)" and pass this Player to cls.' + raise InvalidChannelStateException(f"Player tried to connect without a valid channel: {msg}") if not self._guild: self._guild = self.channel.guild - self.current_node._players[self._guild.id] = self - - try: - self._connection_check(self.channel) - except InvalidChannelPermissions as e: - self._invalidate(before_connect=True) - raise e - - await self.channel.guild.change_voice_state(channel=self.channel, **kwargs) - logger.info(f'Player {self.guild.id} connected to channel: {self.channel}') - - async def move_to(self, channel: discord.VoiceChannel) -> None: - """|coro| - - Moves the player to a different voice channel. - - Parameters - ----------- - channel: :class:`discord.VoiceChannel` - The channel to move to. Must be a voice channel. - """ - self._connection_check(channel) - - await self.guild.change_voice_state(channel=channel) - logger.info(f'Player {self.guild.id} moved to channel: {channel}') - - async def play(self, - track: Playable | spotify.SpotifyTrack, - replace: bool = True, - start: int | None = None, - end: int | None = None, - volume: int | None = None, - *, - populate: bool = False - ) -> Playable: - """|coro| - - Play a WaveLink Track. - - Parameters - ---------- - track: :class:`tracks.Playable` - The :class:`tracks.Playable` or :class:`~wavelink.ext.spotify.SpotifyTrack` track to start playing. - replace: bool - Whether this track should replace the current track. Defaults to ``True``. - start: Optional[int] - The position to start the track at in milliseconds. - Defaults to ``None`` which will start the track at the beginning. - end: Optional[int] - The position to end the track at in milliseconds. - Defaults to ``None`` which means it will play until the end. - volume: Optional[int] - Sets the volume of the player. Must be between ``0`` and ``1000``. - Defaults to ``None`` which will not change the volume. - populate: bool - Whether to populate the AutoPlay queue. Defaults to ``False``. - - .. versionadded:: 2.0 - - Returns - ------- - :class:`~tracks.Playable` - The track that is now playing. - - - .. note:: - - If you pass a :class:`~wavelink.YouTubeTrack` **or** :class:`~wavelink.ext.spotify.SpotifyTrack` and set - ``populate=True``, **while** :attr:`~wavelink.Player.autoplay` is set to ``True``, this method will populate - the ``auto_queue`` with recommended songs. When the ``auto_queue`` is low on tracks this method will - automatically populate the ``auto_queue`` with more tracks, and continue this cycle until either the - player has been disconnected or :attr:`~wavelink.Player.autoplay` is set to ``False``. - - - Example - ------- - - .. code:: python3 - - tracks: list[wavelink.YouTubeTrack] = await wavelink.YouTubeTrack.search(...) - if not tracks: - # Do something as no tracks were found... - return - - await player.queue.put_wait(tracks[0]) - - if not player.is_playing(): - await player.play(player.queue.get(), populate=True) - - - .. versionchanged:: 2.6.0 - - This method now accepts :class:`~wavelink.YouTubeTrack` or :class:`~wavelink.ext.spotify.SpotifyTrack` - when populating the ``auto_queue``. - """ - assert self._guild is not None - - if isinstance(track, YouTubeTrack) and self.autoplay and populate: - query: str = f'https://www.youtube.com/watch?v={track.identifier}&list=RD{track.identifier}' - - recos: YouTubePlaylist = await self.current_node.get_playlist(query=query, cls=YouTubePlaylist) - recos: list[YouTubeTrack] = getattr(recos, 'tracks', []) - - queues = set(self.queue) | set(self.auto_queue) | set(self.auto_queue.history) | {track} - - for track_ in recos: - if track_ in queues: - continue - - await self.auto_queue.put_wait(track_) - - self.auto_queue.shuffle() - - elif isinstance(track, spotify.SpotifyTrack): - original = track - track = await track.fulfill(player=self, cls=YouTubeTrack, populate=populate) - - if populate: - self.auto_queue.shuffle() - - for attr, value in original.__dict__.items(): - if hasattr(track, attr): - logger.warning(f'Player {self.guild.id} was unable to set attribute "{attr}" ' - f'when converting a SpotifyTrack as it conflicts with the new track type.') - continue - - setattr(track, attr, value) - - data = { - 'encodedTrack': track.encoded, - 'position': start or 0, - 'volume': volume or self._volume - } - - if end: - data['endTime'] = end - - self._current = track - self._original = track - - try: - - resp: dict[str, Any] = await self.current_node._send( - method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=data, - query=f'noReplace={not replace}' - ) - - except InvalidLavalinkResponse as e: - self._current = None - self._original = None - logger.debug(f'Player {self._guild.id} attempted to load track: {track}, but failed: {e}') - raise e - - self._player_state['track'] = resp['track']['encoded'] - - if not (self.queue.loop and self.queue._loaded): - self.queue.history.put(track) - - self.queue._loaded = track - - logger.debug(f'Player {self._guild.id} loaded and started playing track: {track}.') - return track - - async def set_volume(self, value: int) -> None: - """|coro| - - Set the Player volume. - - Parameters - ---------- - value: int - A volume value between 0 and 1000. - """ - assert self._guild is not None - - self._volume = max(min(value, 1000), 0) - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'volume': self._volume}) + self.node._players[self._guild.id] = self - logger.debug(f'Player {self.guild.id} volume was set to {self._volume}.') + assert self.guild is not None + await self.guild.change_voice_state(channel=self.channel, self_mute=self_mute, self_deaf=self_deaf) - async def seek(self, position: int) -> None: - """|coro| + # warning: print + print(f"PLAYER CONNECTED TO GUILD {self.guild.id} ON CHANNEL {self.channel}") - Seek to the provided position, in milliseconds. + async def play(self, ytid: str) -> None: + """Test play command.""" + assert self.guild is not None - Parameters - ---------- - position: int - The position to seek to in milliseconds. - """ - if not self._current: - return - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'position': position}) - - logger.debug(f'Player {self.guild.id} seeked current track to position {position}.') - - async def pause(self) -> None: - """|coro| - - Pauses the Player. - """ - assert self._guild is not None - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'paused': True}) - - self._paused = True - logger.debug(f'Player {self.guild.id} was paused.') - - async def resume(self) -> None: - """|coro| - - Resumes the Player. - """ - assert self._guild is not None - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'paused': False}) - - self._paused = False - logger.debug(f'Player {self.guild.id} was resumed.') + request: RequestPayload = {"identifier": ytid, "volume": 20} + resp: PlayerResponse = await self.node._update_player(self.guild.id, data=request) - async def stop(self, *, force: bool = True) -> None: - """|coro| - - Stops the currently playing Track. - - Parameters - ---------- - force: Optional[bool] - Whether to stop the currently playing track and proceed to the next regardless if :attr:`~Queue.loop` - is ``True``. Defaults to ``True``. - - - .. versionchanged:: 2.6 - - Added the ``force`` keyword argument. - """ - assert self._guild is not None - - if force: - self.queue._loaded = None - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data={'encodedTrack': None}) - - self._player_state['track'] = None - logger.debug(f'Player {self.guild.id} was stopped.') - - async def set_filter( - self, - _filter: Filter, - /, *, - seek: bool = False - ) -> None: - """|coro| - - Set the player's filter. - - Parameters - ---------- - filter: :class:`wavelink.Filter` - The filter to apply to the player. - seek: bool - Whether to seek the player to its current position - which will apply the filter immediately. Defaults to ``False``. - """ + # warning: print + print(f"PLAYER STARTED PLAYING: {resp}") - assert self._guild is not None - - self._filter = _filter - data: Request = {"filters": _filter._payload} - - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=data) - - if self.is_playing() and seek: - await self.seek(int(self.position)) - - logger.debug(f'Player {self.guild.id} set filter to: {_filter}') - - def _invalidate(self, *, silence: bool = False, before_connect: bool = False) -> None: - self.current_node._players.pop(self._guild.id, None) - - if not before_connect: - self.current_node._invalidated[self._guild.id] = self - - try: - self.cleanup() - except AttributeError: - pass - except Exception as e: - logger.debug(f'Failed to cleanup player, most likely due to never having been connected: {e}') - - self._voice_state = {} - self._player_state = {} - self.channel = None - - if not silence: - logger.debug(f'Player {self._guild.id} was invalidated.') - - async def _destroy(self, *, guild_id: int | None = None) -> None: - if self._destroyed: - return - - self._invalidate(silence=True) - - guild_id = guild_id or self._guild.id - - await self.current_node._send(method='DELETE', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=guild_id) - - self._destroyed = True - self.current_node._invalidated.pop(guild_id, None) - logger.debug(f'Player {guild_id} was destroyed.') - - async def disconnect(self, **kwargs) -> None: - """|coro| - - Disconnect the Player from voice and cleanup the Player state. - - .. versionchanged:: 2.5 - - The discord.py Voice Client cache and Player are invalidated as soon as this is called. - """ - self._invalidate() - await self.guild.change_voice_state(channel=None) - - logger.debug(f'Player {self._guild.id} was disconnected.') - - async def _swap_state(self) -> None: - assert self._guild is not None - - try: - self._player_state['track'] - except KeyError: - return + async def disconnect(self, **kwargs: Any) -> None: + ... - data: EncodedTrackRequest = {'encodedTrack': self._player_state['track'], 'position': self.position} - resp: dict[str, Any] = await self.current_node._send(method='PATCH', - path=f'sessions/{self.current_node._session_id}/players', - guild_id=self._guild.id, - data=data) + async def _destroy(self) -> None: + ... - logger.debug(f'Player {self.guild.id} is swapping State: {resp}') + def cleanup(self) -> None: + ... diff --git a/wavelink/tracks.py b/wavelink/tracks.py index b55891c7..9509623b 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -23,120 +23,40 @@ """ from __future__ import annotations -import abc -from typing import TYPE_CHECKING, ClassVar, Literal, overload, Optional, Any +from typing import TYPE_CHECKING, overload -import aiohttp import yarl -from discord.ext import commands from .enums import TrackSource -from .exceptions import NoTracksError -from .node import Node, NodePool +from .node import Node, Pool if TYPE_CHECKING: - from typing_extensions import Self + from .types.tracks import TrackInfoPayload, TrackPayload - from .types.track import Track as TrackPayload -__all__ = ( - 'Playable', - 'Playlist', - 'YouTubeTrack', - 'GenericTrack', - 'YouTubeMusicTrack', - 'SoundCloudTrack', - 'YouTubePlaylist', - 'SoundCloudPlaylist' -) - - -_source_mapping: dict[str, TrackSource] = { - 'youtube': TrackSource.YouTube +_source_mapping: dict[TrackSource | str | None, str] = { + TrackSource.YouTube: "ytsearch:", + TrackSource.SoundCloud: "scsearch:", + TrackSource.YouTubeMusic: "ytmsearch:", } -class Playlist(metaclass=abc.ABCMeta): - """An ABC that defines the basic structure of a lavalink playlist resource. - - Attributes - ---------- - data: Dict[str, Any] - The raw data supplied by Lavalink. - """ - - def __init__(self, data: dict[str, Any]): - self.data: dict[str, Any] = data - - -class Playable(metaclass=abc.ABCMeta): - """Base ABC Track used in all the Wavelink Track types. - - - .. container:: operations - - .. describe:: str(track) - - Returns a string representing the tracks name. - - .. describe:: repr(track) - - Returns an official string representation of this track. - - .. describe:: track == other_track - - Check whether a track is equal to another. A track is equal when they have the same Base64 Encoding. - - - Attributes - ---------- - data: dict[str, Any] - The raw data received via Lavalink. - encoded: str - The encoded Track string. - is_seekable: bool - Whether the Track is seekable. - is_stream: bool - Whether the Track is a stream. - length: int - The length of the track in milliseconds. - duration: int - An alias for length. - position: int - The position the track will start in milliseconds. Defaults to 0. - title: str - The Track title. - source: :class:`wavelink.TrackSource` - The source this Track was fetched from. - uri: Optional[str] - The URI of this track. Could be None. - author: Optional[str] - The author of this track. Could be None. - identifier: Optional[str] - The Youtube/YoutubeMusic identifier for this track. Could be None. - """ - - PREFIX: ClassVar[str] = '' - +class Playable: def __init__(self, data: TrackPayload) -> None: - self.data: TrackPayload = data - self.encoded: str = data['encoded'] - - info = data['info'] - self.is_seekable: bool = info.get('isSeekable', False) - self.is_stream: bool = info.get('isStream', False) - self.length: int = info.get('length', 0) - self.duration: int = self.length - self.position: int = info.get('position', 0) - - self.title: str = info.get('title', 'Unknown Title') - - source: str | None = info.get('sourceName') - self.source: TrackSource = _source_mapping.get(source, TrackSource.Unknown) - - self.uri: str | None = info.get('uri') - self.author: str | None = info.get('author') - self.identifier: str | None = info.get('identifier') + info: TrackInfoPayload = data["info"] + + self.encoded: str = data["encoded"] + self.identifier: str = info["identifier"] + self.is_seekable: bool = info["isSeekable"] + self.author: str = info["author"] + self.length: int = info["length"] + self.is_stream: bool = info["isStream"] + self.position: int = info["position"] + self.title: str = info["title"] + self.uri: str | None = info.get("uri") + self.artwork: str | None = info.get("artworkUrl") + self.isrc: str | None = info.get("isrc") + self.source: str = info["sourceName"] def __hash__(self) -> int: return hash(self.encoded) @@ -145,247 +65,48 @@ def __str__(self) -> str: return self.title def __repr__(self) -> str: - return f'Playable: source={self.source}, title={self.title}' + return f"Playable(source={self.source}, title={self.title}, identifier={self.identifier})" def __eq__(self, other: object) -> bool: - if isinstance(other, Playable): - return self.encoded == other.encoded - return NotImplemented - - @overload - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = ... - ) -> list[Self]: - ... + if not isinstance(other, Playable): + raise NotImplemented + + return self.encoded == other.encoded @overload - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = ... - ) -> YouTubePlaylist: + async def search(self, query: str, /, source: TrackSource | str | None) -> list[Playable]: ... @overload - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = ... - ) -> SoundCloudPlaylist: + async def search(self, query: str, /, source: TrackSource | str | None) -> Playlist: ... - @classmethod - async def search(cls, - query: str, - /, - *, - node: Node | None = None - ) -> list[Self]: - """Search and retrieve tracks for the given query. - - Parameters - ---------- - query: str - The query to search for. - node: Optional[:class:`wavelink.Node`] - The node to use when searching for tracks. If no :class:`wavelink.Node` is passed, - one will be fetched via the :class:`wavelink.NodePool`. - """ - + async def search(self, query: str, /, source: TrackSource | str | None): + prefix: TrackSource | str | None = _source_mapping.get(source, source) check = yarl.URL(query) - if str(check.host) == 'youtube.com' or str(check.host) == 'www.youtube.com' and check.query.get("list") or \ - cls.PREFIX == 'ytpl:': + if str(check.host) == "youtube.com" or str(check.host) == "www.youtube.com" and check.query.get("list"): + ytplay: Playlist = await Pool._fetch_playlist(query, cls_=Playlist) + return ytplay - playlist = await NodePool.get_playlist(query, cls=YouTubePlaylist, node=node) - return playlist - elif str(check.host) == 'soundcloud.com' or str(check.host) == 'www.soundcloud.com' and 'sets' in check.parts: + elif str(check.host) == "soundcloud.com" or str(check.host) == "www.soundcloud.com" and "sets" in check.parts: + scplay: Playlist = await Pool._fetch_playlist(query, cls_=Playlist) + return scplay - playlist = await NodePool.get_playlist(query, cls=SoundCloudPlaylist, node=node) - return playlist elif check.host: - tracks = await NodePool.get_tracks(query, cls=cls, node=node) - else: - tracks = await NodePool.get_tracks(f'{cls.PREFIX}{query}', cls=cls, node=node) - - return tracks + tracks: list[Playable] = await Pool._fetch_tracks(query, cls_=Playable) + return tracks - @classmethod - async def convert(cls, ctx: commands.Context, argument: str) -> Self: - """Converter which searches for and returns the first track. - - Used as a type hint in a - `discord.py command `_. - """ - results = await cls.search(argument) - - if not results: - raise commands.BadArgument("Could not find any songs matching that query.") - - if issubclass(cls, YouTubePlaylist): - return results # type: ignore + else: + if isinstance(prefix, TrackSource) or not prefix: + term: str = query + else: + term: str = f"{prefix}{query}" - return results[0] + tracks: list[Playable] = await Pool._fetch_tracks(term, cls_=Playable) + return tracks -class GenericTrack(Playable): - """Generic Wavelink Track. - Use this track for searching for Local songs or direct URLs. - """ +class Playlist: ... - - -class YouTubeTrack(Playable): - - PREFIX: str = 'ytsearch:' - - def __init__(self, data: TrackPayload) -> None: - super().__init__(data) - - self._thumb: str = f"https://img.youtube.com/vi/{self.identifier}/maxresdefault.jpg" - - @property - def thumbnail(self) -> str: - """The URL to the thumbnail of this video. - - .. note:: - - Due to YouTube limitations this may not always return a valid thumbnail. - Use :meth:`.fetch_thumbnail` to fallback. - - Returns - ------- - str - The URL to the video thumbnail. - """ - return self._thumb - - thumb = thumbnail - - async def fetch_thumbnail(self, *, node: Node | None = None) -> str: - """Fetch the max resolution thumbnail with a fallback if it does not exist. - - This sets and overrides the default ``thumbnail`` and ``thumb`` properties. - - .. note:: - - This method uses an API request to fetch the thumbnail. - - Returns - ------- - str - The URL to the video thumbnail. - """ - if not node: - node = NodePool.get_node() - - session: aiohttp.ClientSession = node._session - url: str = f"https://img.youtube.com/vi/{self.identifier}/maxresdefault.jpg" - - async with session.get(url=url) as resp: - if resp.status == 404: - url = f'https://img.youtube.com/vi/{self.identifier}/hqdefault.jpg' - - self._thumb = url - return url - - -class YouTubeMusicTrack(YouTubeTrack): - """A track created using a search to YouTube Music.""" - - PREFIX: str = "ytmsearch:" - - -class SoundCloudTrack(Playable): - """A track created using a search to SoundCloud.""" - - PREFIX: str = "scsearch:" - - -class YouTubePlaylist(Playable, Playlist): - """Represents a Lavalink YouTube playlist object. - - - .. container:: operations - - .. describe:: str(playlist) - - Returns a string representing the playlists name. - - - Attributes - ---------- - name: str - The name of the playlist. - tracks: :class:`YouTubeTrack` - The list of :class:`YouTubeTrack` in the playlist. - selected_track: Optional[int] - The selected video in the playlist. This could be ``None``. - """ - - PREFIX: str = "ytpl:" - - def __init__(self, data: dict): - self.tracks: list[YouTubeTrack] = [] - self.name: str = data["playlistInfo"]["name"] - - self.selected_track: Optional[int] = data["playlistInfo"].get("selectedTrack") - if self.selected_track is not None: - self.selected_track = int(self.selected_track) - - for track_data in data["tracks"]: - track = YouTubeTrack(track_data) - self.tracks.append(track) - - self.source = TrackSource.YouTube - - def __str__(self) -> str: - return self.name - - -class SoundCloudPlaylist(Playable, Playlist): - """Represents a Lavalink SoundCloud playlist object. - - - .. container:: operations - - .. describe:: str(playlist) - - Returns a string representing the playlists name. - - - Attributes - ---------- - name: str - The name of the playlist. - tracks: :class:`SoundCloudTrack` - The list of :class:`SoundCloudTrack` in the playlist. - selected_track: Optional[int] - The selected video in the playlist. This could be ``None``. - """ - - def __init__(self, data: dict): - self.tracks: list[SoundCloudTrack] = [] - self.name: str = data["playlistInfo"]["name"] - - self.selected_track: Optional[int] = data["playlistInfo"].get("selectedTrack") - if self.selected_track is not None: - self.selected_track = int(self.selected_track) - - for track_data in data["tracks"]: - track = SoundCloudTrack(track_data) - self.tracks.append(track) - - self.source = TrackSource.SoundCloud - - def __str__(self) -> str: - return self.name diff --git a/wavelink/types/events.py b/wavelink/types/events.py deleted file mode 100644 index 1051ec0f..00000000 --- a/wavelink/types/events.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import TYPE_CHECKING, Literal, TypedDict - -if TYPE_CHECKING: - from typing_extensions import NotRequired, TypeAlias - - -_TrackStartEventType = Literal["TrackStartEvent"] -_OtherEventOpType = Literal["TrackEndEvent", "TrackExceptionEvent", "TrackStuckEvent", "WebSocketClosedEvent"] - -EventType = Literal[_TrackStartEventType, _OtherEventOpType] - - -class _BaseEventOp(TypedDict): - op: Literal["event"] - guildId: str - -class TrackStartEvent(_BaseEventOp): - type: _TrackStartEventType - encodedTrack: str - -class _OtherEventOp(_BaseEventOp): - type: _OtherEventOpType - -EventOp: TypeAlias = "TrackStartEvent | _OtherEventOp" - - -class PlayerState(TypedDict): - time: int - position: NotRequired[int] - connected: bool - ping: int - - -class PlayerUpdateOp(TypedDict): - guildId: str - state: PlayerState \ No newline at end of file diff --git a/wavelink/types/request.py b/wavelink/types/request.py index a53a5bc6..62911be4 100644 --- a/wavelink/types/request.py +++ b/wavelink/types/request.py @@ -1,25 +1,46 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 __future__ import annotations -from typing import TYPE_CHECKING, TypedDict - -from .state import VoiceState +from typing import TYPE_CHECKING, Optional, TypedDict if TYPE_CHECKING: - from typing_extensions import TypeAlias + from typing_extensions import NotRequired, TypeAlias -class Filters(TypedDict): - ... +class VoiceRequest(TypedDict): + token: str + endpoint: Optional[str] + sessionId: str class _BaseRequest(TypedDict, total=False): - voice: VoiceState + voice: VoiceRequest position: int endTime: int volume: int paused: bool - filters: Filters - voice: VoiceState class EncodedTrackRequest(_BaseRequest): @@ -30,4 +51,4 @@ class IdentifierRequest(_BaseRequest): identifier: str -Request: TypeAlias = '_BaseRequest | EncodedTrackRequest | IdentifierRequest' +Request: TypeAlias = "_BaseRequest | EncodedTrackRequest | IdentifierRequest" diff --git a/wavelink/types/response.py b/wavelink/types/response.py new file mode 100644 index 00000000..3db2203c --- /dev/null +++ b/wavelink/types/response.py @@ -0,0 +1,125 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 TYPE_CHECKING, Literal, TypedDict, Union + +if TYPE_CHECKING: + from typing_extensions import Never, NotRequired, TypeAlias + + from .state import PlayerState, VoiceState + from .stats import CPUStats, FrameStats, MemoryStats + from .tracks import PlaylistPayload, TrackPayload + + +class ErrorResponse(TypedDict): + timestamp: int + status: int + error: str + trace: NotRequired[str] + message: str + path: str + + +class LoadedErrorPayload(TypedDict): + message: str + severity: str + cause: str + + +class PlayerResponse(TypedDict): + guildId: str + track: NotRequired[TrackPayload] + volume: int + paused: bool + state: PlayerState + voice: VoiceState + + +class UpdateResponse(TypedDict): + resuming: bool + timeout: int + + +class TrackLoadedResponse(TypedDict): + loadType: Literal["track"] + data: TrackPayload + + +class PlaylistLoadedResponse(TypedDict): + loadType: Literal["playlist"] + data: PlaylistPayload + + +class SearchLoadedResponse(TypedDict): + loadType: Literal["search"] + data: list[TrackPayload] + + +class EmptyLoadedResponse(TypedDict): + loadType: Literal["empty"] + data: dict[Never, Never] + + +class ErrorLoadedResponse(TypedDict): + loadType: Literal["error"] + data: LoadedErrorPayload + + +class VersionPayload(TypedDict): + semver: str + major: int + minor: int + patch: int + preRelease: NotRequired[str] + build: NotRequired[str] + + +class GitPayload(TypedDict): + branch: str + commit: str + commitTime: int + + +class PluginPayload(TypedDict): + name: str + version: str + + +class InfoResponse(TypedDict): + version: VersionPayload + buildTime: int + git: GitPayload + jvm: str + lavaplayer: str + sourceManagers: list[str] + filters: list[str] + plugins: list[PluginPayload] + + +class StatsResponse(TypedDict): + players: int + playingPlayers: int + uptime: int + memory: MemoryStats + cpu: CPUStats + frameStats: NotRequired[FrameStats] diff --git a/wavelink/types/state.py b/wavelink/types/state.py index bf5b8384..25fd4220 100644 --- a/wavelink/types/state.py +++ b/wavelink/types/state.py @@ -1,16 +1,47 @@ -from typing import TYPE_CHECKING, TypedDict +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 TYPE_CHECKING, Optional, TypedDict if TYPE_CHECKING: - from discord.types.voice import GuildVoiceState, VoiceServerUpdate - from typing_extensions import NotRequired, TypeAlias - -class VoiceState(TypedDict): + from typing_extensions import NotRequired + + +class PlayerState(TypedDict): + time: int + position: int + connected: bool + ping: int + + +class VoiceState(TypedDict, total=False): token: str - endpoint: str - sessionId: str - connected: NotRequired[bool] - ping: NotRequired[int] + endpoint: Optional[str] + session_id: str -class DiscordVoiceState(GuildVoiceState, VoiceServerUpdate): - ... +class PlayerVoiceState(TypedDict): + voice: VoiceState + channel_id: NotRequired[str] + track: NotRequired[str] + position: NotRequired[int] diff --git a/examples/connecting.py b/wavelink/types/stats.py similarity index 59% rename from examples/connecting.py rename to wavelink/types/stats.py index 07ee615b..81cce549 100644 --- a/examples/connecting.py +++ b/wavelink/types/stats.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -21,23 +21,23 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import discord -import wavelink -from discord.ext import commands +from typing import Literal, TypedDict -class Bot(commands.Bot): +class MemoryStats(TypedDict): + free: int + used: int + allocated: int + reservable: int - def __init__(self) -> None: - intents = discord.Intents.default() - super().__init__(intents=intents, command_prefix='?') - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') +class CPUStats(TypedDict): + cores: int + systemLoad: float + lavalinkLoad: float - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node]) +class FrameStats(TypedDict): + sent: int + nulled: int + deficit: int diff --git a/wavelink/types/track.py b/wavelink/types/track.py deleted file mode 100644 index 64ee6531..00000000 --- a/wavelink/types/track.py +++ /dev/null @@ -1,19 +0,0 @@ -from __future__ import annotations - -from typing import TypedDict - - -class TrackInfo(TypedDict): - identifier: str - isSeekable: bool - author: str - length: int - isStream: bool - position: int - title: str - uri: str | None - sourceName: str - -class Track(TypedDict): - encoded: str - info: TrackInfo \ No newline at end of file diff --git a/wavelink/types/tracks.py b/wavelink/types/tracks.py new file mode 100644 index 00000000..6d28c233 --- /dev/null +++ b/wavelink/types/tracks.py @@ -0,0 +1,56 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 TYPE_CHECKING, Literal, TypedDict + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + +class TrackInfoPayload(TypedDict): + identifier: str + isSeekable: bool + author: str + length: int + isStream: bool + position: int + title: str + uri: NotRequired[str] + artworkUrl: NotRequired[str] + isrc: NotRequired[str] + sourceName: str + + +class PlaylistInfoPayload(TypedDict): + name: str + selectedTrack: int + + +class TrackPayload(TypedDict): + encoded: str + info: TrackInfoPayload + + +class PlaylistPayload(TypedDict): + info: PlaylistInfoPayload + tracks: list[TrackPayload] diff --git a/wavelink/types/websocket.py b/wavelink/types/websocket.py new file mode 100644 index 00000000..661cf6c1 --- /dev/null +++ b/wavelink/types/websocket.py @@ -0,0 +1,111 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 TYPE_CHECKING, Literal, TypedDict, Union + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + from .state import PlayerState + from .stats import CPUStats, FrameStats, MemoryStats + from .tracks import TrackPayload + + +class TrackExceptionPayload(TypedDict): + message: NotRequired[str] + severity: str + cause: str + + +class ReadyOP(TypedDict): + op: Literal["ready"] + resumed: bool + sessionId: str + + +class PlayerUpdateOP(TypedDict): + op: Literal["playerUpdate"] + guildId: str + state: PlayerState + + +class StatsOP(TypedDict): + op: Literal["stats"] + players: int + playingPlayers: int + uptime: int + memory: MemoryStats + cpu: CPUStats + frameStats: FrameStats + + +class TrackStartEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackStartEvent"] + track: TrackPayload + + +class TrackEndEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackEndEvent"] + track: TrackPayload + reason: str + + +class TrackExceptionEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackExceptionEvent"] + track: TrackPayload + exception: TrackExceptionPayload + + +class TrackStuckEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["TrackStuckEvent"] + track: TrackPayload + thresholdMs: int + + +class WebsocketClosedEvent(TypedDict): + op: Literal["event"] + guildId: str + type: Literal["WebSocketClosedEvent"] + code: int + reason: str + byRemote: bool + + +WebsocketOP = Union[ + ReadyOP, + PlayerUpdateOP, + StatsOP, + TrackStartEvent, + TrackEndEvent, + TrackExceptionEvent, + TrackStuckEvent, + WebsocketClosedEvent, +] diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 5472b8ef..45dc7f96 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -25,257 +25,212 @@ import asyncio import logging -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any import aiohttp -import wavelink - from . import __version__ from .backoff import Backoff -from .enums import NodeStatus, TrackEventType -from .exceptions import * -from .payloads import TrackEventPayload, WebsocketClosedPayload +from .enums import NodeStatus +from .exceptions import AuthorizationFailedException +from .payloads import * +from .tracks import Playable if TYPE_CHECKING: from .node import Node from .player import Player + from .types.state import PlayerState + from .types.websocket import TrackExceptionPayload, WebsocketOP logger: logging.Logger = logging.getLogger(__name__) class Websocket: - - __slots__ = ( - 'node', - 'socket', - 'retries', - 'retry', - '_original_attempts', - 'backoff', - '_listener_task', - '_reconnect_task' - ) - def __init__(self, *, node: Node) -> None: - self.node: Node = node - self.socket: aiohttp.ClientWebSocketResponse | None = None - - self.retries: int | None = node._retries - self.retry: float = 1 - self._original_attempts: int | None = node._retries + self.node = node self.backoff: Backoff = Backoff() - self._listener_task: asyncio.Task | None = None - self._reconnect_task: asyncio.Task | None = None + self.socket: aiohttp.ClientWebSocketResponse | None = None + self.keep_alive_task: asyncio.Task | None = None @property def headers(self) -> dict[str, str]: assert self.node.client is not None assert self.node.client.user is not None - return { - 'Authorization': self.node.password, - 'User-Id': str(self.node.client.user.id), - 'Client-Name': f'Wavelink/{__version__}' + data = { + "Authorization": self.node.password, + "User-Id": str(self.node.client.user.id), + "Client-Name": f"Wavelink/{__version__}", } + if self.node.session_id: + data["Session-Id"] = self.node.session_id + + return data + def is_connected(self) -> bool: return self.socket is not None and not self.socket.closed async def connect(self) -> None: - if self.node.status is NodeStatus.CONNECTED: - logger.error(f'Node {self.node} websocket tried connecting in an already connected state. ' - f'Disregarding.') - return - self.node._status = NodeStatus.CONNECTING - if self._listener_task: + if self.keep_alive_task: try: - self._listener_task.cancel() + self.keep_alive_task.cancel() except Exception as e: - logger.debug(f'Node {self.node} encountered an error while cancelling the websocket listener: {e}. ' - f'This is likely not an issue and will not affect connection.') - - uri: str = self.node._host.removeprefix('https://').removeprefix('http://') - - if self.node._use_http: - uri: str = f'{"https://" if self.node._secure else "http://"}{uri}' - else: - uri: str = f'{"wss://" if self.node._secure else "ws://"}{uri}' + logger.debug( + f"Failed to cancel websocket keep alive while connecting. " + f"This is most likely not a problem and will not affect websocket connection: '{e}'" + ) + retries: int | None = self.node._retries + session: aiohttp.ClientSession = self.node._session heartbeat: float = self.node.heartbeat + uri: str = f"{self.node.uri.removesuffix('/')}/v4/websocket" + github: str = "https://github.com/PythonistaGuild/Wavelink/issues" - try: - self.socket = await self.node._session.ws_connect(url=uri, heartbeat=heartbeat, headers=self.headers) - except Exception as e: - if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: - raise AuthorizationFailed from e - else: - logger.error(f'An error occurred connecting to node: "{self.node}". {e}') - - if self.is_connected(): - self.retries = self._original_attempts - self._reconnect_task = None - # TODO - Configure Resuming... - else: - await self._reconnect() - return - - self._listener_task = asyncio.create_task(self._listen()) - - async def _reconnect(self) -> None: - self.node._status = NodeStatus.CONNECTING - self.retry = self.backoff.calculate() + while True: + try: + self.socket = await session.ws_connect(url=uri, heartbeat=heartbeat, headers=self.headers) # type: ignore + except Exception as e: + if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: + raise AuthorizationFailedException from e + else: + logger.info( + f'An unexpected error occurred while connecting {self.node!r} to Lavalink: "{e}"\n' + f"If this error persists or wavelink is unable to reconnect, please see: {github}" + ) + + if self.is_connected(): + self.keep_alive_task = asyncio.create_task(self.keep_alive()) + break + + if retries == 0: + logger.warning( + f"{self.node!r} was unable to successfully connect/reconnect to Lavalink after " + f'"{retries + 1}" connection attempt. This Node has exhausted the retry count.' + ) - if self.retries == 0: - logger.error(f'Node {self.node} websocket was unable to connect, ' - f'and has exhausted the reconnection attempt limit. ' - 'Please check your Lavalink Node is started and your connection details are correct.') + await self.cleanup() + break - await self.cleanup() - return + if retries: + retries -= 1 - retries = f'{self.retries} attempt(s) remaining.' if self.retries else '' - logger.error(f'Node {self.node} websocket was unable to connect, retrying connection in: ' - f'"{self.retry}" seconds. {retries}') + delay: float = self.backoff.calculate() + logger.info(f'{self.node!r} retrying websocket connection in "{delay}" seconds.') - if self.retries: - self.retries -= 1 + await asyncio.sleep(delay) - await asyncio.sleep(self.retry) - await self.connect() + async def keep_alive(self) -> None: + assert self.socket is not None - async def _listen(self) -> None: while True: - message = await self.socket.receive() - - if message.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING): - - for player in self.node.players.copy().values(): - await player._update_event(data=None) + message: aiohttp.WSMessage = await self.socket.receive() - self._reconnect_task = asyncio.create_task(self._reconnect()) - return - - if message.data == 1011: - logger.error(f'Node {self.node} websocket encountered an internal error which can not be resolved. ' - 'Make sure your Lavalink sever is up to date, and try restarting.') - - await self.cleanup() - return + if message.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + ): # pyright: ignore[reportUnknownMemberType] + asyncio.create_task(self.cleanup()) + break if message.data is None: - logger.info(f'Node {self.node} websocket received a message from Lavalink with empty data. ' - f'Disregarding.') + logger.debug(f"Received an empty message from Lavalink websocket. Disregarding.") continue - data = message.json() - logger.debug(f'Node {self.node} websocket received a message from Lavalink: {data}') - - op = data.get('op', None) - if not op: - logger.info(f'Node {self.node} websocket payload "op" from Lavalink was None. Disregarding.') - continue + data: WebsocketOP = message.json() - if op == 'ready': + if data["op"] == "ready": self.node._status = NodeStatus.CONNECTED - self.node._session_id = data['sessionId'] - - self.dispatch('node_ready', self.node) + self.node._session_id = data["sessionId"] - elif op == 'stats': - payload = ... - logger.debug(f'Node {self.node} websocket received a Stats Update payload: {data}') - self.dispatch('stats_update', data) + self.dispatch("node_ready", self.node) - elif op == 'event': - logger.debug(f'Node {self.node} websocket received an event payload: {data}') - player = self.get_player(data) + elif data["op"] == "playerUpdate": + playerup: Player | None = self.get_player(data["guildId"]) + state: PlayerState = data["state"] - if data['type'] == 'WebSocketClosedEvent': - player = player or self.node._invalidated.get(int(data['guildId']), None) + updatepayload: PlayerUpdateEventPayload = PlayerUpdateEventPayload(player=playerup, state=state) + self.dispatch("player_update", updatepayload) - if not player: - logger.debug(f'Node {self.node} received a WebsocketClosedEvent in an "unknown" state. ' - f'Disregarding.') - continue + elif data["op"] == "stats": + statspayload: StatsEventPayload = StatsEventPayload(data=data) + self.dispatch("stats_update", statspayload) - if self.node._invalidated.get(player.guild.id): - await player._destroy() + elif data["op"] == "event": + player: Player | None = self.get_player(data["guildId"]) - logger.debug(f'Node {self.node} websocket acknowledged "WebsocketClosedEvent": ' - f'. ' - f'Cleanup on player {player.guild.id} has been completed.') + if data["type"] == "TrackStartEvent": + track: Playable = Playable(data["track"]) # Fuck off pycharm... - payload: WebsocketClosedPayload = WebsocketClosedPayload(data=data, player=player) + startpayload: TrackStartEventPayload = TrackStartEventPayload(player=player, track=track) + self.dispatch("track_start", startpayload) - self.dispatch('websocket_closed', payload) - continue + elif data["type"] == "TrackEndEvent": + track: Playable = Playable(data["track"]) # Fuck off pycharm... + reason: str = data["reason"] - if player is None: - logger.debug(f'Node {self.node} received a payload from Lavalink without an attached player. ' - f'Disregarding.') - continue + endpayload: TrackEndEventPayload = TrackEndEventPayload(player=player, track=track, reason=reason) + self.dispatch("track_end", endpayload) - track = await self.node.build_track(cls=wavelink.GenericTrack, encoded=data['encodedTrack']) - payload: TrackEventPayload = TrackEventPayload( - data=data, - track=track, - player=player, - original=player._original - ) + elif data["type"] == "TrackExceptionEvent": + track: Playable = Playable(data["track"]) # Fuck off pycharm... + exception: TrackExceptionPayload = data["exception"] # Fuck off pycharm... - if payload.event is TrackEventType.END and payload.reason != 'REPLACED': - player._current = None + excpayload: TrackExceptionEventPayload = TrackExceptionEventPayload( + player=player, track=track, exception=exception + ) + self.dispatch("track_exception", excpayload) - self.dispatch('track_event', payload) + elif data["type"] == "TrackStuckEvent": + track: Playable = Playable(data["track"]) # Fuck off pycharm... + threshold: int = data["thresholdMs"] - if payload.event is TrackEventType.END: - self.dispatch('track_end', payload) - asyncio.create_task(player._auto_play_event(payload)) + stuckpayload: TrackStuckEventPayload = TrackStuckEventPayload( + player=player, track=track, threshold=threshold + ) + self.dispatch("track_stuck", stuckpayload) - elif payload.event is TrackEventType.START: - self.dispatch('track_start', payload) + elif data["type"] == "WebSocketClosedEvent": + code: int = data["code"] + reason: str = data["reason"] + by_remote: bool = data["byRemote"] - elif op == 'playerUpdate': - player = self.get_player(data) - if player is None: - logger.debug(f'Node {self.node} received a payload from Lavalink without an attached player. ' - f'Disregarding.') - continue - - await player._update_event(data) - self.dispatch("player_update", data) - logger.debug(f'Node {self.node} websocket received Player Update payload: {data}') + wcpayload: WebsocketClosedEventPayload = WebsocketClosedEventPayload( + player=player, code=code, reason=reason, by_remote=by_remote + ) + self.dispatch("websocket_closed", wcpayload) + else: + logger.debug(f"Received unknown event type from Lavalink '{data['type']}'. Disregarding.") else: - logger.warning(f'Received unknown payload from Lavalink: <{data}>. ' - f'If this continues consider making a ticket on the Wavelink GitHub. ' - f'https://github.com/PythonistaGuild/Wavelink') + logger.debug(f"'Received an unknown OP from Lavalink '{data['op']}'. Disregarding.") - def get_player(self, payload: dict[str, Any]) -> Optional['Player']: - return self.node.players.get(int(payload['guildId']), None) + def get_player(self, guild_id: str | int) -> Player | None: + return self.node.get_player(int(guild_id)) + + def dispatch(self, event: str, /, *args: Any, **kwargs: Any) -> None: + assert self.node.client is not None - def dispatch(self, event, *args: Any, **kwargs: Any) -> None: self.node.client.dispatch(f"wavelink_{event}", *args, **kwargs) - logger.debug(f'Node {self.node} is dispatching an event: "on_wavelink_{event}".') + logger.debug(f"{self.node!r} dispatched the event 'on_wavelink_{event}'") - # noinspection PyBroadException async def cleanup(self) -> None: - try: - await self.socket.close() - except AttributeError: - pass + if self.socket: + try: + await self.socket.close() + except: + pass - try: - self._listener_task.cancel() - except Exception: - pass + if self.keep_alive_task: + try: + self.keep_alive_task.cancel() + except: + pass self.node._status = NodeStatus.DISCONNECTED - - logger.debug(f'Successfully cleaned up websocket for node: {self.node}') + logger.debug(f"Successfully cleaned up the websocket for {self.node!r}") From 175a54aab26f603e68f19890c5b0d13975650d52 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 14 Jul 2023 19:17:28 +1000 Subject: [PATCH 004/132] Remove filters. (Needs to be rewritten) --- wavelink/filters.py | 631 -------------------------------------------- 1 file changed, 631 deletions(-) delete mode 100644 wavelink/filters.py diff --git a/wavelink/filters.py b/wavelink/filters.py deleted file mode 100644 index d0e9a50e..00000000 --- a/wavelink/filters.py +++ /dev/null @@ -1,631 +0,0 @@ -"""MIT License - -Copyright (c) 2019-Present PythonistaGuild - -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 __future__ import annotations - -import abc -import collections -from typing import Any - - -__all__ = ( - "BaseFilter", - "Equalizer", - "Karaoke", - "Timescale", - "Tremolo", - "Vibrato", - "Rotation", - "Distortion", - "ChannelMix", - "LowPass", - "Filter", -) - - -class BaseFilter(abc.ABC): - """ - .. container:: operations - - .. describe:: repr(filter) - - Returns an official string representation of this filter. - """ - - def __init__(self, name: str | None = None) -> None: - self.name: str = name or "Unknown" - - def __repr__(self) -> str: - return f"" - - @property - @abc.abstractmethod - def _payload(self) -> Any: - raise NotImplementedError - - -class Equalizer(BaseFilter): - """An equalizer filter. - - Parameters - ---------- - name: str - The name of this filter. Can be used to differentiate between equalizer filters. - bands: List[Dict[str, int]] - A list of equalizer bands, each item is a dictionary with "band" and "gain" keys. - """ - - def __init__( - self, - name: str = "CustomEqualizer", - *, - bands: list[tuple[int, float]] - ) -> None: - super().__init__(name=name) - - if any((band, gain) for band, gain in bands if band < 0 or band > 15 or gain < -0.25 or gain > 1.0): - raise ValueError("Equalizer bands must be between 0 and 15 and gains between -0.25 and 1.0") - - _dict = collections.defaultdict(float) - _dict.update(bands) - - self.bands = [{"band": band, "gain": _dict[band]} for band in range(15)] - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> list[dict[str, float]]: - return self.bands - - @classmethod - def flat(cls) -> Equalizer: - """A flat equalizer.""" - - bands = [ - (0, 0.0), (1, 0.0), (2, 0.0), (3, 0.0), (4, 0.0), - (5, 0.0), (6, 0.0), (7, 0.0), (8, 0.0), (9, 0.0), - (10, 0.0), (11, 0.0), (12, 0.0), (13, 0.0), (14, 0.0) - ] - return cls(name="Flat EQ", bands=bands) - - @classmethod - def boost(cls) -> Equalizer: - """A boost equalizer.""" - - bands = [ - (0, -0.075), (1, 0.125), (2, 0.125), (3, 0.1), (4, 0.1), - (5, .05), (6, 0.075), (7, 0.0), (8, 0.0), (9, 0.0), - (10, 0.0), (11, 0.0), (12, 0.125), (13, 0.15), (14, 0.05) - ] - return cls(name="Boost EQ", bands=bands) - - @classmethod - def metal(cls) -> Equalizer: - """A metal equalizer.""" - - bands = [ - (0, 0.0), (1, 0.1), (2, 0.1), (3, 0.15), (4, 0.13), - (5, 0.1), (6, 0.0), (7, 0.125), (8, 0.175), (9, 0.175), - (10, 0.125), (11, 0.125), (12, 0.1), (13, 0.075), (14, 0.0) - ] - - return cls(name="Metal EQ", bands=bands) - - @classmethod - def piano(cls) -> Equalizer: - """A piano equalizer.""" - - bands = [ - (0, -0.25), (1, -0.25), (2, -0.125), (3, 0.0), - (4, 0.25), (5, 0.25), (6, 0.0), (7, -0.25), (8, -0.25), - (9, 0.0), (10, 0.0), (11, 0.5), (12, 0.25), (13, -0.025) - ] - return cls(name="Piano EQ", bands=bands) - - -class Karaoke(BaseFilter): - """ - A Karaoke filter. - - The default values provided for all the parameters will play the track normally. - - Parameters - ---------- - level: float - How much of an effect this filter should have. - mono_level: float - How much of an effect this filter should have on mono tracks. - filter_band: float - The band this filter should target. - filter_width: float - The width of the band this filter should target. - """ - - def __init__( - self, - *, - level: float = 1.0, - mono_level: float = 1.0, - filter_band: float = 220.0, - filter_width: float = 100.0 - ) -> None: - super().__init__(name="Karaoke") - - self.level: float = level - self.mono_level: float = mono_level - self.filter_band: float = filter_band - self.filter_width: float = filter_width - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "level": self.level, - "monoLevel": self.mono_level, - "filterBand": self.filter_band, - "filterWidth": self.filter_width - } - - -class Timescale(BaseFilter): - """A timescale filter. - - Increases or decreases the speed, pitch, and/or rate of tracks. - - The default values provided for ``speed``, ``pitch`` and ``rate`` will play the track normally. - - Parameters - ---------- - speed: float - A multiplier for the track playback speed. Should be more than or equal to 0.0. - pitch: float - A multiplier for the track pitch. Should be more than or equal to 0.0. - rate: float - A multiplier for the track rate (pitch + speed). Should be more than or equal to 0.0. - """ - - def __init__( - self, - *, - speed: float = 1.0, - pitch: float = 1.0, - rate: float = 1.0, - ) -> None: - - if speed < 0: - raise ValueError("'speed' must be more than or equal to 0.") - if pitch < 0: - raise ValueError("'pitch' must be more than or equal to 0.") - if rate < 0: - raise ValueError("'rate' must be more than or equal to 0.") - - super().__init__(name="Timescale") - - self.speed: float = speed - self.pitch: float = pitch - self.rate: float = rate - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "speed": self.speed, - "pitch": self.pitch, - "rate": self.rate, - } - - -class Tremolo(BaseFilter): - """A tremolo filter. - - Creates a shuddering effect by quickly changing the volume. - - The default values provided for ``frequency`` and ``depth`` will play the track normally. - - Parameters - ---------- - frequency: float - How quickly the volume should change. Should be more than 0.0. - depth: float - How much the volume should change. Should be more than 0.0 and less than or equal to 1.0. - """ - - def __init__( - self, - *, - frequency: float = 2.0, - depth: float = 0.5 - ) -> None: - - if frequency < 0: - raise ValueError("'frequency' must be more than 0.0.") - if not 0 < depth <= 1: - raise ValueError("'depth' must be more than 0.0 and less than or equal to 1.0.") - - super().__init__(name="Tremolo") - - self.frequency: float = frequency - self.depth: float = depth - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "frequency": self.frequency, - "depth": self.depth - } - - -class Vibrato(BaseFilter): - """A vibrato filter. - - Creates a vibrating effect by quickly changing the pitch. - - The default values provided for ``frequency`` and ``depth`` will play the track normally. - - Parameters - ---------- - frequency: float - How quickly the pitch should change. Should be more than 0.0 and less than or equal to 14.0. - depth: float - How much the pitch should change. Should be more than 0.0 and less than or equal to 1.0. - """ - - def __init__( - self, - *, - frequency: float = 2.0, - depth: float = 0.5 - ) -> None: - - if not 0 < frequency <= 14: - raise ValueError("'frequency' must be more than 0.0 and less than or equal to 14.0.") - if not 0 < depth <= 1: - raise ValueError("'depth' must be more than 0.0 and less than or equal to 1.0.") - - super().__init__(name="Vibrato") - - self.frequency: float = frequency - self.depth: float = depth - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "frequency": self.frequency, - "depth": self.depth - } - - -class Rotation(BaseFilter): - """A rotation filter. - - Rotates the audio around stereo channels which can be used to create a 3D effect. - - The default value provided for ``speed`` will play the track normally. - - Parameters - ---------- - speed: float - The speed at which the audio should rotate. - """ - - def __init__(self, speed: float = 5) -> None: - super().__init__(name="Rotation") - - self.speed: float = speed - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "rotationHz": self.speed, - } - - -class Distortion(BaseFilter): - """A distortion filter.""" - - def __init__( - self, - *, - sin_offset: float = 0.0, - sin_scale: float = 1.0, - cos_offset: float = 0.0, - cos_scale: float = 1.0, - tan_offset: float = 0.0, - tan_scale: float = 1.0, - offset: float = 0.0, - scale: float = 1.0 - ) -> None: - super().__init__(name="Distortion") - - self.sin_offset: float = sin_offset - self.sin_scale: float = sin_scale - self.cos_offset: float = cos_offset - self.cos_scale: float = cos_scale - self.tan_offset: float = tan_offset - self.tan_scale: float = tan_scale - self.offset: float = offset - self.scale: float = scale - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "sinOffset": self.sin_offset, - "sinScale": self.sin_scale, - "cosOffset": self.cos_offset, - "cosScale": self.cos_scale, - "tanOffset": self.tan_offset, - "tanScale": self.tan_scale, - "offset": self.offset, - "scale": self.scale - } - - -class ChannelMix(BaseFilter): - """A channel mix filter. - - Allows you to control what channel audio from the track is actually played on. - - Setting `left_to_left` and `right_to_right` to 1.0 will result in no change. - Setting all channels to 0.5 will result in all channels receiving the same audio. - - The default values provided for all the parameters will play the track normally. - - Parameters - ---------- - left_to_left: float - The "percentage" of audio from the left channel that should actually get played on the left channel. - left_to_right: float - The "percentage" of audio from the left channel that should play on the right channel. - right_to_left: float - The "percentage" of audio from the right channel that should actually get played on the right channel. - right_to_right: float - The "percentage" of audio from the right channel that should play on the left channel. - """ - - def __init__( - self, - *, - left_to_left: float = 1.0, - left_to_right: float = 0.0, - right_to_left: float = 0.0, - right_to_right: float = 1.0, - ) -> None: - - _all = (left_to_left, left_to_right, right_to_left, right_to_right) - - if any(value for value in _all if value < 0 or value > 1): - raise ValueError( - "'left_to_left', 'left_to_right', " - "'right_to_left', and 'right_to_right' " - "must all be between 0.0 and 1.0" - ) - - super().__init__(name="Channel Mix") - - self.left_to_left: float = left_to_left - self.right_to_right: float = right_to_right - self.left_to_right: float = left_to_right - self.right_to_left: float = right_to_left - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "leftToLeft": self.left_to_left, - "leftToRight": self.left_to_right, - "rightToLeft": self.right_to_left, - "rightToRight": self.right_to_right, - } - - @classmethod - def mono(cls) -> ChannelMix: - """Returns a ChannelMix filter that will play the track in mono.""" - return cls(left_to_left=0.5, left_to_right=0.5, right_to_left=0.5, right_to_right=0.5) - - @classmethod - def only_left(cls) -> ChannelMix: - """Returns a ChannelMix filter that will only play the tracks left channel.""" - return cls(left_to_left=1, left_to_right=0, right_to_left=0, right_to_right=0) - - @classmethod - def full_left(cls) -> ChannelMix: - """ - Returns a ChannelMix filter that will play the tracks left and right channels together only on the left channel. - """ - return cls(left_to_left=1, left_to_right=0, right_to_left=1, right_to_right=0) - - @classmethod - def only_right(cls) -> ChannelMix: - """Returns a ChannelMix filter that will only play the tracks right channel.""" - return cls(left_to_left=0, left_to_right=0, right_to_left=0, right_to_right=1) - - @classmethod - def full_right(cls) -> ChannelMix: - """ - Returns a ChannelMix filter that will play the tracks left and right channels together only on the right channel. - """ - return cls(left_to_left=0, left_to_right=1, right_to_left=0, right_to_right=1) - - @classmethod - def switch(cls) -> ChannelMix: - """Returns a ChannelMix filter that will switch the tracks left and right channels.""" - return cls(left_to_left=0, left_to_right=1, right_to_left=1, right_to_right=0) - - -class LowPass(BaseFilter): - """A low pass filter. - - Suppresses higher frequencies while allowing lower frequencies to pass through. - - The default value provided for ``smoothing`` will play the track normally. - - Parameters - ---------- - smoothing: float - The factor by which the filter will block higher frequencies. - """ - - def __init__( - self, - *, - smoothing: float = 20 - ) -> None: - super().__init__(name="Low Pass") - - self.smoothing: float = smoothing - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, float]: - return { - "smoothing": self.smoothing, - } - - -class Filter: - """A filter that can be applied to a track. - - This filter accepts an instance of itself as a parameter which allows - you to keep previous filters while also applying new ones or overwriting old ones. - - - .. container:: operations - - .. describe:: repr(filter) - - Returns an official string representation of this filter. - - - Parameters - ---------- - filter: wavelink.Filter - An instance of this filter class. - equalizer: wavelink.Equalizer - An equalizer to apply to the track. - karaoke: wavelink.Karaoke - A karaoke filter to apply to the track. - timescale: wavelink.Timescale - A timescale filter to apply to the track. - tremolo: wavelink.Tremolo - A tremolo filter to apply to the track. - vibrato: wavelink.Vibrato - A vibrato filter to apply to the track. - rotation: wavelink.Rotation - A rotation filter to apply to the track. - distortion: wavelink.Distortion - A distortion filter to apply to the track. - channel_mix: wavelink.ChannelMix - A channel mix filter to apply to the track. - low_pass: wavelink.LowPass - A low pass filter to apply to the track. - """ - - def __init__( - self, - _filter: Filter | None = None, - /, *, - equalizer: Equalizer | None = None, - karaoke: Karaoke | None = None, - timescale: Timescale | None = None, - tremolo: Tremolo | None = None, - vibrato: Vibrato | None = None, - rotation: Rotation | None = None, - distortion: Distortion | None = None, - channel_mix: ChannelMix | None = None, - low_pass: LowPass | None = None - ) -> None: - - self.filter: Filter | None = _filter - - self.equalizer: Equalizer | None = equalizer - self.karaoke: Karaoke | None = karaoke - self.timescale: Timescale | None = timescale - self.tremolo: Tremolo | None = tremolo - self.vibrato: Vibrato | None = vibrato - self.rotation: Rotation | None = rotation - self.distortion: Distortion | None = distortion - self.channel_mix: ChannelMix | None = channel_mix - self.low_pass: LowPass | None = low_pass - - def __repr__(self) -> str: - return f"" - - @property - def _payload(self) -> dict[str, Any]: - - payload = self.filter._payload.copy() if self.filter else {} - - if self.equalizer: - payload["equalizer"] = self.equalizer._payload - if self.karaoke: - payload["karaoke"] = self.karaoke._payload - if self.timescale: - payload["timescale"] = self.timescale._payload - if self.tremolo: - payload["tremolo"] = self.tremolo._payload - if self.vibrato: - payload["vibrato"] = self.vibrato._payload - if self.rotation: - payload["rotation"] = self.rotation._payload - if self.distortion: - payload["distortion"] = self.distortion._payload - if self.channel_mix: - payload["channelMix"] = self.channel_mix._payload - if self.low_pass: - payload["lowPass"] = self.low_pass._payload - - return payload From b411f332e22f18ef5fd3b110ec2f8d11bfe62e7a Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 14 Jul 2023 19:18:44 +1000 Subject: [PATCH 005/132] Update pyproject. --- pyproject.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 289d3eed..715fae12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "Wavelink" -version = "2.6.0" +version = "3.0.0b1" authors = [ - { name="EvieePy", email="evieepy@gmail.com" }, + { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] dynamic = ["dependencies"] -description = "A robust and powerful Lavalink wrapper for discord.py" +description = "A robust and powerful, fully asynchronous Lavalink wrapper built for discord.py in Python." readme = "README.rst" requires-python = ">=3.10" classifiers = [ @@ -30,3 +30,6 @@ classifiers = [ [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} + +[tool.black] +line-length = 120 \ No newline at end of file From 333c34c0aa125a8bea049e527bed25708df36dbe Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:16:47 +1000 Subject: [PATCH 006/132] Add AutoPlayMode enum. --- wavelink/enums.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/wavelink/enums.py b/wavelink/enums.py index a43e313e..199a718b 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -23,7 +23,7 @@ """ import enum -__all__ = ("NodeStatus", "TrackSource", "DiscordVoiceCloseType") +__all__ = ("NodeStatus", "TrackSource", "DiscordVoiceCloseType", "AutoPlayMode") class NodeStatus(enum.Enum): @@ -108,3 +108,10 @@ class DiscordVoiceCloseType(enum.Enum): DISCONNECTED = 4014 VOICE_SERVER_CRASHED = 4015 UNKNOWN_ENCRYPTION_MODE = 4016 + + +class AutoPlayMode(enum.Enum): + + enabled = 0 + partial = 1 + disabled = 2 From b6f0dfb02779db894db706294efc3cc4ee7717e0 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:18:19 +1000 Subject: [PATCH 007/132] Add/Change various exceptions. --- wavelink/exceptions.py | 43 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py index 2622d272..4cf5eeaa 100644 --- a/wavelink/exceptions.py +++ b/wavelink/exceptions.py @@ -21,6 +21,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .types.response import ErrorResponse, LoadedErrorPayload __all__ = ( @@ -29,7 +35,10 @@ "AuthorizationFailedException", "InvalidNodeException", "LavalinkException", + "LavalinkLoadException", "InvalidChannelStateException", + "ChannelTimeoutException", + "QueueEmpty", ) @@ -67,14 +76,40 @@ class LavalinkException(WavelinkException): The response reason. Could be ``None`` if no reason was provided. """ - def __init__(self, message: str, /, *, status: int, reason: str | None) -> None: - self.status = status - self.reason = reason + def __init__(self, msg: str | None = None, /, *, data: ErrorResponse) -> None: + self.timestamp: int = data["timestamp"] + self.status: int = data["status"] + self.error: str = data["error"] + self.trace: str | None = data.get("trace") + self.path: str = data["path"] + + if not msg: + msg = f"Failed to fulfill request to Lavalink: status={self.status}, reason={self.error}, path={self.path}" + + super().__init__(msg) + + +class LavalinkLoadException(WavelinkException): + def __init__(self, msg: str | None = None, /, *, data: LoadedErrorPayload) -> None: + self.error: str = data["message"] + self.severity: str = data["severity"] + self.cause: str = data["cause"] - super().__init__(message) + if not msg: + msg = f"Failed to Load Tracks: error={self.error}, severity={self.severity}, cause={self.cause}" + + super().__init__(msg) class InvalidChannelStateException(WavelinkException): """Exception raised when a :class:`~wavelink.Player` tries to connect to an invalid channel or has invalid permissions to use this channel. """ + + +class ChannelTimeoutException(WavelinkException): + """Exception raised when connecting to a voice channel times out.""" + + +class QueueEmpty(WavelinkException): + """Exception raised when you try to retrieve from an empty queue.""" From 723d0ba7da2bfee967df5c719ae60664c1682171 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:18:58 +1000 Subject: [PATCH 008/132] Added fetch_tracks to Pool. Various Node changes. --- wavelink/node.py | 171 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 140 insertions(+), 31 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index c73c3242..4d71b8b7 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -25,6 +25,7 @@ import logging import secrets +import urllib from typing import TYPE_CHECKING, Iterable, Union import aiohttp @@ -33,19 +34,32 @@ from . import __version__ from .enums import NodeStatus -from .exceptions import (AuthorizationFailedException, InvalidClientException, - InvalidNodeException, LavalinkException) +from .exceptions import ( + AuthorizationFailedException, + InvalidClientException, + InvalidNodeException, + LavalinkException, + LavalinkLoadException, + WavelinkException, +) +from .tracks import Playable, Playlist from .websocket import Websocket if TYPE_CHECKING: from .player import Player - from .tracks import Playable, Playlist - from .types.request import Request - from .types.response import (EmptyLoadedResponse, ErrorLoadedResponse, - ErrorResponse, InfoResponse, PlayerResponse, - PlaylistLoadedResponse, SearchLoadedResponse, - StatsResponse, TrackLoadedResponse, - UpdateResponse) + from .types.request import Request, UpdateSessionRequest + from .types.response import ( + EmptyLoadedResponse, + ErrorLoadedResponse, + ErrorResponse, + InfoResponse, + PlayerResponse, + PlaylistLoadedResponse, + SearchLoadedResponse, + StatsResponse, + TrackLoadedResponse, + UpdateResponse, + ) from .types.tracks import PlaylistPayload, TrackPayload LoadedResponse = Union[ @@ -81,6 +95,8 @@ def __init__( self._players: dict[int, Player] = {} + self._spotify_enabled: bool = False + def __repr__(self) -> str: return f"Node(identifier={self.identifier}, uri={self.uri}, status={self.status}, players={len(self.players)})" @@ -177,11 +193,33 @@ async def _connect(self, *, client: discord.Client | None) -> None: websocket: Websocket = Websocket(node=self) await websocket.connect() + info: InfoResponse = await self._fetch_info() + if "spotify" in info["sourceManagers"]: + self._spotify_enabled = True + async def _fetch_players(self) -> list[PlayerResponse]: - ... + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players" - async def _fetch_player(self) -> PlayerResponse: - ... + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: list[PlayerResponse] = await resp.json() + return resp_data + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) + + async def _fetch_player(self, guild_id: int, /) -> PlayerResponse: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players/{guild_id}" + + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: PlayerResponse = await resp.json() + return resp_data + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) async def _update_player(self, guild_id: int, /, *, data: Request, replace: bool = False) -> PlayerResponse: no_replace: bool = not replace @@ -193,20 +231,44 @@ async def _update_player(self, guild_id: int, /, *, data: Request, replace: bool resp_data: PlayerResponse = await resp.json() return resp_data - raise LavalinkException( - f"Failed to fulfill request to Lavalink: status={resp.status}, reason={resp.reason}", - status=resp.status, - reason=resp.reason, - ) + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) - async def _destroy_player(self) -> None: - ... + async def _destroy_player(self, guild_id: int, /) -> None: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players/{guild_id}" - async def _update_session(self) -> UpdateResponse: - ... + async with self._session.delete(url=uri, headers=self.headers) as resp: + if resp.status == 204: + return - async def _fetch_tracks(self) -> LoadedResponse: - ... + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) + + async def _update_session(self, *, data: UpdateSessionRequest) -> UpdateResponse: + uri: str = f"{self.uri}/v4/sessions/{self.session_id}" + + async with self._session.patch(url=uri, data=data) as resp: + if resp.status == 200: + resp_data: UpdateResponse = await resp.json() + return resp_data + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) + + async def _fetch_tracks(self, query: str) -> LoadedResponse: + uri: str = f"{self.uri}/v4/loadtracks?identifier={query}" + + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: LoadedResponse = await resp.json() + return resp_data + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) async def _decode_track(self) -> TrackPayload: ... @@ -215,13 +277,39 @@ async def _decode_tracks(self) -> list[TrackPayload]: ... async def _fetch_info(self) -> InfoResponse: - ... + uri: str = f"{self.uri}/v4/info" + + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: InfoResponse = await resp.json() + return resp_data + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) async def _fetch_stats(self) -> StatsResponse: - ... + uri: str = f"{self.uri}/v4/stats" + + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + resp_data: StatsResponse = await resp.json() + return resp_data + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) async def _fetch_version(self) -> str: - ... + uri: str = f"{self.uri}/version" + + async with self._session.get(url=uri, headers=self.headers) as resp: + if resp.status == 200: + return await resp.text() + + else: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) def get_player(self, guild_id: int, /) -> Player | None: return self._players.get(guild_id, None) @@ -321,9 +409,30 @@ def get_node(cls, identifier: str | None = None, /) -> Node: return sorted(nodes, key=lambda n: len(n.players))[0] @classmethod - async def _fetch_tracks(cls, query: str, /, cls_: type[Playable]) -> list[Playable]: - ... + async def fetch_tracks(cls, query: str) -> list[Playable] | list[Playlist]: + encoded_query = urllib.parse.quote(query) # type: ignore - @classmethod - async def _fetch_playlist(cls, query: str, /, cls_: type[Playlist]) -> Playlist: - ... + node: Node = cls.get_node() + resp: LoadedResponse = await node._fetch_tracks(encoded_query) + + if resp["loadType"] == "track": + track = Playable(data=resp["data"]) + + return [track] + + elif resp["loadType"] == "search": + tracks = [Playable(data=tdata) for tdata in resp["data"]] + + return tracks + + if resp["loadType"] == "playlist": + return [Playlist(data=resp["data"])] + + elif resp["loadType"] == "empty": + return [] + + elif resp["loadType"] == "error": + raise LavalinkLoadException(data=resp["data"]) + + else: + return [] From 04e591245d086f25a9a8302bdaa472911f658aa4 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:19:22 +1000 Subject: [PATCH 009/132] Fix typing issues in payloads.py --- wavelink/payloads.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/wavelink/payloads.py b/wavelink/payloads.py index 8547cf0f..679e6ad8 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -26,10 +26,10 @@ from typing import TYPE_CHECKING from .enums import DiscordVoiceCloseType -from .player import Player -from .tracks import Playable if TYPE_CHECKING: + from .player import Player + from .tracks import Playable from .types.state import PlayerState from .types.stats import CPUStats, FrameStats, MemoryStats from .types.websocket import StatsOP, TrackExceptionPayload @@ -120,4 +120,7 @@ def __init__(self, data: StatsOP) -> None: self.memory: StatsEventMemory = StatsEventMemory(data=data["memory"]) self.cpu: StatsEventCPU = StatsEventCPU(data=data["cpu"]) - self.frames: StatsEventFrames = StatsEventFrames(data=data["frameStats"]) + self.frames: StatsEventFrames | None = None + + if data["frameStats"]: + self.frames = StatsEventFrames(data=data["frameStats"]) From bcb61cc8d1b97fe4b49c598dee1f1f0649f84b11 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:19:40 +1000 Subject: [PATCH 010/132] Add PlayableUnion --- wavelink/types/playable.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 wavelink/types/playable.py diff --git a/wavelink/types/playable.py b/wavelink/types/playable.py new file mode 100644 index 00000000..133c897b --- /dev/null +++ b/wavelink/types/playable.py @@ -0,0 +1,5 @@ +from typing import Union + +from ..tracks import Playable, Playlist + +PlayableUnion = Union[Playable, Playlist] From 0587805a05caea872e776ece6d36f3ba7ed694e5 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:20:22 +1000 Subject: [PATCH 011/132] Various changes to Player. --- wavelink/player.py | 100 +++++++++++++++++++++++++++++++++------------ 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index aa23b9e7..7a9257cd 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -23,19 +23,27 @@ """ from __future__ import annotations +import asyncio import logging -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, Union, cast +import async_timeout import discord +from discord.abc import Connectable from discord.utils import MISSING -from .exceptions import InvalidChannelStateException +from .exceptions import ( + ChannelTimeoutException, + InvalidChannelStateException, + LavalinkException, +) from .node import Pool +from .queue import Queue +from .tracks import Playable if TYPE_CHECKING: from discord.types.voice import GuildVoiceState as GuildVoiceStatePayload - from discord.types.voice import \ - VoiceServerUpdate as VoiceServerUpdatePayload + from discord.types.voice import VoiceServerUpdate as VoiceServerUpdatePayload from typing_extensions import Self from .node import Node @@ -43,11 +51,10 @@ from .types.response import PlayerResponse from .types.state import PlayerVoiceState, VoiceState - -logger: logging.Logger = logging.getLogger(__name__) + VocalGuildChannel = Union[discord.VoiceChannel, discord.StageChannel] -VoiceChannel = Union[discord.VoiceChannel, discord.StageChannel] +logger: logging.Logger = logging.getLogger(__name__) class Player(discord.VoiceProtocol): @@ -63,18 +70,21 @@ class Player(discord.VoiceProtocol): """ - def __call__(self, client: discord.Client, channel: VoiceChannel) -> Self: + channel: VocalGuildChannel + + def __call__(self, client: discord.Client, channel: VocalGuildChannel) -> Self: + super().__init__(client, channel) + self.client = client - self.channel = channel self._guild = channel.guild return self def __init__( - self, client: discord.Client = MISSING, channel: VoiceChannel = MISSING, *, nodes: list[Node] | None = None + self, client: discord.Client = MISSING, channel: Connectable = MISSING, *, nodes: list[Node] | None = None ) -> None: + super().__init__(client, channel) self.client: discord.Client = client - self.channel: VoiceChannel = channel self._guild: discord.Guild | None = None self._voice_state: PlayerVoiceState = {"voice": {}} @@ -88,6 +98,13 @@ def __init__( if self.client is MISSING and self.node.client: self.client = self.node.client + self._connected: bool = False + self._connection_event: asyncio.Event = asyncio.Event() + + self._current: Playable | None = None + + self.queue: Queue = Queue() + super().__init__(client=self.client, channel=self.channel) @property @@ -108,6 +125,14 @@ def guild(self) -> discord.Guild | None: """ return self._guild + @property + def connected(self) -> bool: + return self._connected + + @property + def playing(self) -> bool: + return self._connected and self._current is not None + async def on_voice_state_update(self, data: GuildVoiceStatePayload, /) -> None: channel_id = data["channel_id"] @@ -115,6 +140,8 @@ async def on_voice_state_update(self, data: GuildVoiceStatePayload, /) -> None: await self._destroy() return + self._connected = True + self._voice_state["voice"]["session_id"] = data["session_id"] self.channel = self.client.get_channel(int(channel_id)) # type: ignore @@ -139,15 +166,18 @@ async def _dispatch_voice_update(self) -> None: return request: RequestPayload = {"voice": {"sessionId": session_id, "token": token, "endpoint": endpoint}} - resp: PlayerResponse = await self.node._update_player(self.guild.id, data=request) - # warning: print - print(f"RESPONSE WHEN UPDATING STATE: {resp}") + try: + await self.node._update_player(self.guild.id, data=request) + except LavalinkException: + await self.disconnect() + else: + self._connection_event.set() logger.debug(f"Player {self.guild.id} is dispatching VOICE_UPDATE.") async def connect( - self, *, timeout: float, reconnect: bool, self_deaf: bool = False, self_mute: bool = False + self, *, timeout: float = 5.0, reconnect: bool, self_deaf: bool = False, self_mute: bool = False ) -> None: if self.channel is None: msg: str = 'Please use "discord.VoiceChannel.connect(cls=...)" and pass this Player to cls.' @@ -160,24 +190,44 @@ async def connect( assert self.guild is not None await self.guild.change_voice_state(channel=self.channel, self_mute=self_mute, self_deaf=self_deaf) - # warning: print - print(f"PLAYER CONNECTED TO GUILD {self.guild.id} ON CHANNEL {self.channel}") + try: + async with async_timeout.timeout(timeout): + await self._connection_event.wait() + except (asyncio.TimeoutError, asyncio.CancelledError): + msg = f"Unable to connect to {self.channel} as it exceeded the timeout of {timeout} seconds." + raise ChannelTimeoutException(msg) - async def play(self, ytid: str) -> None: + async def play(self, track: Playable) -> None: """Test play command.""" assert self.guild is not None - request: RequestPayload = {"identifier": ytid, "volume": 20} + request: RequestPayload = {"encodedTrack": track.encoded, "volume": 20} resp: PlayerResponse = await self.node._update_player(self.guild.id, data=request) - # warning: print - print(f"PLAYER STARTED PLAYING: {resp}") + self._current = track + + def _invalidate(self) -> None: + self._connected = False + + try: + self.cleanup() + except (AttributeError, KeyError): + pass async def disconnect(self, **kwargs: Any) -> None: - ... + assert self.guild + + await self._destroy() + await self.guild.change_voice_state(channel=None) async def _destroy(self) -> None: - ... + assert self.guild + + self._invalidate() + player: Self | None = self.node._players.pop(self.guild.id, None) - def cleanup(self) -> None: - ... + if player: + try: + await self.node._destroy_player(self.guild.id) + except LavalinkException: + pass From 9bcda6b2015e5ac913c046dd75df6a7d33bb43f0 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:20:49 +1000 Subject: [PATCH 012/132] Fix typing on Queue. (This still needs to be reworked slightly) --- wavelink/queue.py | 96 ++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 52 deletions(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index 26c65caf..c7629c49 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -24,20 +24,15 @@ from __future__ import annotations import asyncio +import random from collections import deque from collections.abc import AsyncIterator, Iterable, Iterator from copy import copy -import random from .exceptions import QueueEmpty -from .tracks import Playable, YouTubePlaylist, SoundCloudPlaylist -from .ext import spotify +from .tracks import Playable, Playlist - -__all__ = ( - 'BaseQueue', - 'Queue' -) +__all__ = ("BaseQueue", "Queue") class BaseQueue: @@ -111,7 +106,7 @@ class BaseQueue: """ def __init__(self) -> None: - self._queue: deque[Playable, spotify.SpotifyTrack] = deque() + self._queue: deque[Playable] = deque() def __str__(self) -> str: """String showing all Playable objects appearing as a list.""" @@ -119,8 +114,7 @@ def __str__(self) -> str: def __repr__(self) -> str: """Official representation displaying member count.""" - return ( - f"BaseQueue(member_count={self.count})") + return f"BaseQueue(member_count={self.count})" def __bool__(self) -> bool: """Treats the queue as a ``bool``, with it evaluating ``True`` when it contains members. @@ -135,7 +129,7 @@ def __bool__(self) -> bool: """ return bool(self.count) - def __call__(self, item: Playable | spotify.SpotifyTrack) -> None: + def __call__(self, item: Playable) -> None: """Allows the queue instance to be called directly in order to add a member. Example @@ -159,7 +153,7 @@ def __len__(self) -> int: """ return self.count - def __getitem__(self, index: int) -> Playable | spotify.SpotifyTrack: + def __getitem__(self, index: int) -> Playable: """Returns a member at the given position. Does **not** remove the item from queue. @@ -176,7 +170,7 @@ def __getitem__(self, index: int) -> Playable | spotify.SpotifyTrack: return self._queue[index] - def __setitem__(self, index: int, item: Playable | spotify.SpotifyTrack): + def __setitem__(self, index: int, item: Playable): """Inserts an item at the given position. Example @@ -203,7 +197,7 @@ def __delitem__(self, index: int) -> None: """ self._queue.__delitem__(index) - def __iter__(self) -> Iterator[Playable | spotify.SpotifyTrack]: + def __iter__(self) -> Iterator[Playable]: """Iterate over members in the queue. Does **not** remove items when iterating. @@ -218,7 +212,7 @@ def __iter__(self) -> Iterator[Playable | spotify.SpotifyTrack]: """ return self._queue.__iter__() - def __reversed__(self) -> Iterator[Playable | spotify.SpotifyTrack]: + def __reversed__(self) -> Iterator[Playable]: """Iterate over members in a reverse order. Example @@ -231,7 +225,7 @@ def __reversed__(self) -> Iterator[Playable | spotify.SpotifyTrack]: """ return self._queue.__reversed__() - def __contains__(self, item: Playable | spotify.SpotifyTrack) -> bool: + def __contains__(self, item: Playable) -> bool: """Check if a track is a member of the queue. Example @@ -244,7 +238,7 @@ def __contains__(self, item: Playable | spotify.SpotifyTrack) -> bool: """ return item in self._queue - def __add__(self, other: Iterable[Playable | spotify.SpotifyTrack]): + def __add__(self, other: Iterable[Playable]): """Return a new queue containing all members, including old members. Example @@ -271,7 +265,7 @@ def __iadd__(self, other: Iterable[Playable] | Playable): player.queue += [track1, track2, ...] """ - if isinstance(other, (Playable, spotify.SpotifyTrack)): + if isinstance(other, Playable): self.put(other) return self @@ -282,40 +276,36 @@ def __iadd__(self, other: Iterable[Playable] | Playable): raise TypeError(f"Adding '{type(other)}' type to the queue is not supported.") - def _get(self) -> Playable | spotify.SpotifyTrack: + def _get(self) -> Playable: if self.is_empty: raise QueueEmpty("No items currently in the queue.") return self._queue.popleft() - def _drop(self) -> Playable | spotify.SpotifyTrack: + def _drop(self) -> Playable: return self._queue.pop() - def _index(self, item: Playable | spotify.SpotifyTrack) -> int: + def _index(self, item: Playable) -> int: return self._queue.index(item) - def _put(self, item: Playable | spotify.SpotifyTrack) -> None: + def _put(self, item: Playable) -> None: self._queue.append(item) - def _insert(self, index: int, item: Playable | spotify.SpotifyTrack) -> None: + def _insert(self, index: int, item: Playable) -> None: self._queue.insert(index, item) @staticmethod - def _check_playable(item: Playable | spotify.SpotifyTrack) -> Playable | spotify.SpotifyTrack: - if not isinstance(item, (Playable, spotify.SpotifyTrack)): + def _check_playable(item: Playable | Playlist) -> None: + if not isinstance(item, (Playable, Playlist)): raise TypeError("Only Playable objects are supported.") - return item - @classmethod - def _check_playable_container(cls, iterable: Iterable) -> list[Playable | spotify.SpotifyTrack]: + def _check_playable_container(cls, iterable: Iterable) -> None: iterable = list(iterable) for item in iterable: cls._check_playable(item) - return iterable - @property def count(self) -> int: """Returns queue member count.""" @@ -326,7 +316,7 @@ def is_empty(self) -> bool: """Returns ``True`` if queue has no members.""" return not bool(self.count) - def get(self) -> Playable | spotify.SpotifyTrack: + def get(self) -> Playable: """Return next immediately available item in queue if any. Raises :exc:`~wavelink.QueueEmpty` if no items in queue. @@ -336,7 +326,7 @@ def get(self) -> Playable | spotify.SpotifyTrack: return self._get() - def pop(self) -> Playable | spotify.SpotifyTrack: + def pop(self) -> Playable: """Return item from the right end side of the queue. Raises :exc:`~wavelink.QueueEmpty` if no items in queue. @@ -346,14 +336,15 @@ def pop(self) -> Playable | spotify.SpotifyTrack: return self._queue.pop() - def find_position(self, item: Playable | spotify.SpotifyTrack) -> int: + def find_position(self, item: Playable) -> int: """Find the position a given item within the queue. Raises :exc:`ValueError` if item is not in the queue. """ - return self._index(self._check_playable(item)) + self._check_playable(item) + return self._index(item) - def put(self, item: Playable | spotify.SpotifyTrack) -> None: + def put(self, item: Playable | Playlist) -> None: """Put the given item into the back of the queue. If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist`, all @@ -373,17 +364,18 @@ def put(self, item: Playable | spotify.SpotifyTrack) -> None: """ self._check_playable(item) - if isinstance(item, (YouTubePlaylist, SoundCloudPlaylist)): + if isinstance(item, Playlist): for track in item.tracks: self._put(track) else: self._put(item) - def put_at_index(self, index: int, item: Playable | spotify.SpotifyTrack) -> None: + def put_at_index(self, index: int, item: Playable) -> None: """Put the given item into the queue at the specified index.""" - self._insert(index, self._check_playable(item)) + self._check_playable(item) + self._insert(index, item) - def put_at_front(self, item: Playable | spotify.SpotifyTrack) -> None: + def put_at_front(self, item: Playable) -> None: """Put the given item into the front of the queue.""" self.put_at_index(0, item) @@ -403,7 +395,7 @@ def shuffle(self) -> None: """ random.shuffle(self._queue) - def extend(self, iterable: Iterable[Playable | spotify.SpotifyTrack], *, atomic: bool = True) -> None: + def extend(self, iterable: Iterable[Playable], *, atomic: bool = True) -> None: """Add the members of the given iterable to the end of the queue. If atomic is set to ``True``, no tracks will be added upon any exceptions. @@ -411,7 +403,7 @@ def extend(self, iterable: Iterable[Playable | spotify.SpotifyTrack], *, atomic: If atomic is set to ``False``, as many tracks will be added as possible. """ if atomic: - iterable = self._check_playable_container(iterable) + self._check_playable_container(iterable) for item in iterable: self.put(item) @@ -476,7 +468,7 @@ def __init__(self): self._finished = asyncio.Event() self._finished.set() - async def __aiter__(self) -> AsyncIterator[Playable | spotify.SpotifyTrack]: + async def __aiter__(self) -> AsyncIterator[Playable]: """Pops members as it iterates the queue, waiting for new members when exhausted. **Does** remove items when iterating. @@ -494,10 +486,10 @@ async def __aiter__(self) -> AsyncIterator[Playable | spotify.SpotifyTrack]: while True: yield await self.get_wait() - def get(self) -> Playable | spotify.SpotifyTrack: + def get(self) -> Playable: return self._get() - def _get(self) -> Playable | spotify.SpotifyTrack: + def _get(self) -> Playable: if self.loop and self._loaded: return self._loaded @@ -510,10 +502,10 @@ def _get(self) -> Playable | spotify.SpotifyTrack: self._loaded = item return item - async def _put(self, item: Playable | spotify.SpotifyTrack) -> None: + async def _put(self, item: Playable | Playlist) -> None: self._check_playable(item) - if isinstance(item, (YouTubePlaylist, SoundCloudPlaylist)): + if isinstance(item, Playlist): for track in item.tracks: super()._put(track) await asyncio.sleep(0) @@ -523,7 +515,7 @@ async def _put(self, item: Playable | spotify.SpotifyTrack) -> None: self._wakeup_next() - def _insert(self, index: int, item: Playable | spotify.SpotifyTrack) -> None: + def _insert(self, index: int, item: Playable) -> None: super()._insert(index, item) self._wakeup_next() @@ -535,7 +527,7 @@ def _wakeup_next(self) -> None: waiter.set_result(None) break - async def get_wait(self) -> Playable | spotify.SpotifyTrack: + async def get_wait(self) -> Playable: """|coro| Return the next item in queue once available. @@ -567,7 +559,7 @@ async def get_wait(self) -> Playable | spotify.SpotifyTrack: return self.get() - async def put_wait(self, item: Playable | spotify.SpotifyTrack) -> None: + async def put_wait(self, item: Playable) -> None: """|coro| Put an item into the queue asynchronously using ``await``. @@ -589,7 +581,7 @@ async def put_wait(self, item: Playable | spotify.SpotifyTrack) -> None: """ await self._put(item) - def put(self, item: Playable | spotify.SpotifyTrack) -> None: + def put(self, item: Playable | Playlist) -> None: """Put the given item into the back of the queue. If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist`, all tracks from this playlist will be put @@ -609,7 +601,7 @@ def put(self, item: Playable | spotify.SpotifyTrack) -> None: """ self._check_playable(item) - if isinstance(item, YouTubePlaylist): + if isinstance(item, Playlist): for track in item.tracks: super()._put(track) else: From ceac60de1c2138dd670a7022123089e0e2364349 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:21:08 +1000 Subject: [PATCH 013/132] Added UpdateSessionRequest TypedDict --- wavelink/types/request.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wavelink/types/request.py b/wavelink/types/request.py index 62911be4..a308159e 100644 --- a/wavelink/types/request.py +++ b/wavelink/types/request.py @@ -51,4 +51,9 @@ class IdentifierRequest(_BaseRequest): identifier: str +class UpdateSessionRequest(TypedDict): + resuming: NotRequired[bool] + timeout: NotRequired[int] + + Request: TypeAlias = "_BaseRequest | EncodedTrackRequest | IdentifierRequest" From af14f1676189ee08517890111f8c587605dd52f4 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:22:26 +1000 Subject: [PATCH 014/132] Added pluginInfo to Payload TypedDicts --- wavelink/types/tracks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wavelink/types/tracks.py b/wavelink/types/tracks.py index 6d28c233..53a5c18e 100644 --- a/wavelink/types/tracks.py +++ b/wavelink/types/tracks.py @@ -49,8 +49,10 @@ class PlaylistInfoPayload(TypedDict): class TrackPayload(TypedDict): encoded: str info: TrackInfoPayload + pluginInfo: dict class PlaylistPayload(TypedDict): info: PlaylistInfoPayload tracks: list[TrackPayload] + pluginInfo: dict From c76380f94f81180aed55cdc60d47578d988b3ab3 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:23:06 +1000 Subject: [PATCH 015/132] Various changes to tracks.py --- wavelink/tracks.py | 92 +++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 9509623b..c8a44dac 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -23,24 +23,45 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, overload +from typing import TYPE_CHECKING, Any, TypeAlias, Union, overload import yarl +import wavelink + from .enums import TrackSource -from .node import Node, Pool if TYPE_CHECKING: - from .types.tracks import TrackInfoPayload, TrackPayload + from .types.tracks import ( + PlaylistInfoPayload, + PlaylistPayload, + TrackInfoPayload, + TrackPayload, + ) _source_mapping: dict[TrackSource | str | None, str] = { - TrackSource.YouTube: "ytsearch:", - TrackSource.SoundCloud: "scsearch:", - TrackSource.YouTubeMusic: "ytmsearch:", + TrackSource.YouTube: "ytsearch", + TrackSource.SoundCloud: "scsearch", + TrackSource.YouTubeMusic: "ytmsearch", } +Search: TypeAlias = "list[Playable] | list[Playlist]" + + +class Album: + def __init__(self, *, data: dict[Any, Any]) -> None: + self.name: str | None = data.get("albumName") + self.url: str | None = data.get("albumUrl") + + +class Artist: + def __init__(self, *, data: dict[Any, Any]) -> None: + self.url: str | None = data.get("artistUrl") + self.artwork: str | None = data.get("artistArtworkUrl") + + class Playable: def __init__(self, data: TrackPayload) -> None: info: TrackInfoPayload = data["info"] @@ -58,6 +79,13 @@ def __init__(self, data: TrackPayload) -> None: self.isrc: str | None = info.get("isrc") self.source: str = info["sourceName"] + plugin: dict[Any, Any] = data["pluginInfo"] + self.album: Album = Album(data=plugin) + self.artist: Artist = Artist(data=plugin) + + self.preview_url: str | None = plugin.get("previewUrl") + self.is_preview: bool | None = plugin.get("isPreview") + def __hash__(self) -> int: return hash(self.encoded) @@ -73,40 +101,38 @@ def __eq__(self, other: object) -> bool: return self.encoded == other.encoded - @overload - async def search(self, query: str, /, source: TrackSource | str | None) -> list[Playable]: - ... - - @overload - async def search(self, query: str, /, source: TrackSource | str | None) -> Playlist: - ... - - async def search(self, query: str, /, source: TrackSource | str | None): + @classmethod + async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTube) -> Search: prefix: TrackSource | str | None = _source_mapping.get(source, source) check = yarl.URL(query) - if str(check.host) == "youtube.com" or str(check.host) == "www.youtube.com" and check.query.get("list"): - ytplay: Playlist = await Pool._fetch_playlist(query, cls_=Playlist) - return ytplay - - elif str(check.host) == "soundcloud.com" or str(check.host) == "www.soundcloud.com" and "sets" in check.parts: - scplay: Playlist = await Pool._fetch_playlist(query, cls_=Playlist) - return scplay - - elif check.host: - tracks: list[Playable] = await Pool._fetch_tracks(query, cls_=Playable) + if check.host: + tracks: Search = await wavelink.Pool.fetch_tracks(query) return tracks + if not prefix: + term: str = query else: - if isinstance(prefix, TrackSource) or not prefix: - term: str = query - else: - term: str = f"{prefix}{query}" + assert not isinstance(prefix, TrackSource) + term: str = f"{prefix.removesuffix(':')}:{query}" - tracks: list[Playable] = await Pool._fetch_tracks(term, cls_=Playable) - - return tracks + tracks: Search = await wavelink.Pool.fetch_tracks(term) + return tracks class Playlist: - ... + def __init__(self, data: PlaylistPayload) -> None: + info: PlaylistInfoPayload = data["info"] + self.name: str = info["name"] + self.selected: int = info["selectedTrack"] + + self.tracks: list[Playable] = [Playable(data=track) for track in data["tracks"]] + + plugin: dict[Any, Any] = data["pluginInfo"] + self.type: str | None = plugin.get("type") + self.url: str | None = plugin.get("url") + self.artwork: str | None = plugin.get("artworkUrl") + self.author: str | None = plugin.get("author") + + def __str__(self) -> str: + return self.name From ad5ab72143e7d545a8e3f277880321fd6a7932fa Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:23:33 +1000 Subject: [PATCH 016/132] Remove player current track in websocket TrackEnd --- wavelink/websocket.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 45dc7f96..0c3b9e72 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -174,6 +174,9 @@ async def keep_alive(self) -> None: track: Playable = Playable(data["track"]) # Fuck off pycharm... reason: str = data["reason"] + if player and reason != "replaced": + player._current = None + endpayload: TrackEndEventPayload = TrackEndEventPayload(player=player, track=track, reason=reason) self.dispatch("track_end", endpayload) From dd64e13ab777a40a22d31c3d86340fb913c42557 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:24:11 +1000 Subject: [PATCH 017/132] Black/isort and added __init__ imports. --- pyproject.toml | 7 +++++-- wavelink/__init__.py | 5 ++++- wavelink/enums.py | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 715fae12..cd06e80b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "Wavelink" +name = "wavelink" version = "3.0.0b1" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, @@ -32,4 +32,7 @@ classifiers = [ dependencies = {file = ["requirements.txt"]} [tool.black] -line-length = 120 \ No newline at end of file +line-length = 120 + +[tool.isort] +profile = "black" \ No newline at end of file diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 692c8276..0011c83a 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -28,7 +28,10 @@ __version__ = "3.0.0b1" -from .enums import NodeStatus +from .enums import * from .exceptions import * from .node import Node, Pool +from .payloads import * from .player import Player +from .tracks import Playable, Playlist, Search +from .types.playable import PlayableUnion diff --git a/wavelink/enums.py b/wavelink/enums.py index 199a718b..eac64774 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -111,7 +111,6 @@ class DiscordVoiceCloseType(enum.Enum): class AutoPlayMode(enum.Enum): - enabled = 0 partial = 1 disabled = 2 From 1b26c15584388367505c817aa4dfb9a526cc3346 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 26 Jul 2023 06:30:40 +1000 Subject: [PATCH 018/132] Remove double super calls --- wavelink/player.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 7a9257cd..85d42276 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -105,8 +105,6 @@ def __init__( self.queue: Queue = Queue() - super().__init__(client=self.client, channel=self.channel) - @property def node(self) -> Node: """The :class:`Player`'s currently selected :class:`Node`. From 81dc909d16de7a8b525e4a5f47cd08aac0ba1500 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 29 Jul 2023 03:02:38 +1000 Subject: [PATCH 019/132] Add cast to payloads. Remove redundant assignment. --- wavelink/payloads.py | 15 +++++++++------ wavelink/player.py | 9 +++++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/wavelink/payloads.py b/wavelink/payloads.py index 679e6ad8..02e0c991 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -23,12 +23,15 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from .enums import DiscordVoiceCloseType +import wavelink + if TYPE_CHECKING: from .player import Player + from .tracks import Playable from .types.state import PlayerState from .types.stats import CPUStats, FrameStats, MemoryStats @@ -48,27 +51,27 @@ class TrackStartEventPayload: def __init__(self, player: Player | None, track: Playable) -> None: - self.player = player + self.player = cast(wavelink.Player, player) self.track = track class TrackEndEventPayload: def __init__(self, player: Player | None, track: Playable, reason: str) -> None: - self.player = player + self.player = cast(wavelink.Player, player) self.track = track self.reason = reason class TrackExceptionEventPayload: def __init__(self, player: Player | None, track: Playable, exception: TrackExceptionPayload) -> None: - self.player = player + self.player = cast(wavelink.Player, player) self.track = track self.exception = exception class TrackStuckEventPayload: def __init__(self, player: Player | None, track: Playable, threshold: int) -> None: - self.player = player + self.player = cast(wavelink.Player, player) self.track = track self.threshold = threshold @@ -83,7 +86,7 @@ def __init__(self, player: Player | None, code: int, reason: str, by_remote: boo class PlayerUpdateEventPayload: def __init__(self, player: Player | None, state: PlayerState) -> None: - self.player = player + self.player = cast(wavelink.Player, player) self.time: int = state["time"] self.position: int = state["position"] self.connected: bool = state["connected"] diff --git a/wavelink/player.py b/wavelink/player.py index 85d42276..dbabd9ed 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -75,7 +75,6 @@ class Player(discord.VoiceProtocol): def __call__(self, client: discord.Client, channel: VocalGuildChannel) -> Self: super().__init__(client, channel) - self.client = client self._guild = channel.guild return self @@ -84,6 +83,7 @@ def __init__( self, client: discord.Client = MISSING, channel: Connectable = MISSING, *, nodes: list[Node] | None = None ) -> None: super().__init__(client, channel) + self.client: discord.Client = client self._guild: discord.Guild | None = None @@ -125,7 +125,12 @@ def guild(self) -> discord.Guild | None: @property def connected(self) -> bool: - return self._connected + """Returns a bool indicating if the player is currently connected to a voice channel. + + ..versionchanged:: 3.0.0 + This property was previously known as ``is_connected``. + """ + return self.channel and self._connected @property def playing(self) -> bool: From 7db4a5c09ca6eb30fa199070faf284e2bc57ee85 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 5 Aug 2023 20:06:50 +1000 Subject: [PATCH 020/132] Use star imports. --- wavelink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 0011c83a..273ec55d 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -33,5 +33,5 @@ from .node import Node, Pool from .payloads import * from .player import Player -from .tracks import Playable, Playlist, Search +from .tracks import * from .types.playable import PlayableUnion From 41e1a9b001f0d7e90c73334963407851be2bcee8 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 5 Aug 2023 20:07:34 +1000 Subject: [PATCH 021/132] Change repr in queues. --- wavelink/queue.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index c7629c49..a4c9a0c6 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -114,7 +114,7 @@ def __str__(self) -> str: def __repr__(self) -> str: """Official representation displaying member count.""" - return f"BaseQueue(member_count={self.count})" + return f"BaseQueue(count={self.count})" def __bool__(self) -> bool: """Treats the queue as a ``bool``, with it evaluating ``True`` when it contains members. @@ -468,6 +468,9 @@ def __init__(self): self._finished = asyncio.Event() self._finished.set() + def __repr__(self) -> str: + return f'Queue(count={self.count}, history={self.history}, loop={self._loop}, loop_all={self._loop_all})' + async def __aiter__(self) -> AsyncIterator[Playable]: """Pops members as it iterates the queue, waiting for new members when exhausted. @@ -475,7 +478,7 @@ async def __aiter__(self) -> AsyncIterator[Playable]: Example ------- - +@ .. code:: python3 async for track in player.queue: @@ -559,7 +562,7 @@ async def get_wait(self) -> Playable: return self.get() - async def put_wait(self, item: Playable) -> None: + async def put_wait(self, item: Playable | Playlist) -> None: """|coro| Put an item into the queue asynchronously using ``await``. From 4514ebbef6d53d625c151c51b3c9c6c4835d8c5b Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 5 Aug 2023 20:08:15 +1000 Subject: [PATCH 022/132] Documentation and dunder methods in Playlist/Playable. --- wavelink/tracks.py | 193 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index c8a44dac..b728cc24 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypeAlias, Union, overload +from typing import TYPE_CHECKING, Any, Iterator, TypeAlias, Union, overload import yarl @@ -40,6 +40,9 @@ ) +__all__ = ("Search", "Album", "Artist", "Playable", "Playlist") + + _source_mapping: dict[TrackSource | str | None, str] = { TrackSource.YouTube: "ytsearch", TrackSource.SoundCloud: "scsearch", @@ -97,12 +100,98 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, Playable): - raise NotImplemented + raise NotImplementedError return self.encoded == other.encoded @classmethod async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTube) -> Search: + """Search for a list of :class:`~wavelink.Playable` or a list containing a singular :class:`~wavelink.Playlist`, + with the given query. + + .. note:: + + This method differs from :meth:`wavelink.Pool.fetch_tracks` in that it will apply a relevant search prefix + for you when a URL is **not** provided. This prefix can be controlled via the ``source`` keyword argument. + + + .. note:: + + This method of searching is preferred over, :meth:`wavelink.Pool.fetch_tracks`. + + + Parameters + ---------- + query: str + The query to search tracks for. If this is **not** a URL based search this method will provide an + appropriate search prefix based on what is provided to the ``source`` keyword only parameter, + or it's default. + + If this query **is a URL**, a search prefix will **not** be used. + + .. positional-only:: + / + * + source: :class:`TrackSource` | str | None + This parameter determines which search prefix to use when searching for tracks. + If ``None`` is provided, no prefix will be used, however this behaviour is default regardless of what + is provided **when a URL is found**. + + For basic searches, E.g. YouTube, YouTubeMusic and SoundCloud, see: :class:`wavelink.TrackSource`. + Otherwise, a ``str`` may be provided for plugin based searches, E.g. "spsearch:" for the + LavaSrc Spotify based search. + + Defaults to :attr:`wavelink.TrackSource.YouTube` which is equivalent to "ytsearch:". + + + Returns + ------- + :class:`wavelink.Search` + A union of either list[:class:`Playable`] or list[:class:`Playlist`]. Could return and empty list, + if no tracks or playlist were found. + + Raises + ------ + LavalinkLoadException + Exception raised when Lavalink fails to load results based on your query. + + + Examples + -------- + + .. code:: python3 + + # Search for tracks, with the default "ytsearch:" prefix. + tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive") + if not tracks: + # No tracks were found... + ... + + # Search for tracks, with a URL. + tracks: wavelink.Search = await wavelink.Playable.search("https://www.youtube.com/watch?v=KDxJlW6cxRk") + ... + + # Search for tracks, using Spotify and the LavaSrc Plugin. + tracks: wavelink.Search = await wavelink.Playable.search("4b93D55xv3YCH5mT4p6HPn", source="spsearch") + ... + + # Search for tracks, using Spotify and the LavaSrc Plugin, with a URL. + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/track/4b93D55xv3YCH5mT4p6HPn") + ... + + # Search for a playlist, using Spotify and the LavaSrc Plugin. + # or alternatively any other playlist URL from another source like YouTube. + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/playlist/37i9dQZF1DWXRqgorJj26U") + ... + + + .. versionchanged:: 3.0.0 + This method has been changed significantly in version ``3.0.0``. This method has been simplified to provide + an easier interface for searching tracks. See the above documentation and examples. + + You can no longer provide a :class:`wavelink.Node` to use for searching as this method will now select the + most appropriate node from the :class:`wavelink.Pool`. + """ prefix: TrackSource | str | None = _source_mapping.get(source, source) check = yarl.URL(query) @@ -121,6 +210,79 @@ async def search(cls, query: str, /, *, source: TrackSource | str | None = Track class Playlist: + """The wavelink Playlist container class. + + This class is created and returned via both :meth:`Playable.search` and :meth:`wavelink.Pool.fetch_tracks`. + + It contains various information about the playlist and a list of :class:`Playable` that can be used directly in + :meth:`wavelink.Player.play`. See below for various supported operations. + + + .. warning:: + + You should not instantiate this class manually, + use :meth:`Playable.search` or :meth:`wavelink.Pool.fetch_tracks` instead. + + + .. warning:: + + You can not use ``.search`` directly on this class, see: :meth:`Playable.search`. + + + .. note:: + + This class can be directly added to :class:`wavelink.Queue` identical to :class:`Playable`. When added, + all tracks contained in this playlist, will be individually added to the :class:`wavelink.Queue`. + + + .. container:: operations + + .. describe:: str(x) + Return the name associated with this playlist. + + .. describe:: repr(x) + Return the official string representation of this playlist. + + .. describe:: x == y + Compare the equality of playlist. + + .. describe:: len(x) + Return an integer representing the amount of tracks contained in this playlist. + + .. describe:: x[0] + Return a track contained in this playlist with the given index. + + .. describe:: x[0:2] + Return a slice of tracks contained in this playlist. + + .. describe:: for x in y + Iterate over the tracks contained in this playlist. + + .. describe:: reversed(x) + Reverse the tracks contained in this playlist. + + .. describe:: x in y + Check if a :class:`Playable` is contained in this playlist. + + + Attributes + ---------- + name: str + The name of this playlist. + selected: int + The index of the selected track from Lavalink. + tracks: list[:class:`Playable`] + A list of :class:`Playable` contained in this playlist. + type: str | None + An optional ``str`` identifying the type of playlist this is. Only available when a plugin is used. + url: str | None + An optional ``str`` to the URL of this playlist. Only available when a plugin is used. + artwork: str | None + An optional ``str`` to the artwork of this playlist. Only available when a plugin is used. + author: str | None + An optional ``str`` of the author of this playlist. Only available when a plugin is used. + """ + def __init__(self, data: PlaylistPayload) -> None: info: PlaylistInfoPayload = data["info"] self.name: str = info["name"] @@ -136,3 +298,30 @@ def __init__(self, data: PlaylistPayload) -> None: def __str__(self) -> str: return self.name + + def __repr__(self) -> str: + return f"Playlist(name={self.name}, tracks={len(self.tracks)})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Playlist): + raise NotImplementedError + + return self.name == other.name and self.tracks == other.tracks + + def __len__(self) -> int: + return len(self.tracks) + + def __getitem__(self, index: int | slice) -> Playable | list[Playable]: + if not isinstance(index, (int, slice)): + raise TypeError(f"Playlist indices must be integers or slices, not {type(index).__name__}") + + return self.tracks[index] + + def __iter__(self) -> Iterator[Playable]: + return self.tracks.__iter__() + + def __reversed__(self) -> Iterator[Playable]: + return self.tracks.__reversed__() + + def __contains__(self, item: Playable) -> bool: + return item in self.tracks From 8fb5a041269f1703a9e74a742ccd0805e334a722 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 5 Aug 2023 20:08:46 +1000 Subject: [PATCH 023/132] Small fixes, docs and run lints. --- wavelink/node.py | 65 +++++++++++++++++++++++++++++++++++++------ wavelink/payloads.py | 5 ++-- wavelink/websocket.py | 19 +++++++------ 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index 4d71b8b7..15fc0fee 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -102,7 +102,7 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, Node): - raise NotImplemented + raise NotImplementedError return other.identifier == self.identifier @@ -178,7 +178,6 @@ def heartbeat(self) -> float: def session_id(self) -> str | None: """The Lavalink session ID. Could be None if this :class:`Node` has not connected yet. - .. versionadded:: 3.0.0 """ return self._session_id @@ -312,6 +311,22 @@ async def _fetch_version(self) -> str: raise LavalinkException(data=exc_data) def get_player(self, guild_id: int, /) -> Player | None: + """Return a :class:`~wavelink.Player` associated with the provided :attr:discord.Guild.id`. + + Parameters + ---------- + guild_id: int + The :attr:discord.Guild.id` to retrieve a :class:`~wavelink.Player` for. + + .. positional-only:: + / + + Returns + ------- + Optional[:class:`~wavelink.Player`] + The Player associated with this guild ID. Could be None if no :class:`~wavelink.Player` exists + for this guild. + """ return self._players.get(guild_id, None) @@ -320,17 +335,16 @@ class Pool: @classmethod async def connect(cls, *, nodes: Iterable[Node], client: discord.Client | None = None) -> dict[str, Node]: - """|coro| - - Connect the provided Iterable[:class:`Node`] to Lavalink. + """Connect the provided Iterable[:class:`Node`] to Lavalink. Parameters ---------- + * nodes: Iterable[:class:`Node`] The :class:`Node`'s to connect to Lavalink. client: :class:`discord.Client` | None The :class:`discord.Client` to use to connect the :class:`Node`. If the Node already has a client - set, this method will *not* override it. Defaults to None. + set, this method will **not** override it. Defaults to None. Returns ------- @@ -387,6 +401,9 @@ def get_node(cls, identifier: str | None = None, /) -> Node: identifier: str | None An optional identifier to retrieve a :class:`Node`. + .. positional-only:: + / + Raises ------ InvalidNodeException @@ -394,7 +411,7 @@ def get_node(cls, identifier: str | None = None, /) -> Node: .. versionchanged:: 3.0.0 - The ``id`` parameter was changed to ``identifier``. + The ``id`` parameter was changed to ``identifier`` and is positional only. """ if not cls.__nodes: raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool.") @@ -409,7 +426,39 @@ def get_node(cls, identifier: str | None = None, /) -> Node: return sorted(nodes, key=lambda n: len(n.players))[0] @classmethod - async def fetch_tracks(cls, query: str) -> list[Playable] | list[Playlist]: + async def fetch_tracks(cls, query: str, /) -> list[Playable] | list[Playlist]: + """Search for a list of :class:`~wavelink.Playable` or a list containing a singular :class:`~wavelink.Playlist`, + with the given query. + + Parameters + ---------- + query: str + The query to search tracks for. If this is not a URL based search you should provide the appropriate search + prefix, E.g. "ytsearch:Rick Roll" + + .. positional-only:: + / + + Returns + ------- + list[Playable] | list[Playlist] + A list of :class:`~wavelink.Playable` or a list containing a singular :class:`~wavelink.Playlist` + based on your search ``query``. Could be an empty list, if no tracks were found. + + Raises + ------ + LavalinkLoadException + Exception raised when Lavalink fails to load results based on your query. + + + .. versionchanged:: 3.0.0 + This method was previously known as both ``.get_tracks`` and ``.get_playlist``. This method now searches + for both :class:`~wavelink.Playable` and :class:`~wavelink.Playlist` and returns the appropriate type + contained in a list, or an empty list if no results were found. + + This method no longer accepts the ``cls`` parameter. + """ + # TODO: Documentation Extension for `.. positional-only::` marker. encoded_query = urllib.parse.quote(query) # type: ignore node: Node = cls.get_node() diff --git a/wavelink/payloads.py b/wavelink/payloads.py index 02e0c991..86a7118e 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -25,13 +25,12 @@ from typing import TYPE_CHECKING, cast -from .enums import DiscordVoiceCloseType - import wavelink +from .enums import DiscordVoiceCloseType + if TYPE_CHECKING: from .player import Player - from .tracks import Playable from .types.state import PlayerState from .types.stats import CPUStats, FrameStats, MemoryStats diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 0c3b9e72..6e00f37f 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -82,7 +82,7 @@ async def connect(self) -> None: self.keep_alive_task.cancel() except Exception as e: logger.debug( - f"Failed to cancel websocket keep alive while connecting. " + "Failed to cancel websocket keep alive while connecting. " f"This is most likely not a problem and will not affect websocket connection: '{e}'" ) @@ -99,7 +99,7 @@ async def connect(self) -> None: if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: raise AuthorizationFailedException from e else: - logger.info( + logger.warning( f'An unexpected error occurred while connecting {self.node!r} to Lavalink: "{e}"\n' f"If this error persists or wavelink is unable to reconnect, please see: {github}" ) @@ -135,7 +135,7 @@ async def keep_alive(self) -> None: aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, ): # pyright: ignore[reportUnknownMemberType] - asyncio.create_task(self.cleanup()) + asyncio.create_task(self.connect()) break if message.data is None: @@ -165,13 +165,13 @@ async def keep_alive(self) -> None: player: Player | None = self.get_player(data["guildId"]) if data["type"] == "TrackStartEvent": - track: Playable = Playable(data["track"]) # Fuck off pycharm... + track: Playable = Playable(data["track"]) startpayload: TrackStartEventPayload = TrackStartEventPayload(player=player, track=track) self.dispatch("track_start", startpayload) elif data["type"] == "TrackEndEvent": - track: Playable = Playable(data["track"]) # Fuck off pycharm... + track: Playable = Playable(data["track"]) reason: str = data["reason"] if player and reason != "replaced": @@ -181,8 +181,8 @@ async def keep_alive(self) -> None: self.dispatch("track_end", endpayload) elif data["type"] == "TrackExceptionEvent": - track: Playable = Playable(data["track"]) # Fuck off pycharm... - exception: TrackExceptionPayload = data["exception"] # Fuck off pycharm... + track: Playable = Playable(data["track"]) + exception: TrackExceptionPayload = data["exception"] excpayload: TrackExceptionEventPayload = TrackExceptionEventPayload( player=player, track=track, exception=exception @@ -190,7 +190,7 @@ async def keep_alive(self) -> None: self.dispatch("track_exception", excpayload) elif data["type"] == "TrackStuckEvent": - track: Playable = Playable(data["track"]) # Fuck off pycharm... + track: Playable = Playable(data["track"]) threshold: int = data["thresholdMs"] stuckpayload: TrackStuckEventPayload = TrackStuckEventPayload( @@ -236,4 +236,7 @@ async def cleanup(self) -> None: pass self.node._status = NodeStatus.DISCONNECTED + self.node._session_id = None + self.node._players = {} + logger.debug(f"Successfully cleaned up the websocket for {self.node!r}") From e0ea9351663dd8d35836e327209c234f81b991ce Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 5 Aug 2023 21:01:00 +1000 Subject: [PATCH 024/132] Return Playlist without list container. --- wavelink/__init__.py | 1 - wavelink/node.py | 15 +++++++-------- wavelink/tracks.py | 7 +++---- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 273ec55d..e06269fa 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -34,4 +34,3 @@ from .payloads import * from .player import Player from .tracks import * -from .types.playable import PlayableUnion diff --git a/wavelink/node.py b/wavelink/node.py index 15fc0fee..1bd6a678 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -426,9 +426,8 @@ def get_node(cls, identifier: str | None = None, /) -> Node: return sorted(nodes, key=lambda n: len(n.players))[0] @classmethod - async def fetch_tracks(cls, query: str, /) -> list[Playable] | list[Playlist]: - """Search for a list of :class:`~wavelink.Playable` or a list containing a singular :class:`~wavelink.Playlist`, - with the given query. + async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: + """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. Parameters ---------- @@ -441,8 +440,8 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | list[Playlist]: Returns ------- - list[Playable] | list[Playlist] - A list of :class:`~wavelink.Playable` or a list containing a singular :class:`~wavelink.Playlist` + list[Playable] | Playlist + A list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist` based on your search ``query``. Could be an empty list, if no tracks were found. Raises @@ -453,8 +452,8 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | list[Playlist]: .. versionchanged:: 3.0.0 This method was previously known as both ``.get_tracks`` and ``.get_playlist``. This method now searches - for both :class:`~wavelink.Playable` and :class:`~wavelink.Playlist` and returns the appropriate type - contained in a list, or an empty list if no results were found. + for both :class:`~wavelink.Playable` and :class:`~wavelink.Playlist` and returns the appropriate type, + or an empty list if no results were found. This method no longer accepts the ``cls`` parameter. """ @@ -475,7 +474,7 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | list[Playlist]: return tracks if resp["loadType"] == "playlist": - return [Playlist(data=resp["data"])] + return Playlist(data=resp["data"]) elif resp["loadType"] == "empty": return [] diff --git a/wavelink/tracks.py b/wavelink/tracks.py index b728cc24..c7df2ee3 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -50,7 +50,7 @@ } -Search: TypeAlias = "list[Playable] | list[Playlist]" +Search: TypeAlias = "list[Playable] | Playlist" class Album: @@ -106,8 +106,7 @@ def __eq__(self, other: object) -> bool: @classmethod async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTube) -> Search: - """Search for a list of :class:`~wavelink.Playable` or a list containing a singular :class:`~wavelink.Playlist`, - with the given query. + """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. .. note:: @@ -147,7 +146,7 @@ async def search(cls, query: str, /, *, source: TrackSource | str | None = Track Returns ------- :class:`wavelink.Search` - A union of either list[:class:`Playable`] or list[:class:`Playlist`]. Could return and empty list, + A union of either list[:class:`Playable`] or :class:`Playlist`. Could return and empty list, if no tracks or playlist were found. Raises From 007d72591eea4b109434348ccd2c740c00a5a03d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 27 Aug 2023 10:50:20 +1000 Subject: [PATCH 025/132] Typing fix to websocket.py --- wavelink/websocket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 6e00f37f..8d1f8ebb 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -53,7 +53,7 @@ def __init__(self, *, node: Node) -> None: self.backoff: Backoff = Backoff() self.socket: aiohttp.ClientWebSocketResponse | None = None - self.keep_alive_task: asyncio.Task | None = None + self.keep_alive_task: asyncio.Task[None] | None = None @property def headers(self) -> dict[str, str]: From 31b65d63acc2163a783b0dd988fbd3d650816bc9 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 27 Aug 2023 10:51:58 +1000 Subject: [PATCH 026/132] Add PlaylistInfo --- wavelink/tracks.py | 177 ++++++++++++++++++++++++++++++++++----- wavelink/types/tracks.py | 6 +- 2 files changed, 157 insertions(+), 26 deletions(-) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index c7df2ee3..298aa67a 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -23,7 +23,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterator, TypeAlias, Union, overload +from typing import TYPE_CHECKING, Any, Iterator, TypeAlias, overload import yarl @@ -40,7 +40,7 @@ ) -__all__ = ("Search", "Album", "Artist", "Playable", "Playlist") +__all__ = ("Search", "Album", "Artist", "Playable", "Playlist", "PlaylistInfo") _source_mapping: dict[TrackSource | str | None, str] = { @@ -66,28 +66,30 @@ def __init__(self, *, data: dict[Any, Any]) -> None: class Playable: - def __init__(self, data: TrackPayload) -> None: + def __init__(self, data: TrackPayload, *, playlist: PlaylistInfo | None = None) -> None: info: TrackInfoPayload = data["info"] - self.encoded: str = data["encoded"] - self.identifier: str = info["identifier"] - self.is_seekable: bool = info["isSeekable"] - self.author: str = info["author"] - self.length: int = info["length"] - self.is_stream: bool = info["isStream"] - self.position: int = info["position"] - self.title: str = info["title"] - self.uri: str | None = info.get("uri") - self.artwork: str | None = info.get("artworkUrl") - self.isrc: str | None = info.get("isrc") - self.source: str = info["sourceName"] + self._encoded: str = data["encoded"] + self._identifier: str = info["identifier"] + self._is_seekable: bool = info["isSeekable"] + self._author: str = info["author"] + self._length: int = info["length"] + self._is_stream: bool = info["isStream"] + self._position: int = info["position"] + self._title: str = info["title"] + self._uri: str | None = info.get("uri") + self._artwork: str | None = info.get("artworkUrl") + self._isrc: str | None = info.get("isrc") + self._source: str = info["sourceName"] plugin: dict[Any, Any] = data["pluginInfo"] - self.album: Album = Album(data=plugin) - self.artist: Artist = Artist(data=plugin) + self._album: Album = Album(data=plugin) + self._artist: Artist = Artist(data=plugin) - self.preview_url: str | None = plugin.get("previewUrl") - self.is_preview: bool | None = plugin.get("isPreview") + self._preview_url: str | None = plugin.get("previewUrl") + self._is_preview: bool | None = plugin.get("isPreview") + + self._playlist = playlist def __hash__(self) -> int: return hash(self.encoded) @@ -104,6 +106,74 @@ def __eq__(self, other: object) -> bool: return self.encoded == other.encoded + @property + def encoded(self) -> str: + return self._encoded + + @property + def identifier(self) -> str: + return self._identifier + + @property + def is_seekable(self) -> bool: + return self._is_seekable + + @property + def author(self) -> str: + return self._author + + @property + def length(self) -> int: + return self._length + + @property + def is_stream(self) -> bool: + return self._is_stream + + @property + def position(self) -> int: + return self._position + + @property + def title(self) -> str: + return self._title + + @property + def uri(self) -> str | None: + return self._uri + + @property + def artwork(self) -> str | None: + return self._artwork + + @property + def isrc(self) -> str | None: + return self._isrc + + @property + def source(self) -> str: + return self._source + + @property + def album(self) -> Album: + return self._album + + @property + def artist(self) -> Artist: + return self._artist + + @property + def preview_url(self) -> str | None: + return self._preview_url + + @property + def is_preview(self) -> bool | None: + return self._is_preview + + @property + def playlist(self) -> PlaylistInfo | None: + return self._playlist + @classmethod async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTube) -> Search: """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. @@ -287,7 +357,8 @@ def __init__(self, data: PlaylistPayload) -> None: self.name: str = info["name"] self.selected: int = info["selectedTrack"] - self.tracks: list[Playable] = [Playable(data=track) for track in data["tracks"]] + playlist_info: PlaylistInfo = PlaylistInfo(data) + self.tracks: list[Playable] = [Playable(data=track, playlist=playlist_info) for track in data["tracks"]] plugin: dict[Any, Any] = data["pluginInfo"] self.type: str | None = plugin.get("type") @@ -310,10 +381,15 @@ def __eq__(self, other: object) -> bool: def __len__(self) -> int: return len(self.tracks) - def __getitem__(self, index: int | slice) -> Playable | list[Playable]: - if not isinstance(index, (int, slice)): - raise TypeError(f"Playlist indices must be integers or slices, not {type(index).__name__}") + @overload + def __getitem__(self, index: int) -> Playable: + ... + @overload + def __getitem__(self, index: slice) -> list[Playable]: + ... + + def __getitem__(self, index: int | slice) -> Playable | list[Playable]: return self.tracks[index] def __iter__(self) -> Iterator[Playable]: @@ -324,3 +400,58 @@ def __reversed__(self) -> Iterator[Playable]: def __contains__(self, item: Playable) -> bool: return item in self.tracks + + def track_extras(self, **attrs: Any) -> None: + """Method which sets attributes to all :class:`Playable` in this playlist, with the provided keyword arguments. + + This is useful when you need to attach state to your :class:`Playable`, E.g. create a requester attribute. + + .. warning:: + + If you try to override any existing property of :class:`Playable` this method will fail. + + + Parameters + ---------- + **attrs + The keyword arguments to set as attribute name=value on each :class:`Playable`. + + Examples + -------- + + .. code:: python3 + + playlist.track_extras(requester=ctx.author) + + track: wavelink.Playable = playlist[0] + print(track.requester) + """ + for track in self.tracks: + for name, value in attrs.items(): + setattr(track, name, value) + + +class PlaylistInfo: + __slots__ = ("name", "selected", "tracks", "type", "url", "artwork", "author") + + def __init__(self, data: PlaylistPayload) -> None: + info: PlaylistInfoPayload = data["info"] + self.name: str = info["name"] + self.selected: int = info["selectedTrack"] + + self.tracks: int = len(data["tracks"]) + + plugin: dict[Any, Any] = data["pluginInfo"] + self.type: str | None = plugin.get("type") + self.url: str | None = plugin.get("url") + self.artwork: str | None = plugin.get("artworkUrl") + self.author: str | None = plugin.get("author") + + def __str__(self) -> str: + return self.name + + def __repr__(self) -> str: + return f"PlaylistInfo(name={self.name}, tracks={self.tracks})" + + def __len__(self) -> int: + return self.tracks diff --git a/wavelink/types/tracks.py b/wavelink/types/tracks.py index 53a5c18e..b4c6fdda 100644 --- a/wavelink/types/tracks.py +++ b/wavelink/types/tracks.py @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import TYPE_CHECKING, Literal, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict if TYPE_CHECKING: from typing_extensions import NotRequired @@ -49,10 +49,10 @@ class PlaylistInfoPayload(TypedDict): class TrackPayload(TypedDict): encoded: str info: TrackInfoPayload - pluginInfo: dict + pluginInfo: dict[Any, Any] class PlaylistPayload(TypedDict): info: PlaylistInfoPayload tracks: list[TrackPayload] - pluginInfo: dict + pluginInfo: dict[Any, Any] From 08fdea404074430f66f1aec4d29ca0e807be8666 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 27 Aug 2023 10:54:18 +1000 Subject: [PATCH 027/132] Remove redundant imports, small cleanup. --- wavelink/__init__.py | 5 +++-- wavelink/__main__.py | 2 -- wavelink/node.py | 10 ++++++---- wavelink/player.py | 7 +++---- wavelink/types/response.py | 4 ++-- wavelink/types/stats.py | 2 +- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/wavelink/__init__.py b/wavelink/__init__.py index e06269fa..b7e7cb7b 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -30,7 +30,8 @@ from .enums import * from .exceptions import * -from .node import Node, Pool +from .node import * from .payloads import * -from .player import Player +from .player import Player as Player +from .queue import * from .tracks import * diff --git a/wavelink/__main__.py b/wavelink/__main__.py index 041507ab..d42cbf7d 100644 --- a/wavelink/__main__.py +++ b/wavelink/__main__.py @@ -3,8 +3,6 @@ import subprocess import sys -import aiohttp - import wavelink parser = argparse.ArgumentParser(prog="wavelink") diff --git a/wavelink/node.py b/wavelink/node.py index 1bd6a678..a6d4f854 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -26,7 +26,7 @@ import logging import secrets import urllib -from typing import TYPE_CHECKING, Iterable, Union +from typing import TYPE_CHECKING, Iterable, Union, cast import aiohttp import discord @@ -40,7 +40,6 @@ InvalidNodeException, LavalinkException, LavalinkLoadException, - WavelinkException, ) from .tracks import Playable, Playlist from .websocket import Websocket @@ -60,13 +59,16 @@ TrackLoadedResponse, UpdateResponse, ) - from .types.tracks import PlaylistPayload, TrackPayload + from .types.tracks import TrackPayload LoadedResponse = Union[ TrackLoadedResponse, SearchLoadedResponse, PlaylistLoadedResponse, EmptyLoadedResponse, ErrorLoadedResponse ] +__all__ = ("Node", "Pool") + + logger: logging.Logger = logging.getLogger(__name__) @@ -458,7 +460,7 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: This method no longer accepts the ``cls`` parameter. """ # TODO: Documentation Extension for `.. positional-only::` marker. - encoded_query = urllib.parse.quote(query) # type: ignore + encoded_query: str = cast(str, urllib.parse.quote(query)) # type: ignore node: Node = cls.get_node() resp: LoadedResponse = await node._fetch_tracks(encoded_query) diff --git a/wavelink/player.py b/wavelink/player.py index dbabd9ed..a11a9c4e 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -25,7 +25,7 @@ import asyncio import logging -from typing import TYPE_CHECKING, Any, Union, cast +from typing import TYPE_CHECKING, Any, Union import async_timeout import discord @@ -48,7 +48,6 @@ from .node import Node from .types.request import Request as RequestPayload - from .types.response import PlayerResponse from .types.state import PlayerVoiceState, VoiceState VocalGuildChannel = Union[discord.VoiceChannel, discord.StageChannel] @@ -182,7 +181,7 @@ async def _dispatch_voice_update(self) -> None: async def connect( self, *, timeout: float = 5.0, reconnect: bool, self_deaf: bool = False, self_mute: bool = False ) -> None: - if self.channel is None: + if self.channel is MISSING: msg: str = 'Please use "discord.VoiceChannel.connect(cls=...)" and pass this Player to cls.' raise InvalidChannelStateException(f"Player tried to connect without a valid channel: {msg}") @@ -205,7 +204,7 @@ async def play(self, track: Playable) -> None: assert self.guild is not None request: RequestPayload = {"encodedTrack": track.encoded, "volume": 20} - resp: PlayerResponse = await self.node._update_player(self.guild.id, data=request) + await self.node._update_player(self.guild.id, data=request) self._current = track diff --git a/wavelink/types/response.py b/wavelink/types/response.py index 3db2203c..081d4f5e 100644 --- a/wavelink/types/response.py +++ b/wavelink/types/response.py @@ -21,10 +21,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import TYPE_CHECKING, Literal, TypedDict, Union +from typing import TYPE_CHECKING, Literal, TypedDict if TYPE_CHECKING: - from typing_extensions import Never, NotRequired, TypeAlias + from typing_extensions import Never, NotRequired from .state import PlayerState, VoiceState from .stats import CPUStats, FrameStats, MemoryStats diff --git a/wavelink/types/stats.py b/wavelink/types/stats.py index 81cce549..1261b5d8 100644 --- a/wavelink/types/stats.py +++ b/wavelink/types/stats.py @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import Literal, TypedDict +from typing import TypedDict class MemoryStats(TypedDict): From c3da7aa9babc7f16d166a5b1477c2d42a3198b69 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 16:40:47 +1000 Subject: [PATCH 028/132] `endTime` is optional. --- wavelink/types/request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/types/request.py b/wavelink/types/request.py index a308159e..25396193 100644 --- a/wavelink/types/request.py +++ b/wavelink/types/request.py @@ -38,7 +38,7 @@ class VoiceRequest(TypedDict): class _BaseRequest(TypedDict, total=False): voice: VoiceRequest position: int - endTime: int + endTime: Optional[int] volume: int paused: bool From 8e849a6008dd8ffed6fb790502b1344bd7dd9e4e Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 16:41:14 +1000 Subject: [PATCH 029/132] Start of the changes to the Queues. --- wavelink/queue.py | 655 ++++++---------------------------------------- 1 file changed, 85 insertions(+), 570 deletions(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index a4c9a0c6..6ed1846e 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -1,7 +1,7 @@ """ MIT License -Copyright (c) 2019-Present PythonistaGuild +Copyright (c) 2019-Current PythonistaGuild, EvieePy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -24,503 +24,107 @@ from __future__ import annotations import asyncio -import random from collections import deque -from collections.abc import AsyncIterator, Iterable, Iterator -from copy import copy +from typing import Any, overload from .exceptions import QueueEmpty -from .tracks import Playable, Playlist +from .tracks import * -__all__ = ("BaseQueue", "Queue") +__all__ = ("Queue",) -class BaseQueue: - """BaseQueue for wavelink. - - All queues inherit from this queue. - - See :class:`Queue` for the default :class:`~wavelink.Player` queue. - Internally this queue uses a :class:`collections.deque`. - - .. warning:: - - It is not advisable to edit the internal :class:`collections.deque` directly. - - - .. container:: operations - - .. describe:: str(queue) - - Returns a string showing all Playable objects appearing as a list in the queue. - - .. describe:: repr(queue) - - Returns an official string representation of this queue. - - .. describe:: if queue - - Returns True if members are in the queue. False if the queue is empty. - - .. describe:: queue(track) - - Adds a member to the queue. - - .. describe:: len(queue) - - Returns an int with the count of members in this queue. - - .. describe:: queue[2] - - Returns a member at the given position. - Does **not** remove the item from queue. - - .. describe:: queue[4] = track - - Inserts an item into the queue at the given position. - - .. describe:: del queue[1] - - Deletes a member from the queue at the given position. - - .. describe:: for track in queue - - Iterates over the queue. - Does **not** remove items when iterating. - - .. describe:: reversed(queue) - - Reverse a reversed version of the queue. - - .. describe:: if track in queue - - Checks whether a track is in the queue. - - .. describe:: queue = queue + [track, track1, track2, ...] - - Return a new queue containing all new and old members from the given iterable. - - .. describe:: queue += [track, track1, track2, ...] - - Add items to queue from the given iterable. - """ - +class _Queue: def __init__(self) -> None: self._queue: deque[Playable] = deque() def __str__(self) -> str: - """String showing all Playable objects appearing as a list.""" - return str([f"'{t}'" for t in self]) + return ", ".join([f'"{p}"' for p in self]) def __repr__(self) -> str: - """Official representation displaying member count.""" - return f"BaseQueue(count={self.count})" + return f"BaseQueue(items={len(self._queue)})" def __bool__(self) -> bool: - """Treats the queue as a ``bool``, with it evaluating ``True`` when it contains members. - - Example - ------- - - .. code:: python3 - - if player.queue: - # queue contains members, do something... - """ - return bool(self.count) - - def __call__(self, item: Playable) -> None: - """Allows the queue instance to be called directly in order to add a member. - - Example - ------- - - .. code:: python3 + return bool(self._queue) - player.queue(track) # adds track to the queue... - """ + def __call__(self, item: Playable | Playlist) -> None: self.put(item) def __len__(self) -> int: - """Return the number of members in the queue. - - Example - ------- - - .. code:: python3 - - print(len(player.queue)) - """ - return self.count + return len(self._queue) + @overload def __getitem__(self, index: int) -> Playable: - """Returns a member at the given position. - - Does **not** remove the item from queue. - - Example - ------- + ... - .. code:: python3 + @overload + def __getitem__(self, index: slice) -> list[Playable]: + ... - track = player.queue[2] - """ - if not isinstance(index, int): - raise ValueError("'int' type required.'") + def __getitem__(self, index: int | slice) -> Playable | list[Playable]: + if isinstance(index, slice): + return list(self._queue)[index] return self._queue[index] - def __setitem__(self, index: int, item: Playable): - """Inserts an item at the given position. - - Example - ------- - - .. code:: python3 - - player.queue[4] = track - """ - if not isinstance(index, int): - raise ValueError("'int' type required.'") - - self.put_at_index(index, item) - - def __delitem__(self, index: int) -> None: - """Delete item at given position. - - Example - ------- - - .. code:: python3 - - del player.queue[1] - """ - self._queue.__delitem__(index) - - def __iter__(self) -> Iterator[Playable]: - """Iterate over members in the queue. - - Does **not** remove items when iterating. - - Example - ------- - - .. code:: python3 - - for track in player.queue: - print(track) - """ - return self._queue.__iter__() - - def __reversed__(self) -> Iterator[Playable]: - """Iterate over members in a reverse order. - - Example - ------- - - .. code:: python3 - - for track in reversed(player.queue): - print(track) - """ - return self._queue.__reversed__() - - def __contains__(self, item: Playable) -> bool: - """Check if a track is a member of the queue. - - Example - ------- - - .. code:: python3 - - if track in player.queue: - # track is in the queue... - """ - return item in self._queue - - def __add__(self, other: Iterable[Playable]): - """Return a new queue containing all members, including old members. - - Example - ------- - - .. code:: python3 - - player.queue = player.queue + [track1, track2, ...] - """ - if not isinstance(other, Iterable): - raise TypeError(f"Adding with the '{type(other)}' type is not supported.") - - new_queue = self.copy() - new_queue.extend(other) - return new_queue - - def __iadd__(self, other: Iterable[Playable] | Playable): - """Add items to queue from an iterable. - - Example - ------- - - .. code:: python3 - - player.queue += [track1, track2, ...] - """ - if isinstance(other, Playable): - self.put(other) - - return self - - if isinstance(other, Iterable): - self.extend(other) - return self - - raise TypeError(f"Adding '{type(other)}' type to the queue is not supported.") + @staticmethod + def _check_compatability(item: Any) -> None: + if not isinstance(item, Playable): + raise TypeError("This queue is restricted to Playable objects.") def _get(self) -> Playable: - if self.is_empty: - raise QueueEmpty("No items currently in the queue.") + if not self: + raise QueueEmpty("There are no items currently in this queue.") return self._queue.popleft() - def _drop(self) -> Playable: - return self._queue.pop() - - def _index(self, item: Playable) -> int: - return self._queue.index(item) - - def _put(self, item: Playable) -> None: - self._queue.append(item) - - def _insert(self, index: int, item: Playable) -> None: - self._queue.insert(index, item) - - @staticmethod - def _check_playable(item: Playable | Playlist) -> None: - if not isinstance(item, (Playable, Playlist)): - raise TypeError("Only Playable objects are supported.") - - @classmethod - def _check_playable_container(cls, iterable: Iterable) -> None: - iterable = list(iterable) - - for item in iterable: - cls._check_playable(item) - - @property - def count(self) -> int: - """Returns queue member count.""" - return len(self._queue) - - @property - def is_empty(self) -> bool: - """Returns ``True`` if queue has no members.""" - return not bool(self.count) - def get(self) -> Playable: - """Return next immediately available item in queue if any. - - Raises :exc:`~wavelink.QueueEmpty` if no items in queue. - """ - if self.is_empty: - raise QueueEmpty("No items currently in the queue.") - return self._get() - def pop(self) -> Playable: - """Return item from the right end side of the queue. - - Raises :exc:`~wavelink.QueueEmpty` if no items in queue. - """ - if self.is_empty: - raise QueueEmpty("No items currently in the queue.") - - return self._queue.pop() - - def find_position(self, item: Playable) -> int: - """Find the position a given item within the queue. - - Raises :exc:`ValueError` if item is not in the queue. - """ - self._check_playable(item) - return self._index(item) - - def put(self, item: Playable | Playlist) -> None: - """Put the given item into the back of the queue. - - If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist`, all - tracks from this playlist will be put into the queue. - - - .. note:: + def _check_atomic(self, item: Playlist) -> None: + for track in item: + self._check_compatability(track) - Inserting playlists is currently only supported via this method, which means you can only insert them into - the back of the queue. Future versions of wavelink may add support for inserting playlists from a specific - index, or at the front of the queue. + def _put(self, item: Playable) -> None: + self._check_compatability(item) + self._queue.append(item) + def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + added: int = 0 - .. versionchanged:: 2.6.0 + if isinstance(item, Playlist): + if atomic: + self._check_atomic(item) - Added support for directly adding a :class:`~wavelink.YouTubePlaylist` to the queue. - """ - self._check_playable(item) + for track in item: + try: + self._put(track) + added += 1 + except TypeError: + pass - if isinstance(item, Playlist): - for track in item.tracks: - self._put(track) else: self._put(item) + added += 1 - def put_at_index(self, index: int, item: Playable) -> None: - """Put the given item into the queue at the specified index.""" - self._check_playable(item) - self._insert(index, item) - - def put_at_front(self, item: Playable) -> None: - """Put the given item into the front of the queue.""" - self.put_at_index(0, item) - - def shuffle(self) -> None: - """Shuffles the queue in place. This does **not** return anything. - - Example - ------- - - .. code:: python3 - - player.queue.shuffle() - # Your queue has now been shuffled... - - - .. versionadded:: 2.5 - """ - random.shuffle(self._queue) - - def extend(self, iterable: Iterable[Playable], *, atomic: bool = True) -> None: - """Add the members of the given iterable to the end of the queue. - - If atomic is set to ``True``, no tracks will be added upon any exceptions. - - If atomic is set to ``False``, as many tracks will be added as possible. - """ - if atomic: - self._check_playable_container(iterable) - - for item in iterable: - self.put(item) - - def copy(self): - """Create a copy of the current queue including its members.""" - new_queue = self.__class__() - new_queue._queue = copy(self._queue) - - return new_queue - - def clear(self) -> None: - """Remove all items from the queue. - - - .. note:: - - This does not reset the queue. See :meth:`~Queue.reset` for resetting the :class:`Queue` assigned to the - player. - """ - self._queue.clear() - - -class Queue(BaseQueue): - """Main Queue class. - - **All** :class:`~wavelink.Player` have this queue assigned to them. + return added - .. note:: - This queue inherits from :class:`BaseQueue` but has access to special async methods and loop logic. - - .. warning:: - - The :attr:`.history` queue is a :class:`BaseQueue` and has **no** access to async methods or loop logic. - - - .. container:: operations - - .. describe:: async for track in queue - - Pops members as it iterates the queue asynchronously, waiting for new members when exhausted. - **Does** remove items when iterating. - - - Attributes - ---------- - history: :class:`BaseQueue` - The history queue stores information about all previous played tracks for the :class:`~wavelink.Player`'s - session. - """ - - def __init__(self): +class Queue(_Queue): + def __init__(self) -> None: super().__init__() - self.history: BaseQueue = BaseQueue() + self.history: _Queue = _Queue() - self._loop: bool = False - self._loop_all: bool = False - - self._loaded = None - self._waiters = deque() - self._finished = asyncio.Event() + self._waiters: deque[asyncio.Future[None]] = deque() + self._finished: asyncio.Event = asyncio.Event() self._finished.set() - def __repr__(self) -> str: - return f'Queue(count={self.count}, history={self.history}, loop={self._loop}, loop_all={self._loop_all})' - - async def __aiter__(self) -> AsyncIterator[Playable]: - """Pops members as it iterates the queue, waiting for new members when exhausted. - - **Does** remove items when iterating. - - Example - ------- -@ - .. code:: python3 - - async for track in player.queue: - # If there is no item in the queue, this will wait for an item to be inserted. - - # Do something with track here... - """ - while True: - yield await self.get_wait() - - def get(self) -> Playable: - return self._get() - - def _get(self) -> Playable: - if self.loop and self._loaded: - return self._loaded - - if self.loop_all and self.is_empty: - self._queue.extend(self.history._queue) - self.history.clear() - - item = super()._get() - - self._loaded = item - return item + self._lock: asyncio.Lock = asyncio.Lock() - async def _put(self, item: Playable | Playlist) -> None: - self._check_playable(item) - - if isinstance(item, Playlist): - for track in item.tracks: - super()._put(track) - await asyncio.sleep(0) - else: - super()._put(item) - await asyncio.sleep(0) - - self._wakeup_next() + def __str__(self) -> str: + return ", ".join([f'"{p}"' for p in self]) - def _insert(self, index: int, item: Playable) -> None: - super()._insert(index, item) - self._wakeup_next() + def __repr__(self) -> str: + return f"Queue(items={len(self)}, history={self.history!r})" def _wakeup_next(self) -> None: while self._waiters: @@ -530,18 +134,17 @@ def _wakeup_next(self) -> None: waiter.set_result(None) break - async def get_wait(self) -> Playable: - """|coro| - - Return the next item in queue once available. + def _get(self) -> Playable: + # TODO ... Looping Logic, history Logic. + return super()._get() - .. note:: + def get(self) -> Playable: + return self._get() - This will wait until an item is available to be retrieved. - """ - while self.is_empty: - loop = asyncio.get_event_loop() - waiter = loop.create_future() + async def get_wait(self) -> Playable: + while not self: + loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() + waiter: asyncio.Future[None] = loop.create_future() self._waiters.append(waiter) @@ -555,128 +158,40 @@ async def get_wait(self) -> Playable: except ValueError: # pragma: no branch pass - if not self.is_empty and not waiter.cancelled(): # pragma: no cover + if self and not waiter.cancelled(): # pragma: no cover # something went wrong with this waiter, move on to next self._wakeup_next() raise return self.get() - async def put_wait(self, item: Playable | Playlist) -> None: - """|coro| - - Put an item into the queue asynchronously using ``await``. - - If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist` or :class:`~wavelink.SoundCloudPlaylist`, all - tracks from this playlist will be put into the queue. - - - .. note:: - - Inserting playlists is currently only supported via this method, which means you can only insert them into - the back of the queue. Future versions of wavelink may add support for inserting playlists from a specific - index, or at the front of the queue. - - - .. versionchanged:: 2.6.0 - - Added support for directly adding a :class:`~wavelink.YouTubePlaylist` to the queue. - """ - await self._put(item) - - def put(self, item: Playable | Playlist) -> None: - """Put the given item into the back of the queue. - - If the provided ``item`` is a :class:`~wavelink.YouTubePlaylist`, all tracks from this playlist will be put - into the queue. - - - .. note:: - - Inserting playlists is currently only supported via this method, which means you can only insert them into - the back of the queue. Future versions of wavelink may add support for inserting playlists from a specific - index, or at the front of the queue. - - - .. versionchanged:: 2.6.0 - - Added support for directly adding a :class:`~wavelink.YouTubePlaylist` to the queue. - """ - self._check_playable(item) - - if isinstance(item, Playlist): - for track in item.tracks: - super()._put(track) - else: - super()._put(item) + def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + added: int = super().put(item, atomic=atomic) self._wakeup_next() + return added - def reset(self) -> None: - """Clears the state of all queues, including the history queue. - - - sets loop and loop_all to ``False``. - - removes all items from the queue and history queue. - - cancels any waiting queues. - """ - self.clear() - self.history.clear() - - for waiter in self._waiters: - waiter.cancel() - - self._waiters.clear() - - self._loaded = None - self._loop = False - self._loop_all = False - - @property - def loop(self) -> bool: - """Whether the queue will loop the currently playing song. + async def put_wait(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + added: int = 0 - Can be set to True or False. - Defaults to False. + async with self._lock: + if isinstance(item, Playlist): + if atomic: + super()._check_atomic(item) - Returns - ------- - bool + for track in item: + try: + super()._put(track) + added += 1 + except TypeError: + pass + await asyncio.sleep(0) - .. versionadded:: 2.0 - """ - return self._loop - - @loop.setter - def loop(self, value: bool) -> None: - if not isinstance(value, bool): - raise ValueError('The "loop" property can only be set with a bool.') - - self._loop = value - - @property - def loop_all(self) -> bool: - """Whether the queue will loop all songs in the history queue. - - Can be set to True or False. - Defaults to False. - - .. note:: - - If `loop` is set to True, this has no effect until `loop` is set to False. - - Returns - ------- - bool - - - .. versionadded:: 2.0 - """ - return self._loop_all - - @loop_all.setter - def loop_all(self, value: bool) -> None: - if not isinstance(value, bool): - raise ValueError('The "loop_all" property can only be set with a bool.') + else: + super()._put(item) + added += 1 + await asyncio.sleep(0) - self._loop_all = value + self._wakeup_next() + return added From be1206cf575aeed25be406060ea7c2cdaf2f39ca Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 16:41:33 +1000 Subject: [PATCH 030/132] Add pyright strict to pyproject.toml --- pyproject.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cd06e80b..f22c0448 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,4 +35,8 @@ dependencies = {file = ["requirements.txt"]} line-length = 120 [tool.isort] -profile = "black" \ No newline at end of file +profile = "black" + +[tool.pyright] +typeCheckingMode = "strict" +reportPrivateUsage = false From 6bf0a25b1ea01afeae123be7b5431d2eef91f290 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 16:41:54 +1000 Subject: [PATCH 031/132] Add original to Start/End events. --- wavelink/payloads.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/wavelink/payloads.py b/wavelink/payloads.py index 86a7118e..f9ee87fe 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -50,15 +50,23 @@ class TrackStartEventPayload: def __init__(self, player: Player | None, track: Playable) -> None: - self.player = cast(wavelink.Player, player) + self.player = player self.track = track + self.original: Playable | None = None + + if player: + self.original = player._original class TrackEndEventPayload: def __init__(self, player: Player | None, track: Playable, reason: str) -> None: - self.player = cast(wavelink.Player, player) + self.player = player self.track = track self.reason = reason + self.original: Playable | None = None + + if player: + self.original = player._previous class TrackExceptionEventPayload: From 51f6ab4fbe8bbe2b0c43bc539e3d3347ef177f62 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 16:42:18 +1000 Subject: [PATCH 032/132] Various changes/additions to player. --- wavelink/player.py | 245 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 233 insertions(+), 12 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index a11a9c4e..e6759eeb 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -101,15 +101,20 @@ def __init__( self._connection_event: asyncio.Event = asyncio.Event() self._current: Playable | None = None + self._original: Playable | None = None + self._previous: Playable | None = None self.queue: Queue = Queue() + self._volume: int = 100 + self._paused: bool = False + @property def node(self) -> Node: """The :class:`Player`'s currently selected :class:`Node`. - ..versionchanged:: 3.0.0 + .. versionchanged:: 3.0.0 This property was previously known as ``current_node``. """ return self._node @@ -126,11 +131,32 @@ def guild(self) -> discord.Guild | None: def connected(self) -> bool: """Returns a bool indicating if the player is currently connected to a voice channel. - ..versionchanged:: 3.0.0 + .. versionchanged:: 3.0.0 This property was previously known as ``is_connected``. """ return self.channel and self._connected + @property + def current(self) -> Playable | None: + """Returns the currently playing :class:`~wavelink.Playable` or None if no track is playing.""" + return self._current + + @property + def volume(self) -> int: + """Returns an int representing the currently set volume, as a percentage. + + See: :meth:`set_volume` for setting the volume. + """ + return self._volume + + @property + def paused(self) -> bool: + """Returns the paused status of the player. A currently paused player will return ``True``. + + See: :meth:`pause` and :meth:`play` for setting the paused status. + """ + return self._paused + @property def playing(self) -> bool: return self._connected and self._current is not None @@ -199,29 +225,224 @@ async def connect( msg = f"Unable to connect to {self.channel} as it exceeded the timeout of {timeout} seconds." raise ChannelTimeoutException(msg) - async def play(self, track: Playable) -> None: - """Test play command.""" + async def play( + self, + track: Playable, + *, + replace: bool = True, + start: int = 0, + end: int | None = None, + volume: int | None = None, + paused: bool | None = None + ) -> Playable: + """Play the provided :class:`~wavelink.Playable`. + + Parameters + ---------- + track: :class:`~wavelink.Playable` + The track to being playing. + replace: bool + Whether this track should replace the currently playing track, if there is one. Defaults to ``True``. + start: int + The position to start playing the track at in milliseconds. + Defaults to ``0`` which will start the track from the beginning. + end: Optional[int] + The position to end the track at in milliseconds. + Defaults to ``None`` which means this track will play until the very end. + volume: Optional[int] + Sets the volume of the player. Must be between ``0`` and ``1000``. + Defaults to ``None`` which will not change the current volume. + See Also: :meth:`set_volume` + paused: bool | None + Whether the player should be paused, resumed or retain current status when playing this track. + Setting this parameter to ``True`` will pause the player. Setting this parameter to ``False`` will + resume the player if it is currently paused. Setting this parameter to ``None`` will not change the status + of the player. Defaults to ``None``. + + Returns + ------- + :class:`~wavelink.Playable` + The track that began playing. + + + .. versionchanged:: 3.0.0 + Added the ``paused`` parameter. ``replace``, ``start``, ``end``, ``volume`` and ``paused`` are now all + keyword-only arguments. + """ assert self.guild is not None - request: RequestPayload = {"encodedTrack": track.encoded, "volume": 20} - await self.node._update_player(self.guild.id, data=request) + original_vol: int = self._volume + vol: int = volume or self._volume - self._current = track + if vol != self._volume: + self._volume = vol - def _invalidate(self) -> None: - self._connected = False + if replace or not self._current: + self._current = track + self._original = track + + old_previous = self._previous + self._previous = self._current + + pause: bool + if paused is not None: + pause = paused + else: + pause = self._paused + + request: RequestPayload = { + "encodedTrack": track.encoded, + "volume": vol, + "position": start, + "endTime": end, + "paused": pause + } try: - self.cleanup() - except (AttributeError, KeyError): - pass + await self.node._update_player(self.guild.id, data=request, replace=replace) + except LavalinkException as e: + self._current = None + self._original = None + self._previous = old_previous + self._volume = original_vol + raise e + + self._paused = pause + + return track + + async def pause(self, value: bool, /) -> None: + """Set the paused or resume state of the player. + + Parameters + ---------- + value: bool + A bool indicating whether the player should be paused or resumed. True indicates that the player should be + ``paused``. False will resume the player if it is currently paused. + + + .. versionchanged:: 3.0.0 + This method now expects a positional-only bool value. The ``resume`` method has been removed. + """ + assert self.guild is not None + + request: RequestPayload = {"paused": value} + await self.node._update_player(self.guild.id, data=request) + + self._paused = value + + async def seek(self, position: int = 0, /) -> None: + """Seek to the provided position in the currently playing track, in milliseconds. + + Parameters + ---------- + position: int + The position to seek to in milliseconds. To restart the song from the beginning, + you can disregard this parameter or set position to 0. + + + .. versionchanged:: 3.0.0 + The ``position`` parameter is now positional-only, and has a default of 0. + """ + assert self.guild is not None + + if not self._current: + return + + request: RequestPayload = {"position": position} + await self.node._update_player(self.guild.id, data=request) + + async def set_filter(self) -> None: + raise NotImplementedError + + async def set_volume(self, value: int = 100, /) -> None: + """Set the :class:`Player` volume, as a percentage, between 0 and 1000. + + By default, every player is set to 100 on creation. If a value outside 0 to 1000 is provided it will be + clamped. + + Parameters + ---------- + value: int + A volume value between 0 and 1000. To reset the player to 100, you can disregard this parameter. + + + .. versionchanged:: 3.0.0 + The ``value`` parameter is now positional-only, and has a default of 100. + """ + assert self.guild is not None + vol: int = max(min(value, 1000), 0) + + request: RequestPayload = {"volume": vol} + await self.node._update_player(self.guild.id, data=request) + + self._volume = vol async def disconnect(self, **kwargs: Any) -> None: + """Disconnect the player from the current voice channel and remove it from the :class:`~wavelink.Node`. + + This method will cause any playing track to stop and potentially trigger the following events: + + - ``on_wavelink_track_end`` + - ``on_wavelink_websocket_closed`` + + + .. warning:: + + Please do not re-use a :class:`Player` instance that has been disconnected, unwanted side effects are + possible. + """ assert self.guild await self._destroy() await self.guild.change_voice_state(channel=None) + async def stop(self, *, force: bool = True) -> Playable | None: + """An alias to :meth:`skip`. + + See Also: :meth:`skip` for more information. + + .. versionchanged:: 3.0.0 + This method is now known as ``skip``, but the alias ``stop`` has been kept for backwards compatability. + """ + return await self.skip(force=force) + + async def skip(self, *, force: bool = True) -> Playable | None: + """Stop playing the currently playing track. + + Parameters + ---------- + force: bool + Whether the track should skip looping, if :class:`wavelink.Queue` has been set to loop. + Defaults to ``True``. + + Returns + ------- + :class:`~wavelink.Playable` | None + The currently playing track that was skipped, or ``None`` if no track was playing. + + + .. versionchanged:: 3.0.0 + This method was previously known as ``stop``. To avoid confusion this method is now known as ``skip``. + This method now returns the :class:`~wavelink.Playable` that was skipped. + """ + assert self.guild is not None + old: Playable | None = self._current + + request: RequestPayload = {"encodedTrack": None} + await self.node._update_player(self.guild.id, data=request, replace=True) + + return old + + def _invalidate(self) -> None: + self._connected = False + self._connection_event.clear() + + try: + self.cleanup() + except (AttributeError, KeyError): + pass + async def _destroy(self) -> None: assert self.guild From 68a5ce0f7b28bf5f2f617be04b76a9c5232306ae Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 16:51:17 +1000 Subject: [PATCH 033/132] Run black. --- wavelink/player.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index e6759eeb..7918515f 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -226,14 +226,14 @@ async def connect( raise ChannelTimeoutException(msg) async def play( - self, - track: Playable, - *, - replace: bool = True, - start: int = 0, - end: int | None = None, - volume: int | None = None, - paused: bool | None = None + self, + track: Playable, + *, + replace: bool = True, + start: int = 0, + end: int | None = None, + volume: int | None = None, + paused: bool | None = None, ) -> Playable: """Play the provided :class:`~wavelink.Playable`. @@ -295,7 +295,7 @@ async def play( "volume": vol, "position": start, "endTime": end, - "paused": pause + "paused": pause, } try: From 473763944e1e340438570245b634848ad91d7a48 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 17:00:59 +1000 Subject: [PATCH 034/132] Return NotImplemented rather than raise. --- wavelink/node.py | 2 +- wavelink/tracks.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index a6d4f854..44eabc39 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -104,7 +104,7 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, Node): - raise NotImplementedError + return NotImplemented return other.identifier == self.identifier diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 298aa67a..7ceefc78 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -102,7 +102,7 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, Playable): - raise NotImplementedError + return NotImplemented return self.encoded == other.encoded @@ -374,7 +374,7 @@ def __repr__(self) -> str: def __eq__(self, other: object) -> bool: if not isinstance(other, Playlist): - raise NotImplementedError + return NotImplemented return self.name == other.name and self.tracks == other.tracks From c7952fbf5c3975815a9af67baef3f8309d610bcb Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 17:18:24 +1000 Subject: [PATCH 035/132] Remove playable.py --- wavelink/types/playable.py | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 wavelink/types/playable.py diff --git a/wavelink/types/playable.py b/wavelink/types/playable.py deleted file mode 100644 index 133c897b..00000000 --- a/wavelink/types/playable.py +++ /dev/null @@ -1,5 +0,0 @@ -from typing import Union - -from ..tracks import Playable, Playlist - -PlayableUnion = Union[Playable, Playlist] From 4d71573a5e47e801783b8582e6402c82ee92653b Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 21:38:34 +1000 Subject: [PATCH 036/132] Add LFU cache. --- wavelink/lfu.py | 187 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 wavelink/lfu.py diff --git a/wavelink/lfu.py b/wavelink/lfu.py new file mode 100644 index 00000000..46185db3 --- /dev/null +++ b/wavelink/lfu.py @@ -0,0 +1,187 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +from typing import Any, DefaultDict + +from .exceptions import WavelinkException + + +class CapacityZero(WavelinkException): + ... + + +class _MissingSentinel: + __slots__ = () + + def __eq__(self, other: object) -> bool: + return False + + def __bool__(self) -> bool: + return False + + def __hash__(self) -> int: + return 0 + + def __repr__(self) -> str: + return "..." + + +class _NotFoundSentinel(_MissingSentinel): + def __repr__(self) -> str: + return "NotFound" + + +MISSING: Any = _MissingSentinel() +NotFound: Any = _NotFoundSentinel() + + +class DLLNode: + __slots__ = ("value", "previous", "later") + + def __init__(self, value: Any | None = None, previous: DLLNode | None = None, later: DLLNode | None = None) -> None: + self.value = value + self.previous = previous + self.later = later + + +@dataclass +class DataNode: + key: Any + value: Any + frequency: int + node: DLLNode + + +class LFUCache: + def __init__(self, *, capacity: int) -> None: + self._capacity = capacity + self._cache: dict[Any, DataNode] = {} + + self._freq_map: DefaultDict[int, DLL] = defaultdict(DLL) + self._min: int = 1 + self._used: int = 0 + + def __len__(self) -> int: + return len(self._cache) + + def __getitem__(self, key: Any) -> Any: + if key not in self._cache: + raise KeyError(f'"{key}" could not be found in LFU.') + + return self.get(key) + + def __setitem__(self, key: Any, value: Any) -> None: + return self.put(key, value) + + @property + def capacity(self) -> int: + return self._capacity + + def get(self, key: Any, default: Any = MISSING) -> Any: + if key not in self._cache: + return default if default is not MISSING else NotFound + + data: DataNode = self._cache[key] + self._freq_map[data.frequency].remove(data.node) + self._freq_map[data.frequency + 1].append(data.node) + + self._cache[key] = DataNode(key=key, value=data.value, frequency=data.frequency + 1, node=data.node) + self._min += self._min == data.frequency and not self._freq_map[data.frequency] + + return data.value + + def put(self, key: Any, value: Any) -> None: + if self._capacity <= 0: + raise CapacityZero("Unable to place item in LFU as capacity has been set to 0 or below.") + + if key in self._cache: + self._cache[key].value = value + self.get(key) + return + + if self._used == self._capacity: + least_freq: DLL = self._freq_map[self._min] + least_freq_key: DLLNode | None = least_freq.popleft() + + if least_freq_key: + self._cache.pop(least_freq_key) + self._used -= 1 + + data: DataNode = DataNode(key=key, value=value, frequency=1, node=DLLNode(key)) + self._freq_map[data.frequency].append(data.node) + self._cache[key] = data + + self._used += 1 + self._min = 1 + + +class DLL: + __slots__ = ("head", "tail") + + def __init__(self) -> None: + self.head: DLLNode = DLLNode() + self.tail: DLLNode = DLLNode() + + self.head.later, self.tail.previous = self.tail, self.head + + def append(self, node: DLLNode) -> None: + tail_prev: DLLNode | None = self.tail.previous + tail: DLLNode | None = self.tail + + assert tail_prev and tail + + tail_prev.later = node + tail.previous = node + + node.later = tail + node.previous = tail_prev + + def popleft(self) -> DLLNode | None: + node: DLLNode | None = self.head.later + if node is None: + return + + self.remove(node) + return node + + def remove(self, node: DLLNode | None) -> None: + if node is None: + return + + node_prev: DLLNode | None = node.previous + node_later: DLLNode | None = node.later + + assert node_prev and node_later + + node_prev.later = node_later + node_later.previous = node_prev + + node.later = None + node.previous = None + + def __bool__(self) -> bool: + return self.head.later != self.tail From 9d98e19372ecf2503eefff12bd281ffd270a5baf Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 21:39:07 +1000 Subject: [PATCH 037/132] Add request caching options and bump version. --- pyproject.toml | 2 +- wavelink/__init__.py | 3 ++- wavelink/node.py | 49 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f22c0448..5fa18f20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b1" +version = "3.0.0b2" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index b7e7cb7b..c087e66c 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,11 +25,12 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b1" +__version__ = "3.0.0b2" from .enums import * from .exceptions import * +from .lfu import CapacityZero as CapacityZero from .node import * from .payloads import * from .player import Player as Player diff --git a/wavelink/node.py b/wavelink/node.py index 44eabc39..85706e51 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -41,6 +41,7 @@ LavalinkException, LavalinkLoadException, ) +from .lfu import CapacityZero, LFUCache from .tracks import Playable, Playlist from .websocket import Websocket @@ -334,9 +335,12 @@ def get_player(self, guild_id: int, /) -> Player | None: class Pool: __nodes: dict[str, Node] = {} + __cache: LFUCache | None = None @classmethod - async def connect(cls, *, nodes: Iterable[Node], client: discord.Client | None = None) -> dict[str, Node]: + async def connect( + cls, *, nodes: Iterable[Node], client: discord.Client | None = None, cache_capacity: int | None = None + ) -> dict[str, Node]: """Connect the provided Iterable[:class:`Node`] to Lavalink. Parameters @@ -379,6 +383,14 @@ async def connect(cls, *, nodes: Iterable[Node], client: discord.Client | None = else: cls.__nodes[node.identifier] = node + if cache_capacity is not None: + if cache_capacity <= 0: + logger.warning(f"LFU Request cache capacity must be > 0. Not enabling cache.") + + else: + cls.__cache = LFUCache(capacity=cache_capacity) + logger.info("Experimental request caching has been toggled ON. To disable run Pool.toggle_cache()") + return cls.nodes @classproperty @@ -462,21 +474,38 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: # TODO: Documentation Extension for `.. positional-only::` marker. encoded_query: str = cast(str, urllib.parse.quote(query)) # type: ignore + if cls.__cache is not None: + potential: list[Playable] | Playlist = cls.__cache.get(encoded_query, None) + + if potential: + return potential + node: Node = cls.get_node() resp: LoadedResponse = await node._fetch_tracks(encoded_query) if resp["loadType"] == "track": track = Playable(data=resp["data"]) + if cls.__cache is not None and not track.is_stream: + cls.__cache.put(encoded_query, [track]) + return [track] elif resp["loadType"] == "search": tracks = [Playable(data=tdata) for tdata in resp["data"]] + if cls.__cache is not None: + cls.__cache.put(encoded_query, tracks) + return tracks if resp["loadType"] == "playlist": - return Playlist(data=resp["data"]) + playlist: Playlist = Playlist(data=resp["data"]) + + if cls.__cache is not None: + cls.__cache.put(encoded_query, playlist) + + return playlist elif resp["loadType"] == "empty": return [] @@ -486,3 +515,19 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: else: return [] + + @classmethod + def toggle_cache(cls, capacity: int = 100) -> None: + if capacity <= 0: + raise CapacityZero(f"LFU Request cache capacity must be > 0.") + + if cls.__cache is not None: + cls.__cache = LFUCache(capacity=capacity) + logger.info("Experimental request caching has been toggled ON. To disable run Pool.toggle_cache()") + else: + cls.__cache = None + logger.info("Experimental request caching has been toggled OFF. To enable run Pool.toggle_cache()") + + @classproperty + def cache(cls) -> bool: + return cls.__cache is not None From 7860052b5677aaf50a60433ffe139094a1068166 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 21:46:56 +1000 Subject: [PATCH 038/132] Add extra check in toggle_cache. --- wavelink/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/node.py b/wavelink/node.py index 85706e51..33579122 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -518,7 +518,7 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: @classmethod def toggle_cache(cls, capacity: int = 100) -> None: - if capacity <= 0: + if cls.__cache is None and capacity <= 0: raise CapacityZero(f"LFU Request cache capacity must be > 0.") if cls.__cache is not None: From 37309f229436e4e75a0cd5a20e39327d4c39a6f7 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 13 Sep 2023 22:02:42 +1000 Subject: [PATCH 039/132] Fix toggle_cache --- wavelink/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/node.py b/wavelink/node.py index 33579122..1b4278c5 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -521,7 +521,7 @@ def toggle_cache(cls, capacity: int = 100) -> None: if cls.__cache is None and capacity <= 0: raise CapacityZero(f"LFU Request cache capacity must be > 0.") - if cls.__cache is not None: + if cls.__cache is None: cls.__cache = LFUCache(capacity=capacity) logger.info("Experimental request caching has been toggled ON. To disable run Pool.toggle_cache()") else: From e0a6f978758fc633c72292798df2ccd51641e4af Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 13 Sep 2023 18:50:30 +0100 Subject: [PATCH 040/132] Remove unused f-strings and else Remove unused f-strings and unnecessary else in guard clause --- wavelink/node.py | 14 ++++++-------- wavelink/websocket.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index 1b4278c5..2f3e26ad 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -244,9 +244,8 @@ async def _destroy_player(self, guild_id: int, /) -> None: if resp.status == 204: return - else: - exc_data: ErrorResponse = await resp.json() - raise LavalinkException(data=exc_data) + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) async def _update_session(self, *, data: UpdateSessionRequest) -> UpdateResponse: uri: str = f"{self.uri}/v4/sessions/{self.session_id}" @@ -309,9 +308,8 @@ async def _fetch_version(self) -> str: if resp.status == 200: return await resp.text() - else: - exc_data: ErrorResponse = await resp.json() - raise LavalinkException(data=exc_data) + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) def get_player(self, guild_id: int, /) -> Player | None: """Return a :class:`~wavelink.Player` associated with the provided :attr:discord.Guild.id`. @@ -385,7 +383,7 @@ async def connect( if cache_capacity is not None: if cache_capacity <= 0: - logger.warning(f"LFU Request cache capacity must be > 0. Not enabling cache.") + logger.warning("LFU Request cache capacity must be > 0. Not enabling cache.") else: cls.__cache = LFUCache(capacity=cache_capacity) @@ -519,7 +517,7 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: @classmethod def toggle_cache(cls, capacity: int = 100) -> None: if cls.__cache is None and capacity <= 0: - raise CapacityZero(f"LFU Request cache capacity must be > 0.") + raise CapacityZero("LFU Request cache capacity must be > 0.") if cls.__cache is None: cls.__cache = LFUCache(capacity=capacity) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 8d1f8ebb..64101efd 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -139,7 +139,7 @@ async def keep_alive(self) -> None: break if message.data is None: - logger.debug(f"Received an empty message from Lavalink websocket. Disregarding.") + logger.debug("Received an empty message from Lavalink websocket. Disregarding.") continue data: WebsocketOP = message.json() From a474bb2b88e628437e2ad191b581f46125d03fc0 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 14 Sep 2023 20:34:51 +1000 Subject: [PATCH 041/132] Add NodeException --- wavelink/exceptions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py index 4cf5eeaa..d7a2799b 100644 --- a/wavelink/exceptions.py +++ b/wavelink/exceptions.py @@ -31,6 +31,7 @@ __all__ = ( "WavelinkException", + "NodeException", "InvalidClientException", "AuthorizationFailedException", "InvalidNodeException", @@ -49,6 +50,10 @@ class WavelinkException(Exception): """ +class NodeException(WavelinkException): + """Error raised when an Unknown or Generic error occurs on a Node.""" + + class InvalidClientException(WavelinkException): """Exception raised when an invalid :class:`discord.Client` is provided while connecting a :class:`wavelink.Node`. From 28b039717e6016f55c75d752ef63fa94c918ebc4 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 14 Sep 2023 20:36:01 +1000 Subject: [PATCH 042/132] Changes to some Node connection/disconnect logic. --- wavelink/node.py | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index 2f3e26ad..09b49079 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -23,6 +23,7 @@ """ from __future__ import annotations +import asyncio import logging import secrets import urllib @@ -36,6 +37,7 @@ from .enums import NodeStatus from .exceptions import ( AuthorizationFailedException, + NodeException, InvalidClientException, InvalidNodeException, LavalinkException, @@ -100,6 +102,8 @@ def __init__( self._spotify_enabled: bool = False + self._websocket: Websocket | None = None + def __repr__(self) -> str: return f"Node(identifier={self.identifier}, uri={self.uri}, status={self.status}, players={len(self.players)})" @@ -109,6 +113,9 @@ def __eq__(self, other: object) -> bool: return other.identifier == self.identifier + def __del__(self) -> None: + asyncio.create_task(self.close()) + @property def headers(self) -> dict[str, str]: assert self.client is not None @@ -185,6 +192,21 @@ def session_id(self) -> str | None: """ return self._session_id + async def close(self) -> None: + if self._websocket is not None: + await self._websocket.cleanup() + else: + self._status = NodeStatus.DISCONNECTED + self._session_id = None + self._players = {} + + try: + await self._session.close() + except: + pass + + Pool._Pool__nodes.pop(self.identifier, None) # type: ignore + async def _connect(self, *, client: discord.Client | None) -> None: client_ = self._client or client @@ -192,7 +214,9 @@ async def _connect(self, *, client: discord.Client | None) -> None: raise InvalidClientException(f"Unable to connect {self!r} as you have not provided a valid discord.Client.") self._client = client_ + websocket: Websocket = Websocket(node=self) + self._websocket = websocket await websocket.connect() info: InfoResponse = await self._fetch_info() @@ -378,10 +402,13 @@ async def connect( logger.error(e) except AuthorizationFailedException: logger.error(f"Failed to authenticate {node!r} on Lavalink with the provided password.") + except NodeException: + logger.error(f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " + f"and that you are trying to connect to Lavalink on the correct port.") else: cls.__nodes[node.identifier] = node - if cache_capacity is not None: + if cache_capacity is not None and cls.nodes: if cache_capacity <= 0: logger.warning("LFU Request cache capacity must be > 0. Not enabling cache.") @@ -425,16 +452,16 @@ def get_node(cls, identifier: str | None = None, /) -> Node: .. versionchanged:: 3.0.0 The ``id`` parameter was changed to ``identifier`` and is positional only. """ - if not cls.__nodes: - raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool.") - if identifier: if identifier not in cls.__nodes: raise InvalidNodeException(f'A Node with the identifier "{identifier}" does not exist.') return cls.__nodes[identifier] - nodes: list[Node] = list(cls.__nodes.values()) + nodes: list[Node] = [n for n in cls.__nodes.values() if n.status is NodeStatus.CONNECTED] + if nodes: + raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool in a CONNECTED state.") + return sorted(nodes, key=lambda n: len(n.players))[0] @classmethod From 943d1e8a807a6b44c3131fd3cfe495dd33c7e7dc Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 14 Sep 2023 20:36:53 +1000 Subject: [PATCH 043/132] Websocket 404/>=500 error handling and cleanups. --- wavelink/websocket.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 64101efd..a56fe5b6 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -32,7 +32,7 @@ from . import __version__ from .backoff import Backoff from .enums import NodeStatus -from .exceptions import AuthorizationFailedException +from .exceptions import AuthorizationFailedException, NodeException from .payloads import * from .tracks import Playable @@ -97,7 +97,11 @@ async def connect(self) -> None: self.socket = await session.ws_connect(url=uri, heartbeat=heartbeat, headers=self.headers) # type: ignore except Exception as e: if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: + await self.cleanup() raise AuthorizationFailedException from e + elif isinstance(e, aiohttp.WSServerHandshakeError) and (e.status == 404 or e.status >= 500): + await self.cleanup() + raise NodeException from e else: logger.warning( f'An unexpected error occurred while connecting {self.node!r} to Lavalink: "{e}"\n' @@ -239,4 +243,6 @@ async def cleanup(self) -> None: self.node._session_id = None self.node._players = {} + self.node._websocket = None + logger.debug(f"Successfully cleaned up the websocket for {self.node!r}") From 37451277b304af4522f1f3367c6ac3f01021e977 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 14 Sep 2023 20:37:41 +1000 Subject: [PATCH 044/132] Run black. --- wavelink/node.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index 09b49079..a6ded9c3 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -37,11 +37,11 @@ from .enums import NodeStatus from .exceptions import ( AuthorizationFailedException, - NodeException, InvalidClientException, InvalidNodeException, LavalinkException, LavalinkLoadException, + NodeException, ) from .lfu import CapacityZero, LFUCache from .tracks import Playable, Playlist @@ -403,8 +403,10 @@ async def connect( except AuthorizationFailedException: logger.error(f"Failed to authenticate {node!r} on Lavalink with the provided password.") except NodeException: - logger.error(f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " - f"and that you are trying to connect to Lavalink on the correct port.") + logger.error( + f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " + f"and that you are trying to connect to Lavalink on the correct port." + ) else: cls.__nodes[node.identifier] = node From 6d12813ba14e11c0f2737c0358602449ee489837 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 14 Sep 2023 20:38:14 +1000 Subject: [PATCH 045/132] Bump version - 3.0.0b3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5fa18f20..aad77622 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b2" +version = "3.0.0b3" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] From ae04c675e6fc2ad83760f746b28102cc60b8006f Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 14 Sep 2023 20:47:58 +1000 Subject: [PATCH 046/132] Check if loop is not running --- wavelink/node.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wavelink/node.py b/wavelink/node.py index a6ded9c3..2ee4c735 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -114,7 +114,11 @@ def __eq__(self, other: object) -> bool: return other.identifier == self.identifier def __del__(self) -> None: - asyncio.create_task(self.close()) + try: + asyncio.get_running_loop() + asyncio.create_task(self.close()) + except RuntimeError: + pass @property def headers(self) -> dict[str, str]: From ac42441b0d8c13142ed528136205cf0b08bace9f Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 29 Sep 2023 14:45:16 +1000 Subject: [PATCH 047/132] Fix logic error in get_node...Woops --- wavelink/node.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/node.py b/wavelink/node.py index 2ee4c735..0d4492c6 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -465,7 +465,7 @@ def get_node(cls, identifier: str | None = None, /) -> Node: return cls.__nodes[identifier] nodes: list[Node] = [n for n in cls.__nodes.values() if n.status is NodeStatus.CONNECTED] - if nodes: + if not nodes: raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool in a CONNECTED state.") return sorted(nodes, key=lambda n: len(n.players))[0] From 1a643855cf2ece58cf848d1f5ba71019520700ee Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 29 Sep 2023 14:45:53 +1000 Subject: [PATCH 048/132] Bump version --- pyproject.toml | 3 ++- wavelink/__init__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index aad77622..544ae6a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b3" +version = "3.0.0b4" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] @@ -38,5 +38,6 @@ line-length = 120 profile = "black" [tool.pyright] +ignore = ["test*.py"] typeCheckingMode = "strict" reportPrivateUsage = false diff --git a/wavelink/__init__.py b/wavelink/__init__.py index c087e66c..4d41235e 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b2" +__version__ = "3.0.0b4" from .enums import * From b31d6282e26a95db18dab1cdd220d30e011ac84a Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 7 Oct 2023 07:17:15 +1000 Subject: [PATCH 049/132] Add initial AutoPlay code. --- wavelink/player.py | 95 ++++++++++++++++++++++++++++++++++++++++++- wavelink/websocket.py | 5 ++- 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 7918515f..d53304c3 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -25,6 +25,7 @@ import asyncio import logging +import random from typing import TYPE_CHECKING, Any, Union import async_timeout @@ -32,14 +33,17 @@ from discord.abc import Connectable from discord.utils import MISSING +from .enums import AutoPlayMode, NodeStatus from .exceptions import ( ChannelTimeoutException, InvalidChannelStateException, LavalinkException, + QueueEmpty, ) from .node import Pool +from .payloads import TrackEndEventPayload from .queue import Queue -from .tracks import Playable +from .tracks import Playable, Playlist if TYPE_CHECKING: from discord.types.voice import GuildVoiceState as GuildVoiceStatePayload @@ -52,7 +56,6 @@ VocalGuildChannel = Union[discord.VoiceChannel, discord.StageChannel] - logger: logging.Logger = logging.getLogger(__name__) @@ -105,10 +108,98 @@ def __init__( self._previous: Playable | None = None self.queue: Queue = Queue() + self.auto_queue: Queue = Queue() self._volume: int = 100 self._paused: bool = False + self._autoplay: AutoPlayMode = AutoPlayMode.disabled + + async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: + if self._autoplay is AutoPlayMode.disabled: + return + + if payload.reason == "replaced": + return + + if self.node.status is not NodeStatus.CONNECTED: + logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to disconnected Node.') + return + + if not isinstance(self.queue, Queue) or not isinstance(self.auto_queue, Queue): # type: ignore + logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to unsupported Queue.') + return + + if self._autoplay is AutoPlayMode.partial or self.queue: + await self._do_partial() + + elif self._autoplay is AutoPlayMode.enabled: + await self._do_recommendation() + + async def _do_partial(self) -> None: + # TODO: This may change in the future depending on queue changes? + + if self._current is None: + try: + track: Playable = self.queue.get() + except QueueEmpty: + return + + await self.play(track) + + async def _do_recommendation(self): + # TODO: Adjustable trigger? + if len(self.auto_queue) > 20: + await self.play(self.auto_queue.get()) + return + + choices: list[Playable | None] + + choices = random.choices( + [*self.queue.history[0:10], *self.auto_queue[0:10], self._current, self._previous], k=5 + ) + filtered: list[Playable] = [t for t in choices if t is not None] + + spotify: list[str] = [t.identifier for t in filtered if t.source == "spotify"][0:3] + if self._node._spotify_enabled: + query: str = f'sprec:seed_tracks={",".join(spotify)}' + + search: list[Playable] | Playlist = await Pool.fetch_tracks(query) + if not search: + return + + tracks: list[Playable] + if isinstance(search, Playlist): + tracks = search.tracks.copy() + else: + tracks = search + + random.shuffle(tracks) + + if not self._current: + now: Playable = tracks.pop(0) + now._recommended = True + + await self.play(now) + + for track in tracks: + if track in self.auto_queue or track in self.auto_queue.history: + continue + + track._recommended = True + await self.auto_queue.put_wait(track) + + @property + def autoplay(self) -> AutoPlayMode: + return self._autoplay + + @autoplay.setter + def autoplay(self, value: Any) -> None: + if not isinstance(value, AutoPlayMode): + raise ValueError("Please provide a valid 'wavelink.AutoPlayMode' to set.") + + self._autoplay = value + @property def node(self) -> Node: """The :class:`Player`'s currently selected :class:`Node`. diff --git a/wavelink/websocket.py b/wavelink/websocket.py index a56fe5b6..6945f10d 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -99,7 +99,7 @@ async def connect(self) -> None: if isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 401: await self.cleanup() raise AuthorizationFailedException from e - elif isinstance(e, aiohttp.WSServerHandshakeError) and (e.status == 404 or e.status >= 500): + elif isinstance(e, aiohttp.WSServerHandshakeError) and e.status == 404: await self.cleanup() raise NodeException from e else: @@ -184,6 +184,9 @@ async def keep_alive(self) -> None: endpayload: TrackEndEventPayload = TrackEndEventPayload(player=player, track=track, reason=reason) self.dispatch("track_end", endpayload) + if player: + asyncio.create_task(player._auto_play_event(endpayload)) + elif data["type"] == "TrackExceptionEvent": track: Playable = Playable(data["track"]) exception: TrackExceptionPayload = data["exception"] From 0505e032954a922252064d90fd1777ae098c6cf9 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 7 Oct 2023 07:17:45 +1000 Subject: [PATCH 050/132] Add recommended property to Playable. --- wavelink/tracks.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 7ceefc78..e19d5a98 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -90,6 +90,7 @@ def __init__(self, data: TrackPayload, *, playlist: PlaylistInfo | None = None) self._is_preview: bool | None = plugin.get("isPreview") self._playlist = playlist + self._recommended: bool = False def __hash__(self) -> int: return hash(self.encoded) @@ -174,6 +175,10 @@ def is_preview(self) -> bool | None: def playlist(self) -> PlaylistInfo | None: return self._playlist + @property + def recommended(self) -> bool: + return self._recommended + @classmethod async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTube) -> Search: """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. @@ -401,6 +406,9 @@ def __reversed__(self) -> Iterator[Playable]: def __contains__(self, item: Playable) -> bool: return item in self.tracks + def pop(self, index: int = -1) -> Playable: + return self.tracks.pop(index) + def track_extras(self, **attrs: Any) -> None: """Method which sets attributes to all :class:`Playable` in this playlist, with the provided keyword arguments. From 750b114f500c5561202f47e04374afe1b74a8a2b Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 7 Oct 2023 07:18:24 +1000 Subject: [PATCH 051/132] Queue can add list of Playable. Add contains dunder to Queue. --- wavelink/queue.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index 6ed1846e..1d0ea5ef 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -66,6 +66,9 @@ def __getitem__(self, index: int | slice) -> Playable | list[Playable]: return self._queue[index] + def __contains__(self, item: Any) -> bool: + return item in self._queue + @staticmethod def _check_compatability(item: Any) -> None: if not isinstance(item, Playable): @@ -80,7 +83,7 @@ def _get(self) -> Playable: def get(self) -> Playable: return self._get() - def _check_atomic(self, item: Playlist) -> None: + def _check_atomic(self, item: list[Playable] | Playlist) -> None: for track in item: self._check_compatability(track) @@ -171,11 +174,11 @@ def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: self._wakeup_next() return added - async def put_wait(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomic: bool = True) -> int: added: int = 0 async with self._lock: - if isinstance(item, Playlist): + if isinstance(item, (list, Playlist)): if atomic: super()._check_atomic(item) From c9253e16eff35a9918c64a6a6cb95982108fbc7b Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 7 Oct 2023 07:18:52 +1000 Subject: [PATCH 052/132] dd closing logic to Node/Pool. Change cache logic. --- wavelink/node.py | 97 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 29 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index 0d4492c6..ef11a66a 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -23,7 +23,6 @@ """ from __future__ import annotations -import asyncio import logging import secrets import urllib @@ -43,7 +42,7 @@ LavalinkLoadException, NodeException, ) -from .lfu import CapacityZero, LFUCache +from .lfu import LFUCache from .tracks import Playable, Playlist from .websocket import Websocket @@ -96,6 +95,7 @@ def __init__( self._client = client self._status: NodeStatus = NodeStatus.DISCONNECTED + self._has_closed: bool = False self._session_id: str | None = None self._players: dict[int, Player] = {} @@ -113,13 +113,6 @@ def __eq__(self, other: object) -> bool: return other.identifier == self.identifier - def __del__(self) -> None: - try: - asyncio.get_running_loop() - asyncio.create_task(self.close()) - except RuntimeError: - pass - @property def headers(self) -> dict[str, str]: assert self.client is not None @@ -196,20 +189,38 @@ def session_id(self) -> str | None: """ return self._session_id - async def close(self) -> None: - if self._websocket is not None: - await self._websocket.cleanup() - else: - self._status = NodeStatus.DISCONNECTED - self._session_id = None - self._players = {} - + async def _pool_closer(self) -> None: try: await self._session.close() except: pass - Pool._Pool__nodes.pop(self.identifier, None) # type: ignore + if not self._has_closed: + await self.close() + + async def close(self) -> None: + disconnected: list[Player] = [] + + for player in self._players.values(): + try: + await player._destroy() + except LavalinkException: + pass + + disconnected.append(player) + + if self._websocket is not None: + await self._websocket.cleanup() + + self._status = NodeStatus.DISCONNECTED + self._session_id = None + self._players = {} + + self._has_closed = True + + # Dispatch Node Closed Event... node, list of disconnected players + if self.client is not None: + self.client.dispatch("wavelink_node_closed", self, disconnected) async def _connect(self, *, client: discord.Client | None) -> None: client_ = self._client or client @@ -223,6 +234,10 @@ async def _connect(self, *, client: discord.Client | None) -> None: self._websocket = websocket await websocket.connect() + self._has_closed = False + if not self._session or self._session.closed: + self._session = aiohttp.ClientSession() + info: InfoResponse = await self._fetch_info() if "spotify" in info["sourceManagers"]: self._spotify_enabled = True @@ -424,6 +439,31 @@ async def connect( return cls.nodes + @classmethod + async def reconnect(cls) -> dict[str, Node]: + for node in cls.__nodes.values(): + if node.status is not NodeStatus.DISCONNECTED: + continue + + try: + await node._connect(client=None) + except InvalidClientException as e: + logger.error(e) + except AuthorizationFailedException: + logger.error(f"Failed to authenticate {node!r} on Lavalink with the provided password.") + except NodeException: + logger.error( + f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " + f"and that you are trying to connect to Lavalink on the correct port." + ) + + return cls.nodes + + @classmethod + async def close(cls) -> None: + for node in cls.__nodes.values(): + await node.close() + @classproperty def nodes(cls) -> dict[str, Node]: """A mapping of :attr:`Node.identifier` to :class:`Node` that have previously been successfully connected. @@ -548,17 +588,16 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: return [] @classmethod - def toggle_cache(cls, capacity: int = 100) -> None: - if cls.__cache is None and capacity <= 0: - raise CapacityZero("LFU Request cache capacity must be > 0.") - - if cls.__cache is None: - cls.__cache = LFUCache(capacity=capacity) - logger.info("Experimental request caching has been toggled ON. To disable run Pool.toggle_cache()") - else: + def cache(cls, capacity: int | None | bool = None) -> None: + if capacity in (None, False) or capacity <= 0: cls.__cache = None - logger.info("Experimental request caching has been toggled OFF. To enable run Pool.toggle_cache()") + return - @classproperty - def cache(cls) -> bool: + if not isinstance(capacity, int): # type: ignore + raise ValueError("The LFU cache expects an integer, None or bool.") + + cls.__cache = LFUCache(capacity=capacity) + + @classmethod + def has_cache(cls) -> bool: return cls.__cache is not None From 97a8e5139a4ee36a4f5695126e04d55405a717ac Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 7 Oct 2023 07:26:20 +1000 Subject: [PATCH 053/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 544ae6a8..e816048f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b4" +version = "3.0.0b5" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 4d41235e..00609b6f 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b4" +__version__ = "3.0.0b5" from .enums import * From 6810046436232c4bb250e6a7c0e643c91cc2c5f2 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 10:59:26 +1000 Subject: [PATCH 054/132] Add YouTubeMusic recommendations and queue history. --- wavelink/player.py | 120 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 98 insertions(+), 22 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index d53304c3..0c8881f5 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -26,6 +26,7 @@ import asyncio import logging import random +import typing from typing import TYPE_CHECKING, Any, Union import async_timeout @@ -33,6 +34,8 @@ from discord.abc import Connectable from discord.utils import MISSING +import wavelink + from .enums import AutoPlayMode, NodeStatus from .exceptions import ( ChannelTimeoutException, @@ -59,6 +62,9 @@ logger: logging.Logger = logging.getLogger(__name__) +T_a: typing.TypeAlias = "list[Playable] | Playlist" + + class Player(discord.VoiceProtocol): """ @@ -113,7 +119,15 @@ def __init__( self._volume: int = 100 self._paused: bool = False + self._auto_cutoff: int = 20 + self._previous_seeds_cutoff: int = self._auto_cutoff * 3 + self._autoplay: AutoPlayMode = AutoPlayMode.disabled + self.__previous_seeds: asyncio.Queue[str] = asyncio.Queue(maxsize=self._previous_seeds_cutoff) + + # We need an asyncio lock primitive because if either of the queues are changed during recos.. + # We are screwed... + self._auto_lock: asyncio.Lock = asyncio.Lock() async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if self._autoplay is AutoPlayMode.disabled: @@ -134,11 +148,10 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: await self._do_partial() elif self._autoplay is AutoPlayMode.enabled: - await self._do_recommendation() + async with self._auto_lock: + await self._do_recommendation() async def _do_partial(self) -> None: - # TODO: This may change in the future depending on queue changes? - if self._current is None: try: track: Playable = self.queue.get() @@ -148,25 +161,64 @@ async def _do_partial(self) -> None: await self.play(track) async def _do_recommendation(self): - # TODO: Adjustable trigger? - if len(self.auto_queue) > 20: - await self.play(self.auto_queue.get()) + assert self.guild is not None + + if len(self.auto_queue) > self._auto_cutoff: + track: Playable = self.auto_queue.get() + self.auto_queue.history.put(track) + + await self.play(track) return choices: list[Playable | None] + # Up to 32 tracks as sequence, k=16 (choices) which is half... choices = random.choices( - [*self.queue.history[0:10], *self.auto_queue[0:10], self._current, self._previous], k=5 + [*self.queue.history[0:20], *self.auto_queue[0:10], self._current, self._previous], k=16 ) + + # Filter out tracks which are None... filtered: list[Playable] = [t for t in choices if t is not None] - spotify: list[str] = [t.identifier for t in filtered if t.source == "spotify"][0:3] - if self._node._spotify_enabled: - query: str = f'sprec:seed_tracks={",".join(spotify)}' + seeds: list[Playable] = [] + for seed in filtered: + if seed.identifier in self.__previous_seeds._queue: # type: ignore + continue + + seeds.append(seed) + + spotify: list[str] = [t.identifier for t in seeds if t.source == "spotify"] + youtube: list[str] = [t.identifier for t in seeds if t.source == "youtube"] + + spotify_query: str | None = None + youtube_query: str | None = None - search: list[Playable] | Playlist = await Pool.fetch_tracks(query) + if spotify: + spotify_seeds: list[str] = spotify[:3] + spotify_query = f"sprec:seed_tracks={','.join(spotify_seeds)}" + + for s_seed in spotify_seeds: + if self.__previous_seeds.full(): + self.__previous_seeds.get_nowait() + + self.__previous_seeds.put_nowait(s_seed) + + if youtube: + ytm_seed: str = youtube[0] + youtube_query = f"https://music.youtube.com/watch?v={ytm_seed}8&list=RD{ytm_seed}" + + if self.__previous_seeds.full(): + self.__previous_seeds.get_nowait() + + self.__previous_seeds.put_nowait(ytm_seed) + + async def _search(query: str | None) -> T_a: + if query is None: + return [] + + search: wavelink.Search = await Pool.fetch_tracks(query) if not search: - return + return [] tracks: list[Playable] if isinstance(search, Playlist): @@ -174,20 +226,43 @@ async def _do_recommendation(self): else: tracks = search - random.shuffle(tracks) + return tracks + + results: tuple[T_a, T_a] = await asyncio.gather(_search(spotify_query), _search(youtube_query)) - if not self._current: - now: Playable = tracks.pop(0) - now._recommended = True + # track for result in results for track in result... + # Maybe itertools here tbh... + filtered_r: list[Playable] = [t for r in results for t in r] + random.shuffle(filtered_r) + + if not filtered_r: + logger.debug(f'Player "{self.guild.id}" could not load any songs via AutoPlay.') + return + + if not self._current: + now: Playable = filtered_r.pop(0) + now._recommended = True + self.auto_queue.history.put(now) + + await self.play(now) + + # Possibly adjust these thresholds? + history: list[Playable] = ( + list(self.auto_queue)[0:40] + + list(self.queue)[0:40] + + list(reversed(self.queue.history))[0:40] + + list(reversed(self.auto_queue.history))[0:60] + ) - await self.play(now) + added: int = 0 + for track in filtered_r: + if track in history: + continue - for track in tracks: - if track in self.auto_queue or track in self.auto_queue.history: - continue + track._recommended = True + added += await self.auto_queue.put_wait(track) - track._recommended = True - await self.auto_queue.put_wait(track) + logger.debug(f'Player "{self.guild.id}" added "{added}" tracks to the auto_queue via AutoPlay.') @property def autoplay(self) -> AutoPlayMode: @@ -399,6 +474,7 @@ async def play( raise e self._paused = pause + self.queue.history.put(track) return track From b4eadad76ddafe828cd57b7a8169ba185883be1d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 10:59:54 +1000 Subject: [PATCH 055/132] Add iter dunder to queue. --- wavelink/queue.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index 1d0ea5ef..fb23da86 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -25,7 +25,7 @@ import asyncio from collections import deque -from typing import Any, overload +from typing import Any, Iterator, overload from .exceptions import QueueEmpty from .tracks import * @@ -66,6 +66,9 @@ def __getitem__(self, index: int | slice) -> Playable | list[Playable]: return self._queue[index] + def __iter__(self) -> Iterator[Playable]: + return self._queue.__iter__() + def __contains__(self, item: Any) -> bool: return item in self._queue From 8d76aab80069524c50c5db76d934c454c05a32cb Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 11:02:23 +1000 Subject: [PATCH 056/132] Add simple example (examples/simple.py) --- examples/simple.py | 185 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 examples/simple.py diff --git a/examples/simple.py b/examples/simple.py new file mode 100644 index 00000000..e7fe8dc4 --- /dev/null +++ b/examples/simple.py @@ -0,0 +1,185 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 +from typing import cast + +import discord +import wavelink +from discord.ext import commands + + +class Bot(commands.Bot): + + def __init__(self) -> None: + intents: discord.Intents = discord.Intents.default() + intents.message_content = True + + discord.utils.setup_logging(level=logging.INFO) + super().__init__(command_prefix="?", intents=intents) + + async def setup_hook(self) -> None: + nodes = [wavelink.Node(uri="...", password="...")] + + # cache_capacity is EXPERIMENTAL. Turn it off by passing None + await wavelink.Pool.connect(nodes=nodes, client=self, cache_capacity=100) + + async def on_ready(self) -> None: + logging.info(f"Logged in: {self.user} | {self.user.id}") + + async def on_wavelink_node_ready(self, node: wavelink.Node) -> None: + logging.info(f"Wavelink Node connected: {node!r}") + + async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload) -> None: + player: wavelink.Player | None = payload.player + if not player: + # Handle edge cases... + return + + original: wavelink.Playable | None = payload.original + track: wavelink.Playable = payload.track + + embed: discord.Embed = discord.Embed(title="Now Playing") + embed.description = f"**{track.title}** by `{track.author}`" + + if track.artwork: + embed.set_image(url=track.artwork) + + if original and original.recommended: + embed.description += f'\n\n`This track was recommended via {track.source}`' + + if track.album.name: + embed.add_field(name='Album', value=track.album.name) + + await player.home.send(embed=embed) + + +bot: Bot = Bot() + + +@bot.command() +async def play(ctx: commands.Context, *, query: str) -> None: + """Play a song with the given query.""" + player: wavelink.Player + + try: + player = await ctx.author.voice.channel.connect(cls=wavelink.Player) + except discord.ClientException: + player = cast(wavelink.Player, ctx.voice_client) + except AttributeError: + await ctx.send("Please join a voice channel first.") + return + + # Turn on AutoPlay to enabled mode. + # enabled = AutoPlay will play songs for us and fetch recommendations... + # partial = AutoPlay will play songs for us, but WILL NOT fetch recommendations... + # disabled = AutoPlay will do nothing... + player.autoplay = wavelink.AutoPlayMode.enabled + + # Lock the player to this channel... + if not hasattr(player, "home"): + player.home = ctx.channel + elif player.home != ctx.channel: + await ctx.send(f"You can only play songs in {player.home.mention}, as the player has already started there.") + return + + # This will handle fetching Tracks and Playlists... + # Seed the doc strings for more information on this method... + # If spotify is enabled via LavaSrc, this will automatically fetch Spotify tracks if you pass a URL... + # Defaults to YouTube for non URL based queries... + tracks: wavelink.Search = await wavelink.Playable.search(query) + if not tracks: + await ctx.send(f"{ctx.author.mention} - Could not find any tracks with that query. Please try again.") + return + + if isinstance(tracks, wavelink.Playlist): + # tracks is a playlist... + added: int = await player.queue.put_wait(tracks) + await ctx.send(f"Added the playlist **`{tracks.name}`** ({added} songs) to the queue.") + else: + track: wavelink.Playable = tracks[0] + await player.queue.put_wait(track) + await ctx.send(f"Added **`{track}`** to the queue.") + + if not player.playing: + # Play now since we aren't playing anything... + await player.play(player.queue.get(), volume=30) + + # Optionally delete the invokers message... + try: + await ctx.message.delete() + except discord.HTTPException: + pass + + +@bot.command() +async def skip(ctx: commands.Context) -> None: + """Skip the current song.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.skip(force=True) + await ctx.message.add_reaction("\u2705") + + +@bot.command(name="toggle", aliases=["pause", "resume"]) +async def pause_resume(ctx: commands.Context) -> None: + """Pause or Resume the Player depending on its current state.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.pause(not player.paused) + await ctx.message.add_reaction("\u2705") + + +@bot.command() +async def volume(ctx: commands.Context, value: int) -> None: + """Change the volume of the player.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.set_volume(value) + await ctx.message.add_reaction("\u2705") + + +@bot.command(aliases=["dc"]) +async def disconnect(ctx: commands.Context) -> None: + """Disconnect the Player.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + await player.disconnect() + await ctx.message.add_reaction("\u2705") + + +async def main() -> None: + async with bot: + await bot.start("BOT_TOKEN_HERE") + + +asyncio.run(main()) From 72a65a5c993ea85a2dcc5b9ee96b7c3f3b5f6f03 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 11:04:08 +1000 Subject: [PATCH 057/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e816048f..97091c9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b5" +version = "3.0.0b6" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 00609b6f..f18e8485 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b5" +__version__ = "3.0.0b6" from .enums import * From a4d93933ecf856998e43afd661171712ea0ee090 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 17:26:41 +1000 Subject: [PATCH 058/132] Add examples folder to pyright ignore. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 97091c9a..95182751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,6 @@ line-length = 120 profile = "black" [tool.pyright] -ignore = ["test*.py"] +ignore = ["test*.py", "examples/*.py"] typeCheckingMode = "strict" reportPrivateUsage = false From b59ed9923722d70349d12c1fb344667fdff1c613 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 17:28:10 +1000 Subject: [PATCH 059/132] Optimise AutoPlay bias. Add add_history kwarg. Small bug fix in AutoPlay --- wavelink/player.py | 75 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 22 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 0c8881f5..72ac3ac8 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -27,6 +27,7 @@ import logging import random import typing +from collections import deque from typing import TYPE_CHECKING, Any, Union import async_timeout @@ -41,6 +42,7 @@ ChannelTimeoutException, InvalidChannelStateException, LavalinkException, + LavalinkLoadException, QueueEmpty, ) from .node import Pool @@ -120,7 +122,9 @@ def __init__( self._paused: bool = False self._auto_cutoff: int = 20 - self._previous_seeds_cutoff: int = self._auto_cutoff * 3 + self._auto_weight: int = 3 + self._previous_seeds_cutoff: int = self._auto_cutoff * self._auto_weight + self._history_count: int | None = None self._autoplay: AutoPlayMode = AutoPlayMode.disabled self.__previous_seeds: asyncio.Queue[str] = asyncio.Queue(maxsize=self._previous_seeds_cutoff) @@ -163,29 +167,21 @@ async def _do_partial(self) -> None: async def _do_recommendation(self): assert self.guild is not None - if len(self.auto_queue) > self._auto_cutoff: + if len(self.auto_queue) > self._auto_cutoff + 1: track: Playable = self.auto_queue.get() self.auto_queue.history.put(track) - await self.play(track) + await self.play(track, add_history=False) return - choices: list[Playable | None] - - # Up to 32 tracks as sequence, k=16 (choices) which is half... - choices = random.choices( - [*self.queue.history[0:20], *self.auto_queue[0:10], self._current, self._previous], k=16 - ) + weighted_history: list[Playable] = list(reversed(self.queue.history))[:max(5, 5 * self._auto_weight)] + weighted_upcoming: list[Playable] = self.auto_queue[:max(3, int((5 * self._auto_weight) / 3))] + choices: list[Playable | None] = [*weighted_history, *weighted_upcoming, self._current, self._previous] # Filter out tracks which are None... - filtered: list[Playable] = [t for t in choices if t is not None] - - seeds: list[Playable] = [] - for seed in filtered: - if seed.identifier in self.__previous_seeds._queue: # type: ignore - continue - - seeds.append(seed) + _previous: deque[str] = self.__previous_seeds._queue # type: ignore + seeds: list[Playable] = [t for t in choices if t is not None and t.identifier not in _previous] + random.shuffle(seeds) spotify: list[str] = [t.identifier for t in seeds if t.source == "spotify"] youtube: list[str] = [t.identifier for t in seeds if t.source == "youtube"] @@ -193,9 +189,37 @@ async def _do_recommendation(self): spotify_query: str | None = None youtube_query: str | None = None + count: int = len(self.queue.history) + changed_by: int + if self._history_count is None: + changed_by = min(3, count) + else: + changed_by: int = count - self._history_count + + if changed_by > 0: + self._history_count = count + + changed_history: list[Playable] = list(reversed(self.queue.history)) + + added: int = 0 + for i in range(changed_by): + if i == 3: + break + + track: Playable = changed_history[i] + if added == 2 and track.source == "spotify": + break + + if track.source == "spotify": + spotify.insert(0, track.identifier) + added += 1 + + elif track.source == "youtube": + youtube[0] = track.identifier + if spotify: spotify_seeds: list[str] = spotify[:3] - spotify_query = f"sprec:seed_tracks={','.join(spotify_seeds)}" + spotify_query = f"sprec:seed_tracks={','.join(spotify_seeds)}&limit=10" for s_seed in spotify_seeds: if self.__previous_seeds.full(): @@ -216,7 +240,11 @@ async def _search(query: str | None) -> T_a: if query is None: return [] - search: wavelink.Search = await Pool.fetch_tracks(query) + try: + search: wavelink.Search = await Pool.fetch_tracks(query) + except (LavalinkLoadException, LavalinkException): + return [] + if not search: return [] @@ -233,7 +261,6 @@ async def _search(query: str | None) -> T_a: # track for result in results for track in result... # Maybe itertools here tbh... filtered_r: list[Playable] = [t for r in results for t in r] - random.shuffle(filtered_r) if not filtered_r: logger.debug(f'Player "{self.guild.id}" could not load any songs via AutoPlay.') @@ -244,7 +271,7 @@ async def _search(query: str | None) -> T_a: now._recommended = True self.auto_queue.history.put(now) - await self.play(now) + await self.play(now, add_history=False) # Possibly adjust these thresholds? history: list[Playable] = ( @@ -262,6 +289,7 @@ async def _search(query: str | None) -> T_a: track._recommended = True added += await self.auto_queue.put_wait(track) + random.shuffle(self.auto_queue._queue) logger.debug(f'Player "{self.guild.id}" added "{added}" tracks to the auto_queue via AutoPlay.') @property @@ -400,6 +428,7 @@ async def play( end: int | None = None, volume: int | None = None, paused: bool | None = None, + add_history: bool = True ) -> Playable: """Play the provided :class:`~wavelink.Playable`. @@ -474,7 +503,9 @@ async def play( raise e self._paused = pause - self.queue.history.put(track) + + if add_history: + self.queue.history.put(track) return track From 1704b9de95306a398d049978565bdb4bfdf35981 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 9 Oct 2023 17:30:00 +1000 Subject: [PATCH 060/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 95182751..010483cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b6" +version = "3.0.0b7" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index f18e8485..e0a12623 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b6" +__version__ = "3.0.0b7" from .enums import * From db1ffd3beaf04617140026f550cb204a0ee9cb1e Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 11 Oct 2023 14:55:34 +0100 Subject: [PATCH 061/132] Load balancing of Lavalink Nodes --- wavelink/node.py | 3 ++- wavelink/websocket.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/wavelink/node.py b/wavelink/node.py index ef11a66a..e63ed4d4 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -99,6 +99,7 @@ def __init__( self._session_id: str | None = None self._players: dict[int, Player] = {} + self._total_player_count: int | None = None self._spotify_enabled: bool = False @@ -508,7 +509,7 @@ def get_node(cls, identifier: str | None = None, /) -> Node: if not nodes: raise InvalidNodeException("No nodes are currently assigned to the wavelink.Pool in a CONNECTED state.") - return sorted(nodes, key=lambda n: len(n.players))[0] + return sorted(nodes, key=lambda n: n._total_player_count or len(n.players))[0] @classmethod async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 6945f10d..ae62ee7f 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -163,6 +163,7 @@ async def keep_alive(self) -> None: elif data["op"] == "stats": statspayload: StatsEventPayload = StatsEventPayload(data=data) + self.node._total_player_count = statspayload.players self.dispatch("stats_update", statspayload) elif data["op"] == "event": From 77cca6b67fcb4268308026359bb12f06c4c73640 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:28:03 +0100 Subject: [PATCH 062/132] slicing optimisations --- wavelink/player.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 72ac3ac8..7ba248cf 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -174,7 +174,7 @@ async def _do_recommendation(self): await self.play(track, add_history=False) return - weighted_history: list[Playable] = list(reversed(self.queue.history))[:max(5, 5 * self._auto_weight)] + weighted_history: list[Playable] = self.queue.history[::-1][:max(5, 5 * self._auto_weight)] weighted_upcoming: list[Playable] = self.auto_queue[:max(3, int((5 * self._auto_weight) / 3))] choices: list[Playable | None] = [*weighted_history, *weighted_upcoming, self._current, self._previous] @@ -199,7 +199,7 @@ async def _do_recommendation(self): if changed_by > 0: self._history_count = count - changed_history: list[Playable] = list(reversed(self.queue.history)) + changed_history: list[Playable] = self.queue.history[::-1] added: int = 0 for i in range(changed_by): @@ -275,10 +275,10 @@ async def _search(query: str | None) -> T_a: # Possibly adjust these thresholds? history: list[Playable] = ( - list(self.auto_queue)[0:40] - + list(self.queue)[0:40] - + list(reversed(self.queue.history))[0:40] - + list(reversed(self.auto_queue.history))[0:60] + self.auto_queue[:40] + + self.queue[:40] + + self.queue.history[:-41:-1] + + self.auto_queue.history[:-61:-1] ) added: int = 0 From cc5f025eb36f7ebc6eb9637e56d3bcaed4454b6e Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:29:51 +0100 Subject: [PATCH 063/132] Prevent duplicate track playing on autoplay --- wavelink/player.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 7ba248cf..208e5c29 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -256,11 +256,22 @@ async def _search(query: str | None) -> T_a: return tracks + + # Possibly adjust these thresholds? + # Possibly create a set of history for filtered_r lookup and then reset to empty after? + history: list[Playable] = ( + self.auto_queue[:40] + + self.queue[:40] + + self.queue.history[:-41:-1] + + self.auto_queue.history[:-61:-1] + ) + + results: tuple[T_a, T_a] = await asyncio.gather(_search(spotify_query), _search(youtube_query)) # track for result in results for track in result... # Maybe itertools here tbh... - filtered_r: list[Playable] = [t for r in results for t in r] + filtered_r: list[Playable] = [t for r in results for t in r if t not in history] if not filtered_r: logger.debug(f'Player "{self.guild.id}" could not load any songs via AutoPlay.') @@ -273,13 +284,7 @@ async def _search(query: str | None) -> T_a: await self.play(now, add_history=False) - # Possibly adjust these thresholds? - history: list[Playable] = ( - self.auto_queue[:40] - + self.queue[:40] - + self.queue.history[:-41:-1] - + self.auto_queue.history[:-61:-1] - ) + added: int = 0 for track in filtered_r: From 88a85956f666274680deb67cabb68da7e42468bf Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:55:56 +0100 Subject: [PATCH 064/132] refactor _do_recommendation --- wavelink/player.py | 45 +++++++++++++++------------------------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 208e5c29..5a877901 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -174,8 +174,8 @@ async def _do_recommendation(self): await self.play(track, add_history=False) return - weighted_history: list[Playable] = self.queue.history[::-1][:max(5, 5 * self._auto_weight)] - weighted_upcoming: list[Playable] = self.auto_queue[:max(3, int((5 * self._auto_weight) / 3))] + weighted_history: list[Playable] = self.queue.history[::-1][: max(5, 5 * self._auto_weight)] + weighted_upcoming: list[Playable] = self.auto_queue[: max(3, int((5 * self._auto_weight) / 3))] choices: list[Playable | None] = [*weighted_history, *weighted_upcoming, self._current, self._previous] # Filter out tracks which are None... @@ -190,11 +190,7 @@ async def _do_recommendation(self): youtube_query: str | None = None count: int = len(self.queue.history) - changed_by: int - if self._history_count is None: - changed_by = min(3, count) - else: - changed_by: int = count - self._history_count + changed_by: int = min(3, count) if self._history_count is None else count - self._history_count if changed_by > 0: self._history_count = count @@ -202,11 +198,9 @@ async def _do_recommendation(self): changed_history: list[Playable] = self.queue.history[::-1] added: int = 0 - for i in range(changed_by): - if i == 3: - break - + for i in range(min(changed_by, 3)): track: Playable = changed_history[i] + if added == 2 and track.source == "spotify": break @@ -222,19 +216,12 @@ async def _do_recommendation(self): spotify_query = f"sprec:seed_tracks={','.join(spotify_seeds)}&limit=10" for s_seed in spotify_seeds: - if self.__previous_seeds.full(): - self.__previous_seeds.get_nowait() - - self.__previous_seeds.put_nowait(s_seed) + self._add_to_previous_seeds(s_seed) if youtube: ytm_seed: str = youtube[0] youtube_query = f"https://music.youtube.com/watch?v={ytm_seed}8&list=RD{ytm_seed}" - - if self.__previous_seeds.full(): - self.__previous_seeds.get_nowait() - - self.__previous_seeds.put_nowait(ytm_seed) + self._add_to_previous_seeds(ytm_seed) async def _search(query: str | None) -> T_a: if query is None: @@ -256,21 +243,15 @@ async def _search(query: str | None) -> T_a: return tracks - # Possibly adjust these thresholds? # Possibly create a set of history for filtered_r lookup and then reset to empty after? history: list[Playable] = ( - self.auto_queue[:40] - + self.queue[:40] - + self.queue.history[:-41:-1] - + self.auto_queue.history[:-61:-1] + self.auto_queue[:40] + self.queue[:40] + self.queue.history[:-41:-1] + self.auto_queue.history[:-61:-1] ) - results: tuple[T_a, T_a] = await asyncio.gather(_search(spotify_query), _search(youtube_query)) # track for result in results for track in result... - # Maybe itertools here tbh... filtered_r: list[Playable] = [t for r in results for t in r if t not in history] if not filtered_r: @@ -284,8 +265,6 @@ async def _search(query: str | None) -> T_a: await self.play(now, add_history=False) - - added: int = 0 for track in filtered_r: if track in history: @@ -433,7 +412,7 @@ async def play( end: int | None = None, volume: int | None = None, paused: bool | None = None, - add_history: bool = True + add_history: bool = True, ) -> Playable: """Play the provided :class:`~wavelink.Playable`. @@ -657,3 +636,9 @@ async def _destroy(self) -> None: await self.node._destroy_player(self.guild.id) except LavalinkException: pass + + def _add_to_previous_seeds(self, seed: str) -> None: + """Helper method to manage previous seeds.""" + if self.__previous_seeds.full(): + self.__previous_seeds.get_nowait() + self.__previous_seeds.put_nowait(seed) From 2a86f4b76a605221561516bf0cf7ff7dbd7d7990 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 11 Oct 2023 20:57:09 +0100 Subject: [PATCH 065/132] Version bump --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 010483cf..1561503e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b7" +version = "3.0.0b8" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index e0a12623..44f15950 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b7" +__version__ = "3.0.0b8" from .enums import * From 449f51c78f3eece4ca1210e15d3363534ba39ad2 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Thu, 12 Oct 2023 10:32:03 +0100 Subject: [PATCH 066/132] Update Playable __eq__ and pop --- wavelink/player.py | 15 +++++++-------- wavelink/tracks.py | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 5a877901..8301b8e3 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -243,28 +243,27 @@ async def _search(query: str | None) -> T_a: return tracks - # Possibly adjust these thresholds? - # Possibly create a set of history for filtered_r lookup and then reset to empty after? - history: list[Playable] = ( - self.auto_queue[:40] + self.queue[:40] + self.queue.history[:-41:-1] + self.auto_queue.history[:-61:-1] - ) - results: tuple[T_a, T_a] = await asyncio.gather(_search(spotify_query), _search(youtube_query)) # track for result in results for track in result... - filtered_r: list[Playable] = [t for r in results for t in r if t not in history] + filtered_r: list[Playable] = [t for r in results for t in r] if not filtered_r: logger.debug(f'Player "{self.guild.id}" could not load any songs via AutoPlay.') return if not self._current: - now: Playable = filtered_r.pop(0) + now: Playable = filtered_r.pop(1) now._recommended = True self.auto_queue.history.put(now) await self.play(now, add_history=False) + # Possibly adjust these thresholds? + history: list[Playable] = ( + self.auto_queue[:40] + self.queue[:40] + self.queue.history[:-41:-1] + self.auto_queue.history[:-61:-1] + ) + added: int = 0 for track in filtered_r: if track in history: diff --git a/wavelink/tracks.py b/wavelink/tracks.py index e19d5a98..562829bb 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -105,7 +105,7 @@ def __eq__(self, other: object) -> bool: if not isinstance(other, Playable): return NotImplemented - return self.encoded == other.encoded + return self.encoded == other.encoded or self.identifier == other.identifier @property def encoded(self) -> str: From 3819f5f9d1be432b38102c349d493d53c98004b6 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Thu, 12 Oct 2023 10:32:42 +0100 Subject: [PATCH 067/132] version bump --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1561503e..8da24805 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b8" +version = "3.0.0b9" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 44f15950..86f48b34 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b8" +__version__ = "3.0.0b9" from .enums import * From c9c3e200a3d1147c5c24923277bf482cba3b658e Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 15 Oct 2023 20:53:12 +1000 Subject: [PATCH 068/132] Add position and ping to player. --- wavelink/player.py | 35 ++++++++++++++++++++++++++++++++++- wavelink/websocket.py | 3 +++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/wavelink/player.py b/wavelink/player.py index 8301b8e3..f55495e7 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -26,6 +26,7 @@ import asyncio import logging import random +import time import typing from collections import deque from typing import TYPE_CHECKING, Any, Union @@ -46,7 +47,7 @@ QueueEmpty, ) from .node import Pool -from .payloads import TrackEndEventPayload +from .payloads import PlayerUpdateEventPayload, TrackEndEventPayload from .queue import Queue from .tracks import Playable, Playlist @@ -108,6 +109,10 @@ def __init__( if self.client is MISSING and self.node.client: self.client = self.node.client + self._last_update: int | None = None + self._last_position: int = 0 + self._ping: int = -1 + self._connected: bool = False self._connection_event: asyncio.Event = asyncio.Event() @@ -334,10 +339,38 @@ def paused(self) -> bool: """ return self._paused + @property + def ping(self) -> int: + return self._ping + @property def playing(self) -> bool: return self._connected and self._current is not None + @property + def position(self) -> int: + if self.current is None or not self.playing: + return 0 + + if not self.connected: + return 0 + + if self._last_update is None: + return 0 + + if self.paused: + return self._last_position + + position: int = int((time.monotonic_ns() - self._last_update) / 1000000) + self._last_position + return min(position, self.current.length) + + async def _update_event(self, payload: PlayerUpdateEventPayload) -> None: + # Convert nanoseconds into milliseconds... + self._last_update = time.monotonic_ns() + self._last_position = payload.position + + self._ping = payload.ping + async def on_voice_state_update(self, data: GuildVoiceStatePayload, /) -> None: channel_id = data["channel_id"] diff --git a/wavelink/websocket.py b/wavelink/websocket.py index ae62ee7f..6b96234f 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -161,6 +161,9 @@ async def keep_alive(self) -> None: updatepayload: PlayerUpdateEventPayload = PlayerUpdateEventPayload(player=playerup, state=state) self.dispatch("player_update", updatepayload) + if playerup: + asyncio.create_task(playerup._update_event(updatepayload)) + elif data["op"] == "stats": statspayload: StatsEventPayload = StatsEventPayload(data=data) self.node._total_player_count = statspayload.players From b4146f49600fabed14e198b8b955eb23ba2b0da6 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 15 Oct 2023 20:53:37 +1000 Subject: [PATCH 069/132] Run black --- examples/simple.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index e7fe8dc4..558801a0 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -26,12 +26,12 @@ from typing import cast import discord -import wavelink from discord.ext import commands +import wavelink + class Bot(commands.Bot): - def __init__(self) -> None: intents: discord.Intents = discord.Intents.default() intents.message_content = True @@ -67,10 +67,10 @@ async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload embed.set_image(url=track.artwork) if original and original.recommended: - embed.description += f'\n\n`This track was recommended via {track.source}`' + embed.description += f"\n\n`This track was recommended via {track.source}`" if track.album.name: - embed.add_field(name='Album', value=track.album.name) + embed.add_field(name="Album", value=track.album.name) await player.home.send(embed=embed) From 16992f83870c7f1959e6373d8a052a5975005352 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 15 Oct 2023 20:54:20 +1000 Subject: [PATCH 070/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8da24805..5989fc9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b9" +version = "3.0.0b10" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 86f48b34..70e07cc1 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b9" +__version__ = "3.0.0b10" from .enums import * From 423bb0d7d685c3a706df949c8e166dfd6212b594 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 08:09:56 +1000 Subject: [PATCH 071/132] Add send method. Add resume_timeout. Fix update session method. (Node) --- wavelink/node.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index e63ed4d4..25d9371a 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -26,7 +26,7 @@ import logging import secrets import urllib -from typing import TYPE_CHECKING, Iterable, Union, cast +from typing import TYPE_CHECKING, Any, Iterable, Literal, Union, cast import aiohttp import discord @@ -74,6 +74,9 @@ logger: logging.Logger = logging.getLogger(__name__) +Method = Literal["GET", "POST", "PATCH", "DELETE", "PUT", "OPTIONS"] + + class Node: def __init__( self, @@ -85,6 +88,7 @@ def __init__( heartbeat: float = 15.0, retries: int | None = None, client: discord.Client | None = None, + resume_timeout: int = 60, ) -> None: self._identifier = identifier or secrets.token_urlsafe(12) self._uri = uri.removesuffix("/") @@ -93,6 +97,7 @@ def __init__( self._heartbeat = heartbeat self._retries = retries self._client = client + self._resume_timeout = resume_timeout self._status: NodeStatus = NodeStatus.DISCONNECTED self._has_closed: bool = False @@ -243,6 +248,77 @@ async def _connect(self, *, client: discord.Client | None) -> None: if "spotify" in info["sourceManagers"]: self._spotify_enabled = True + if self._resume_timeout > 0: + udata: UpdateSessionRequest = {"resuming": True, "timeout": self._resume_timeout} + await self._update_session(data=udata) + + async def send( + self, method: Method = "GET", *, path: str, data: Any | None = None, params: dict[str, Any] | None = None + ) -> Any: + """Method for making requests to the Lavalink node. + + .. warning:: + + Usually you wouldn't use this method. Please use the built in methods of :class:`~Node`, :class:`~Pool` + and :class:`~wavelink.Player`, unless you need to send specific plugin data to Lavalink. + + Using this method may have unwanted side effects on your players and/or nodes. + + Parameters + ---------- + method: Optional[str] + The method to use when making this request. Available methods are + "GET", "POST", "PATCH", "PUT", "DELETE" and "OPTIONS". Defaults to "GET". + path: str + The path to make this request to. E.g. "/v4/stats" + data: Any | None + The optional JSON data to send along with your request to Lavalink. This should be a dict[str, Any] + and able to be converted to JSON. + params: Optional[dict[str, Any]] + An optional dict of query parameters to send with your request to Lavalink. If you include your query + parameters in the ``path`` parameter, do not pass them here as well. E.g. {"thing": 1, "other": 2} + would equate to "?thing=1&other=2". + + Returns + ------- + Any + The response from Lavalink which will either be None, a str or JSON. + + Raises + ------ + LavalinkException + An error occurred while making this request to Lavalink. + """ + clean_path: str = path.removesuffix("/") + uri: str = f"{self.uri}/{clean_path}" + + if params is None: + params = {} + + async with self._session.request( + method=method, url=uri, params=params, json=data, headers=self.headers + ) as resp: + if resp.status == 204: + return + + if resp.status >= 300: + exc_data: ErrorResponse = await resp.json() + raise LavalinkException(data=exc_data) + + try: + rdata: Any = await resp.json() + except aiohttp.ContentTypeError: + pass + else: + return rdata + + try: + body: str = await resp.text() + except aiohttp.ClientError: + return + + return body + async def _fetch_players(self) -> list[PlayerResponse]: uri: str = f"{self.uri}/v4/sessions/{self.session_id}/players" @@ -294,7 +370,7 @@ async def _destroy_player(self, guild_id: int, /) -> None: async def _update_session(self, *, data: UpdateSessionRequest) -> UpdateResponse: uri: str = f"{self.uri}/v4/sessions/{self.session_id}" - async with self._session.patch(url=uri, data=data) as resp: + async with self._session.patch(url=uri, json=data, headers=self.headers) as resp: if resp.status == 200: resp_data: UpdateResponse = await resp.json() return resp_data From 2fda5c0524feb48b6f3d068eef1c60e9eaa5804e Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 08:10:19 +1000 Subject: [PATCH 072/132] Add NodeReadyEventPayload --- wavelink/payloads.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/wavelink/payloads.py b/wavelink/payloads.py index f9ee87fe..e6fd4ee8 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -30,6 +30,7 @@ from .enums import DiscordVoiceCloseType if TYPE_CHECKING: + from .node import Node from .player import Player from .tracks import Playable from .types.state import PlayerState @@ -45,9 +46,17 @@ "WebsocketClosedEventPayload", "PlayerUpdateEventPayload", "StatsEventPayload", + "NodeReadyEventPayload", ) +class NodeReadyEventPayload: + def __init__(self, node: Node, resumed: bool, session_id: str) -> None: + self.node = node + self.resumed = resumed + self.session_id = session_id + + class TrackStartEventPayload: def __init__(self, player: Player | None, track: Playable) -> None: self.player = player From ea46e2184f51192c907600d1a0b59659e5e5409e Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 08:12:42 +1000 Subject: [PATCH 073/132] Add documentation to Player. --- wavelink/player.py | 40 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index f55495e7..ca9eb70d 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -341,14 +341,40 @@ def paused(self) -> bool: @property def ping(self) -> int: + """Returns the ping in milliseconds as int between your connected Lavalink Node and Discord (Players Channel). + + Returns ``-1`` if no player update event has been received or the player is not connected. + """ return self._ping @property def playing(self) -> bool: + """Returns whether the :class:`~Player` is currently playing a track and is connected. + + Due to relying on validation from Lavalink, this property may in some cases return ``True`` directly after + skipping/stopping a track, although this is not the case when disconnecting the player. + + This property will return ``True`` in cases where the player is paused *and* has a track loaded. + + .. versionchanged:: 3.0.0 + This property used to be known as the `is_playing()` method. + """ return self._connected and self._current is not None @property def position(self) -> int: + """Returns the position of the currently playing :class:`~wavelink.Playable` in milliseconds. + + This property relies on information updates from Lavalink. + + In cases there is no :class:`~wavelink.Playable` loaded or the player is not connected, + this property will return ``0``. + + This property will return ``0`` if no update has been received from Lavalink. + + .. versionchanged:: 3.0.0 + This property now uses a monotonic clock. + """ if self.current is None or not self.playing: return 0 @@ -469,6 +495,12 @@ async def play( Setting this parameter to ``True`` will pause the player. Setting this parameter to ``False`` will resume the player if it is currently paused. Setting this parameter to ``None`` will not change the status of the player. Defaults to ``None``. + add_history: Optional[bool] + If this argument is set to ``True``, the :class:`~Player` will add this track into the + :class:`wavelink.Queue` history, if loading the track was successful. If ``False`` this track will not be + added to your history. This does not directly affect the ``AutoPlay Queue`` but will alter how ``AutoPlay`` + recommends songs in the future. Defaults to ``True``. + Returns ------- @@ -477,8 +509,10 @@ async def play( .. versionchanged:: 3.0.0 - Added the ``paused`` parameter. ``replace``, ``start``, ``end``, ``volume`` and ``paused`` are now all - keyword-only arguments. + Added the ``paused`` parameter. Parameters ``replace``, ``start``, ``end``, ``volume`` and ``paused`` + are now all keyword-only arguments. + + Added the ``add_history`` keyword-only argument. """ assert self.guild is not None @@ -670,7 +704,7 @@ async def _destroy(self) -> None: pass def _add_to_previous_seeds(self, seed: str) -> None: - """Helper method to manage previous seeds.""" + # Helper method to manage previous seeds. if self.__previous_seeds.full(): self.__previous_seeds.get_nowait() self.__previous_seeds.put_nowait(seed) From 52b1cc2ad47aae4e47e7f3200ffab458a78edee9 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 08:13:07 +1000 Subject: [PATCH 074/132] Add an async delete to Queue. --- wavelink/queue.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/wavelink/queue.py b/wavelink/queue.py index fb23da86..d71b39ee 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -201,3 +201,7 @@ async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomi self._wakeup_next() return added + + async def delete(self, index: int, /) -> None: + async with self._lock: + self._queue.__delitem__(index) From 6c54fbbcbd27a3cbff6a8fcbe79ecdae09d9fe18 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 08:13:35 +1000 Subject: [PATCH 075/132] Use new payload to dispatch node ready events. --- wavelink/websocket.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 6b96234f..e60cbbdd 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -149,10 +149,16 @@ async def keep_alive(self) -> None: data: WebsocketOP = message.json() if data["op"] == "ready": + resumed: bool = data["resumed"] + session_id: str = data["sessionId"] + self.node._status = NodeStatus.CONNECTED - self.node._session_id = data["sessionId"] + self.node._session_id = session_id - self.dispatch("node_ready", self.node) + ready_payload: NodeReadyEventPayload = NodeReadyEventPayload( + node=self.node, resumed=resumed, session_id=session_id + ) + self.dispatch("node_ready", ready_payload) elif data["op"] == "playerUpdate": playerup: Player | None = self.get_player(data["guildId"]) From 4880c11f375879ea7af0518ea7522c9b6504e6d7 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 08:14:50 +1000 Subject: [PATCH 076/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5989fc9b..a0e67242 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b10" +version = "3.0.0b11" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 70e07cc1..d1dd8da3 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b10" +__version__ = "3.0.0b11" from .enums import * From 4a2c853afbde7b34649f4ad259d65df956444bd2 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 20:12:03 +1000 Subject: [PATCH 077/132] Update example simple.py --- examples/simple.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index 558801a0..04ba5506 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -48,8 +48,8 @@ async def setup_hook(self) -> None: async def on_ready(self) -> None: logging.info(f"Logged in: {self.user} | {self.user.id}") - async def on_wavelink_node_ready(self, node: wavelink.Node) -> None: - logging.info(f"Wavelink Node connected: {node!r}") + async def on_wavelink_node_ready(self, payload: wavelink.NodeReadyEventPayload) -> None: + logging.info(f"Wavelink Node connected: {payload.node!r} | Resumed: {payload.resumed}") async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload) -> None: player: wavelink.Player | None = payload.player From 0ed2d74bd6f160d973478b7df8958699c7905f2f Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 22:57:53 +1000 Subject: [PATCH 078/132] Add documentation for wavelink 3 (Beta) --- docs/_static/js/custom.js | 2 +- docs/_static/styles/furo.css | 2 +- docs/conf.py | 79 +++---- docs/ext/spotify.rst | 106 --------- docs/extensions/attributetable.py | 131 +++++------ docs/extensions/details.py | 31 ++- docs/extensions/exception_hierarchy.py | 20 +- docs/extensions/prettyversion.py | 47 ++-- docs/index.rst | 41 +--- docs/installing.rst | 2 +- docs/migrating.rst | 1 + docs/recipes.rst | 280 +--------------------- docs/requirements.txt | 1 - docs/wavelink.rst | 311 +++++++++++++------------ pyproject.toml | 2 +- wavelink/enums.py | 14 ++ wavelink/exceptions.py | 12 + wavelink/node.py | 106 +++++++-- wavelink/payloads.py | 153 ++++++++++++ wavelink/player.py | 52 ++++- wavelink/queue.py | 127 ++++++++++ wavelink/tracks.py | 97 +++++++- 22 files changed, 864 insertions(+), 753 deletions(-) delete mode 100644 docs/ext/spotify.rst create mode 100644 docs/migrating.rst diff --git a/docs/_static/js/custom.js b/docs/_static/js/custom.js index 1250f16c..e3e002a3 100644 --- a/docs/_static/js/custom.js +++ b/docs/_static/js/custom.js @@ -21,4 +21,4 @@ function dynamicallyLoadScript(url) { } -dynamicallyLoadScript('https://kit.fontawesome.com/12146b1c3e.js'); +dynamicallyLoadScript('https://kit.fontawesome.com/12146b1c3e.js'); \ No newline at end of file diff --git a/docs/_static/styles/furo.css b/docs/_static/styles/furo.css index 18a84ea2..26332d03 100644 --- a/docs/_static/styles/furo.css +++ b/docs/_static/styles/furo.css @@ -2363,4 +2363,4 @@ ul.search li { content: "Supported Operations:"; font-weight: bold; padding: 1rem; -} +} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 57622a6a..3c0484cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,9 +15,10 @@ # sys.path.insert(0, os.path.abspath('.')) +import os + # -- Project information ----------------------------------------------------- import re -import os import sys sys.path.insert(0, os.path.abspath(".")) @@ -30,8 +31,8 @@ author = "PythonistaGuild, EvieePy" # The full version, including alpha/beta/rc tags -release = '' -with open('../wavelink/__init__.py') as f: +release = "" +with open("../wavelink/__init__.py") as f: release = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE).group(1) # type: ignore version = release @@ -41,32 +42,18 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'prettyversion', + "prettyversion", "sphinx.ext.autodoc", "sphinx.ext.extlinks", "sphinx.ext.napoleon", "sphinx.ext.intersphinx", - 'details', - 'exception_hierarchy', - 'attributetable', - "sphinxext.opengraph", - 'hoverxref.extension', - 'sphinxcontrib_trio', + "details", + "exception_hierarchy", + "attributetable", + "hoverxref.extension", + "sphinxcontrib_trio", ] -# OpenGraph Meta Tags -ogp_site_name = "Wavelink Documentation" -ogp_image = "https://raw.githubusercontent.com/PythonistaGuild/Wavelink/master/logo.png" -ogp_description = "Documentation for Wavelink, the Powerful Lavalink wrapper for discord.py." -ogp_site_url = "https://wavelink.dev/" -ogp_custom_meta_tags = [ - '', - '' -] -ogp_enable_meta_description = True - # Add any paths that contain templates here, relative to this directory. # templates_path = ["_templates"] @@ -83,21 +70,17 @@ html_theme = "furo" # html_logo = "logo.png" -html_theme_options = { - "sidebar_hide_name": True, - "light_logo": "logo.png", - "dark_logo": "wl_dark.png" -} +html_theme_options = {"sidebar_hide_name": True, "light_logo": "logo.png", "dark_logo": "wl_dark.png"} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". +# so a file named "default.styles" will overwrite the builtin "default.styles". # These folders are copied to the documentation's HTML output html_static_path = ["_static"] # These paths are either relative to html_static_path # or fully qualified paths (eg. https://...) -html_css_files = ["styles/furo.css"] +html_css_files = ["styles/furo.styles"] html_js_files = ["js/custom.js"] napoleon_google_docstring = False @@ -122,35 +105,35 @@ intersphinx_mapping = { "py": ("https://docs.python.org/3", None), - "dpy": ("https://discordpy.readthedocs.io/en/stable/", None) + "dpy": ("https://discordpy.readthedocs.io/en/stable/", None), } extlinks = { - 'wlissue': ('https://github.com/PythonistaGuild/Wavelink/issues/%s', 'GH-%s'), - 'ddocs': ('https://discord.com/developers/docs/%s', None), + "wlissue": ("https://github.com/PythonistaGuild/Wavelink/issues/%s", "GH-%s"), + "ddocs": ("https://discord.com/developers/docs/%s", None), } # Hoverxref Settings... hoverxref_auto_ref = True -hoverxref_intersphinx = ['py', 'dpy'] +hoverxref_intersphinx = ["py", "dpy"] hoverxref_role_types = { - 'hoverxref': 'modal', - 'ref': 'modal', - 'confval': 'tooltip', - 'mod': 'tooltip', - 'class': 'tooltip', - 'attr': 'tooltip', - 'func': 'tooltip', - 'meth': 'tooltip', - 'exc': 'tooltip' + "hoverxref": "modal", + "ref": "modal", + "confval": "tooltip", + "mod": "tooltip", + "class": "tooltip", + "attr": "tooltip", + "func": "tooltip", + "meth": "tooltip", + "exc": "tooltip", } hoverxref_roles = list(hoverxref_role_types.keys()) -hoverxref_domains = ['py'] -hoverxref_default_type = 'tooltip' -hoverxref_tooltip_theme = ['tooltipster-punk', 'tooltipster-shadow', 'tooltipster-shadow-custom'] +hoverxref_domains = ["py"] +hoverxref_default_type = "tooltip" +hoverxref_tooltip_theme = ["tooltipster-punk", "tooltipster-shadow", "tooltipster-shadow-custom"] pygments_style = "sphinx" @@ -161,11 +144,11 @@ def autodoc_skip_member(app, what, name, obj, skip, options): - exclusions = ('__weakref__', '__doc__', '__module__', '__dict__', '__init__') + exclusions = ("__weakref__", "__doc__", "__module__", "__dict__", "__init__") exclude = name in exclusions return True if exclude else None def setup(app): - app.connect('autodoc-skip-member', autodoc_skip_member) + app.connect("autodoc-skip-member", autodoc_skip_member) diff --git a/docs/ext/spotify.rst b/docs/ext/spotify.rst deleted file mode 100644 index 23e5d347..00000000 --- a/docs/ext/spotify.rst +++ /dev/null @@ -1,106 +0,0 @@ -.. currentmodule:: wavelink.ext.spotify - - -Intro ------ -The Spotify extension is a QoL extension that helps in searching for and queueing tracks from Spotify URLs. To get started create a :class:`~SpotifyClient` and pass in your credentials. You then pass this to your :class:`wavelink.Node`'s. - -An example: - -.. code-block:: python3 - - import discord - import wavelink - from discord.ext import commands - from wavelink.ext import spotify - - - class Bot(commands.Bot): - - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True - - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - sc = spotify.SpotifyClient( - client_id='CLIENT_ID', - client_secret='CLIENT_SECRET' - ) - - node: wavelink.Node = wavelink.Node(uri='http://127.0.0.1:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node], spotify=sc) - - - bot = Bot() - - - @bot.command() - @commands.is_owner() - async def play(ctx: commands.Context, *, search: str) -> None: - - try: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - except discord.ClientException: - vc: wavelink.Player = ctx.voice_client - - vc.autoplay = True - - track: spotify.SpotifyTrack = await spotify.SpotifyTrack.search(search) - - if not vc.is_playing(): - await vc.play(track, populate=True) - else: - await vc.queue.put_wait(track) - - -Helpers -------- -.. autofunction:: decode_url - - -Payloads --------- -.. attributetable:: SpotifyDecodePayload - -.. autoclass:: SpotifyDecodePayload - - -Client ------- -.. attributetable:: SpotifyClient - -.. autoclass:: SpotifyClient - :members: - - -Enums ------ -.. attributetable:: SpotifySearchType - -.. autoclass:: SpotifySearchType - :members: - - -Spotify Tracks --------------- -.. attributetable:: SpotifyTrack - -.. autoclass:: SpotifyTrack - :members: - - -Exceptions ----------- -.. py:exception:: SpotifyRequestError - - Base error for Spotify requests. - - status: :class:`int` - The status code returned from the request. - reason: Optional[:class:`str`] - The reason the request failed. Could be ``None``. diff --git a/docs/extensions/attributetable.py b/docs/extensions/attributetable.py index 01dc0881..5b6f41be 100644 --- a/docs/extensions/attributetable.py +++ b/docs/extensions/attributetable.py @@ -1,9 +1,10 @@ from __future__ import annotations + import importlib import inspect import re from enum import Enum -from typing import Dict, List, NamedTuple, Optional, Tuple, Sequence, TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Sequence, Tuple from docutils import nodes from sphinx import addnodes @@ -42,51 +43,51 @@ class attributetable_item(nodes.Part, nodes.Element): def visit_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: - class_ = node['python-class'] + class_ = node["python-class"] self.body.append(f'
') def visit_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: - self.body.append(self.starttag(node, 'div', CLASS='py-attribute-table-column')) + self.body.append(self.starttag(node, "div", CLASS="py-attribute-table-column")) def visit_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: - self.body.append(self.starttag(node, 'span')) + self.body.append(self.starttag(node, "span")) def visit_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: attributes = { - 'class': 'py-attribute-table-badge', - 'title': node['badge-type'], + "class": "py-attribute-table-badge", + "title": node["badge-type"], } - self.body.append(self.starttag(node, 'span', **attributes)) + self.body.append(self.starttag(node, "span", **attributes)) def visit_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: - self.body.append(self.starttag(node, 'li', CLASS='py-attribute-table-entry')) + self.body.append(self.starttag(node, "li", CLASS="py-attribute-table-entry")) def depart_attributetable_node(self: DPYHTML5Translator, node: attributetable) -> None: - self.body.append('
') + self.body.append("") def depart_attributetablecolumn_node(self: DPYHTML5Translator, node: attributetablecolumn) -> None: - self.body.append('') + self.body.append("") def depart_attributetabletitle_node(self: DPYHTML5Translator, node: attributetabletitle) -> None: - self.body.append('') + self.body.append("") def depart_attributetablebadge_node(self: DPYHTML5Translator, node: attributetablebadge) -> None: - self.body.append('') + self.body.append("") def depart_attributetable_item_node(self: DPYHTML5Translator, node: attributetable_item) -> None: - self.body.append('') + self.body.append("") -_name_parser_regex = re.compile(r'(?P[\w.]+\.)?(?P\w+)') +_name_parser_regex = re.compile(r"(?P[\w.]+\.)?(?P\w+)") class PyAttributeTable(SphinxDirective): @@ -102,13 +103,13 @@ def parse_name(self, content: str) -> Tuple[str, str]: raise RuntimeError(f"content {content} somehow doesn't match regex in {self.env.docname}.") path, name = match.groups() if path: - modulename = path.rstrip('.') + modulename = path.rstrip(".") else: - modulename = self.env.temp_data.get('autodoc:module') + modulename = self.env.temp_data.get("autodoc:module") if not modulename: - modulename = self.env.ref_context.get('py:module') + modulename = self.env.ref_context.get("py:module") if modulename is None: - raise RuntimeError(f'modulename somehow None for {content} in {self.env.docname}.') + raise RuntimeError(f"modulename somehow None for {content} in {self.env.docname}.") return modulename, name @@ -140,12 +141,12 @@ def run(self) -> List[attributetableplaceholder]: replaced. """ content = self.arguments[0].strip() - node = attributetableplaceholder('') + node = attributetableplaceholder("") modulename, name = self.parse_name(content) - node['python-doc'] = self.env.docname - node['python-module'] = modulename - node['python-class'] = name - node['python-full-name'] = f'{modulename}.{name}' + node["python-doc"] = self.env.docname + node["python-module"] = modulename + node["python-class"] = name + node["python-full-name"] = f"{modulename}.{name}" return [node] @@ -153,20 +154,20 @@ def build_lookup_table(env: BuildEnvironment) -> Dict[str, List[str]]: # Given an environment, load up a lookup table of # full-class-name: objects result = {} - domain = env.domains['py'] + domain = env.domains["py"] ignored = { - 'data', - 'exception', - 'module', - 'class', + "data", + "exception", + "module", + "class", } for fullname, _, objtype, docname, _, _ in domain.get_objects(): if objtype in ignored: continue - classname, _, child = fullname.rpartition('.') + classname, _, child = fullname.rpartition(".") try: result[classname].append(child) except KeyError: @@ -186,15 +187,15 @@ def process_attributetable(app: Sphinx, doctree: nodes.Node, fromdocname: str) - lookup = build_lookup_table(env) for node in doctree.traverse(attributetableplaceholder): - modulename, classname, fullname = node['python-module'], node['python-class'], node['python-full-name'] + modulename, classname, fullname = node["python-module"], node["python-class"], node["python-full-name"] groups = get_class_results(lookup, modulename, classname, fullname) - table = attributetable('') + table = attributetable("") for label, subitems in groups.items(): if not subitems: continue table.append(class_results_to_node(label, sorted(subitems, key=lambda c: c.label))) - table['python-class'] = fullname + table["python-class"] = fullname if not table: node.replace_self([]) @@ -209,8 +210,8 @@ def get_class_results( cls = getattr(module, name) groups: Dict[str, List[TableElement]] = { - _('Attributes'): [], - _('Methods'): [], + _("Attributes"): [], + _("Methods"): [], } try: @@ -219,11 +220,11 @@ def get_class_results( return groups for attr in members: - if attr.startswith('__') and attr.endswith('__'): + if attr.startswith("__") and attr.endswith("__"): continue - attrlookup = f'{fullname}.{attr}' - key = _('Attributes') + attrlookup = f"{fullname}.{attr}" + key = _("Attributes") badge = None label = attr value = None @@ -234,31 +235,31 @@ def get_class_results( break if value is not None: - doc = value.__doc__ or '' + doc = value.__doc__ or "" - if inspect.iscoroutinefunction(value) or doc.startswith('|coro|'): - key = _('Methods') - badge = attributetablebadge('async', 'async') - badge['badge-type'] = _('coroutine') + if inspect.iscoroutinefunction(value) or doc.startswith("|coro|"): + key = _("Methods") + badge = attributetablebadge("async", "async") + badge["badge-type"] = _("coroutine") elif isinstance(value, classmethod): - key = _('Methods') - label = f'{name}.{attr}' - badge = attributetablebadge('cls', 'cls') - badge['badge-type'] = _('classmethod') + key = _("Methods") + label = f"{name}.{attr}" + badge = attributetablebadge("cls", "cls") + badge["badge-type"] = _("classmethod") elif inspect.isfunction(value): - if doc.startswith(('A decorator', 'A shortcut decorator')): + if doc.startswith(("A decorator", "A shortcut decorator")): # finicky but surprisingly consistent - key = _('Methods') - badge = attributetablebadge('@', '@') - badge['badge-type'] = _('decorator') + key = _("Methods") + badge = attributetablebadge("@", "@") + badge["badge-type"] = _("decorator") elif inspect.isasyncgenfunction(value): - key = _('Methods') - badge = attributetablebadge('async for', 'async for') - badge['badge-type'] = _('async iterable') + key = _("Methods") + badge = attributetablebadge("async for", "async for") + badge["badge-type"] = _("async iterable") else: - key = _('Methods') - badge = attributetablebadge('def', 'def') - badge['badge-type'] = _('method') + key = _("Methods") + badge = attributetablebadge("def", "def") + badge["badge-type"] = _("method") groups[key].append(TableElement(fullname=attrlookup, label=label, badge=badge)) @@ -267,27 +268,27 @@ def get_class_results( def class_results_to_node(key: str, elements: Sequence[TableElement]) -> attributetablecolumn: title = attributetabletitle(key, key) - ul = nodes.bullet_list('') + ul = nodes.bullet_list("") for element in elements: ref = nodes.reference( - '', '', internal=True, refuri=f'#{element.fullname}', anchorname='', *[nodes.Text(element.label)] + "", "", internal=True, refuri=f"#{element.fullname}", anchorname="", *[nodes.Text(element.label)] ) - para = addnodes.compact_paragraph('', '', ref) + para = addnodes.compact_paragraph("", "", ref) if element.badge is not None: - ul.append(attributetable_item('', element.badge, para)) + ul.append(attributetable_item("", element.badge, para)) else: - ul.append(attributetable_item('', para)) + ul.append(attributetable_item("", para)) - return attributetablecolumn('', title, ul) + return attributetablecolumn("", title, ul) def setup(app: Sphinx): - app.add_directive('attributetable', PyAttributeTable) + app.add_directive("attributetable", PyAttributeTable) app.add_node(attributetable, html=(visit_attributetable_node, depart_attributetable_node)) app.add_node(attributetablecolumn, html=(visit_attributetablecolumn_node, depart_attributetablecolumn_node)) app.add_node(attributetabletitle, html=(visit_attributetabletitle_node, depart_attributetabletitle_node)) app.add_node(attributetablebadge, html=(visit_attributetablebadge_node, depart_attributetablebadge_node)) app.add_node(attributetable_item, html=(visit_attributetable_item_node, depart_attributetable_item_node)) app.add_node(attributetableplaceholder) - app.connect('doctree-resolved', process_attributetable) - return {'parallel_read_safe': True} \ No newline at end of file + app.connect("doctree-resolved", process_attributetable) + return {"parallel_read_safe": True} diff --git a/docs/extensions/details.py b/docs/extensions/details.py index b1a56096..18a79193 100644 --- a/docs/extensions/details.py +++ b/docs/extensions/details.py @@ -1,34 +1,40 @@ -from docutils.parsers.rst import Directive -from docutils.parsers.rst import states, directives -from docutils.parsers.rst.roles import set_classes from docutils import nodes +from docutils.parsers.rst import Directive, directives, states +from docutils.parsers.rst.roles import set_classes + class details(nodes.General, nodes.Element): pass + class summary(nodes.General, nodes.Element): pass + def visit_details_node(self, node): - self.body.append(self.starttag(node, 'details', CLASS=node.attributes.get('class', ''))) + self.body.append(self.starttag(node, "details", CLASS=node.attributes.get("class", ""))) + def visit_summary_node(self, node): - self.body.append(self.starttag(node, 'summary', CLASS=node.attributes.get('summary-class', ''))) + self.body.append(self.starttag(node, "summary", CLASS=node.attributes.get("summary-class", ""))) self.body.append(node.rawsource) + def depart_details_node(self, node): - self.body.append('\n') + self.body.append("\n") + def depart_summary_node(self, node): - self.body.append('') + self.body.append("") + class DetailsDirective(Directive): final_argument_whitespace = True optional_arguments = 1 option_spec = { - 'class': directives.class_option, - 'summary-class': directives.class_option, + "class": directives.class_option, + "summary-class": directives.class_option, } has_content = True @@ -37,7 +43,7 @@ def run(self): set_classes(self.options) self.assert_has_content() - text = '\n'.join(self.content) + text = "\n".join(self.content) node = details(text, **self.options) if self.arguments: @@ -48,8 +54,9 @@ def run(self): self.state.nested_parse(self.content, self.content_offset, node) return [node] + def setup(app): app.add_node(details, html=(visit_details_node, depart_details_node)) app.add_node(summary, html=(visit_summary_node, depart_summary_node)) - app.add_directive('details', DetailsDirective) - return {'parallel_read_safe': True} \ No newline at end of file + app.add_directive("details", DetailsDirective) + return {"parallel_read_safe": True} diff --git a/docs/extensions/exception_hierarchy.py b/docs/extensions/exception_hierarchy.py index 974de2df..988eeb7d 100644 --- a/docs/extensions/exception_hierarchy.py +++ b/docs/extensions/exception_hierarchy.py @@ -1,28 +1,32 @@ -from docutils.parsers.rst import Directive -from docutils.parsers.rst import states, directives -from docutils.parsers.rst.roles import set_classes from docutils import nodes +from docutils.parsers.rst import Directive, directives, states +from docutils.parsers.rst.roles import set_classes from sphinx.locale import _ + class exception_hierarchy(nodes.General, nodes.Element): pass + def visit_exception_hierarchy_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='exception-hierarchy-content')) + self.body.append(self.starttag(node, "div", CLASS="exception-hierarchy-content")) + def depart_exception_hierarchy_node(self, node): - self.body.append('\n') + self.body.append("\n") + class ExceptionHierarchyDirective(Directive): has_content = True def run(self): self.assert_has_content() - node = exception_hierarchy('\n'.join(self.content)) + node = exception_hierarchy("\n".join(self.content)) self.state.nested_parse(self.content, self.content_offset, node) return [node] + def setup(app): app.add_node(exception_hierarchy, html=(visit_exception_hierarchy_node, depart_exception_hierarchy_node)) - app.add_directive('exception_hierarchy', ExceptionHierarchyDirective) - return {'parallel_read_safe': True} \ No newline at end of file + app.add_directive("exception_hierarchy", ExceptionHierarchyDirective) + return {"parallel_read_safe": True} diff --git a/docs/extensions/prettyversion.py b/docs/extensions/prettyversion.py index fd3b4763..4ffb195b 100644 --- a/docs/extensions/prettyversion.py +++ b/docs/extensions/prettyversion.py @@ -1,8 +1,7 @@ -from docutils.statemachine import StringList -from docutils.parsers.rst import Directive -from docutils.parsers.rst import states, directives -from docutils.parsers.rst.roles import set_classes from docutils import nodes +from docutils.parsers.rst import Directive, directives, states +from docutils.parsers.rst.roles import set_classes +from docutils.statemachine import StringList from sphinx.locale import _ @@ -19,25 +18,25 @@ class pretty_version_removed(nodes.General, nodes.Element): def visit_pretty_version_added_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='pretty-version pv-added')) - self.body.append(self.starttag(node, 'i', CLASS='fa-solid fa-circle-plus')) - self.body.append('') + self.body.append(self.starttag(node, "div", CLASS="pretty-version pv-added")) + self.body.append(self.starttag(node, "i", CLASS="fa-solid fa-circle-plus")) + self.body.append("") def visit_pretty_version_changed_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='pretty-version pv-changed')) - self.body.append(self.starttag(node, 'i', CLASS='fa-solid fa-wrench')) - self.body.append('') + self.body.append(self.starttag(node, "div", CLASS="pretty-version pv-changed")) + self.body.append(self.starttag(node, "i", CLASS="fa-solid fa-wrench")) + self.body.append("") def visit_pretty_version_removed_node(self, node): - self.body.append(self.starttag(node, 'div', CLASS='pretty-version pv-removed')) - self.body.append(self.starttag(node, 'i', CLASS='fa-solid fa-trash')) - self.body.append('') + self.body.append(self.starttag(node, "div", CLASS="pretty-version pv-removed")) + self.body.append(self.starttag(node, "i", CLASS="fa-solid fa-trash")) + self.body.append("") def depart_pretty_version_node(self, node): - self.body.append('\n') + self.body.append("\n") class PrettyVersionAddedDirective(Directive): @@ -47,10 +46,10 @@ class PrettyVersionAddedDirective(Directive): def run(self): version = self.arguments[0] - joined = '\n'.join(self.content) if self.content else '' + joined = "\n".join(self.content) if self.content else "" content = [f'**New in version:** *{version}*{" - " if joined else ""}', joined] - node = pretty_version_added('\n'.join(content)) + node = pretty_version_added("\n".join(content)) self.state.nested_parse(StringList(content), self.content_offset, node) return [node] @@ -63,10 +62,10 @@ class PrettyVersionChangedDirective(Directive): def run(self): version = self.arguments[0] - joined = '\n'.join(self.content) if self.content else '' + joined = "\n".join(self.content) if self.content else "" content = [f'**Version changed:** *{version}*{" - " if joined else ""}', joined] - node = pretty_version_changed('\n'.join(content)) + node = pretty_version_changed("\n".join(content)) self.state.nested_parse(StringList(content), self.content_offset, node) return [node] @@ -79,10 +78,10 @@ class PrettyVersionRemovedDirective(Directive): def run(self): version = self.arguments[0] - joined = '\n'.join(self.content) + joined = "\n".join(self.content) content = [f'**Removed in version:** *{version}*{" - " if joined else ""}', joined] - node = pretty_version_removed('\n'.join(content)) + node = pretty_version_removed("\n".join(content)) self.state.nested_parse(StringList(content), self.content_offset, node) return [node] @@ -93,8 +92,8 @@ def setup(app): app.add_node(pretty_version_changed, html=(visit_pretty_version_changed_node, depart_pretty_version_node)) app.add_node(pretty_version_removed, html=(visit_pretty_version_removed_node, depart_pretty_version_node)) - app.add_directive('versionadded', PrettyVersionAddedDirective, override=True) - app.add_directive('versionchanged', PrettyVersionChangedDirective, override=True) - app.add_directive('versionremoved', PrettyVersionRemovedDirective, override=True) + app.add_directive("versionadded", PrettyVersionAddedDirective, override=True) + app.add_directive("versionchanged", PrettyVersionChangedDirective, override=True) + app.add_directive("versionremoved", PrettyVersionRemovedDirective, override=True) - return {'parallel_read_safe': True} + return {"parallel_read_safe": True} diff --git a/docs/index.rst b/docs/index.rst index 25b656cf..1a1cbabd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,15 +1,3 @@ -:og:description: Wavelink is a robust and powerful Lavalink wrapper for Discord.py. Featuring, a fully asynchronous API that's intuitive and easy to use with built in Spotify Support, Node Pool Balancing, advanced Queues, autoplay feature and looping features built in. -:og:title: Wavelink Documentation - - -.. meta:: - :title: Wavelink Documentation - :description: Wavelink is a robust and powerful Lavalink wrapper for Discord.py. Featuring, a fully asynchronous API that's intuitive and easy to use with built in Spotify Support, Node Pool Balancing, advanced Queues, autoplay feature and looping features built in. - :language: en-US - :keywords: wavelink, lavalink, python api - :copyright: PythonistaGuild. 2019 - Present - - .. raw:: html @@ -23,10 +11,16 @@

Featuring:

  • Full asynchronous design.
  • -
  • Lavalink v3.7+ Supported with REST API.
  • +
  • Lavalink v4+ Supported with REST API.
  • discord.py v2.0.0+ Support.
  • -
  • Spotify and YouTube AutoPlay supported.
  • -
  • Object orientated design with stateful objects.
  • +
  • Advanced AutoPlay and track recommendations for continuous play.
  • +
  • Object orientated design with stateful objects and payloads.
  • +
  • Fully annotated and complies with Pyright strict typing.
  • +
+ +

Migrating from version 2 to 3:

+

@@ -51,13 +45,6 @@
  • API Reference
  • - -
    - Wavelink Extension API's: - -

    Getting Help

    @@ -74,6 +61,7 @@ :caption: Getting Started: installing + migrating recipes @@ -85,15 +73,6 @@ wavelink -.. rst-class:: index-display-none -.. toctree:: - :maxdepth: 1 - :caption: Spotify Extension - - ext/spotify - - - .. raw:: html

    Table of Contents

    diff --git a/docs/installing.rst b/docs/installing.rst index 3ab7f711..8414192f 100644 --- a/docs/installing.rst +++ b/docs/installing.rst @@ -1,6 +1,6 @@ Installing ============ -WaveLink requires Python 3.10+. +WaveLink **3** requires Python 3.10+. You can download the latest version of Python `here `_. **Windows:** diff --git a/docs/migrating.rst b/docs/migrating.rst new file mode 100644 index 00000000..d4edfd02 --- /dev/null +++ b/docs/migrating.rst @@ -0,0 +1 @@ +Coming Soon... \ No newline at end of file diff --git a/docs/recipes.rst b/docs/recipes.rst index 69f2aa97..fdf702fb 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -1,279 +1 @@ -Recipes and Examples -============================= -Below are common short examples and recipes for use with WaveLink 2. -This is not an exhaustive list, for more detailed examples, see: `GitHub Examples `_ - - -Listening to Events -------------------- -WaveLink 2 makes use of the built in event dispatcher of Discord.py. -This means you can listen to WaveLink events the same way you listen to discord.py events. - -All WaveLink events are prefixed with ``on_wavelink_`` - - -**Outside of a Cog:** - -.. code:: python3 - - @bot.event - async def on_wavelink_node_ready(node: Node) -> None: - print(f"Node {node.id} is ready!") - - -**Inside a Cog:** - -.. code:: python3 - - @commands.Cog.listener() - async def on_wavelink_node_ready(self, node: Node) -> None: - print(f"Node {node.id} is ready!") - - - -Creating and using Nodes ------------------------- -Wavelink 2 has a more intuitive way of creating and storing Nodes. -Nodes are now stored at class level in a `NodePool`. Once a node has been created, you can access that node anywhere that -wavelink can be imported. - - -**Creating a Node:** - -.. code:: python3 - - # Creating a node is as simple as this... - # The node will be automatically stored to the global NodePool... - # You can create as many nodes as you like, most people only need 1... - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=bot, nodes=[node]) - - -**Accessing the best Node from the NodePool:** - -.. code:: python3 - - # Accessing a Node is easy... - node = wavelink.NodePool.get_node() - - -**Accessing a node by identifier from the NodePool:** - -.. code:: python3 - - node = wavelink.NodePool.get_node(id="MY_NODE_ID") - - -**Accessing a list of Players a Node contains:** - -.. code:: python3 - - # A mapping of Guild ID to Player. - node = wavelink.NodePool.get_node() - print(node.players) - - -**Attaching Spotify support to a Node:** - -.. code:: python3 - - from wavelink.ext import spotify - - - sc = spotify.SpotifyClient( - client_id='CLIENT_ID', - client_secret='SECRET' - ) - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node], spotify=sc) - - -Searching Tracks ----------------- -Below are some common recipes for searching tracks. - - -**A Simple YouTube search:** - -.. code:: python3 - - tracks = await wavelink.YouTubeTrack.search("Ocean Drive - Duke Dumont") - if not tracks: - # No tracks were found, do something here... - return - - track = tracks[0] - - -**As a Discord.py converter:** - -.. code:: python3 - - @commands.command() - async def play(self, ctx: commands.Context, *, track: wavelink.YouTubeTrack): - # The track will be the first result from what you searched when invoking the command... - ... - - -Creating Players and VoiceProtocol ----------------------------------- -Below are some common examples of how to use the new VoiceProtocol with WaveLink. - - -**A Simple Player:** - -.. code:: python3 - - import discord - import wavelink - - from discord.ext import commands - - - @commands.command() - async def connect(self, ctx: commands.Context, *, channel: discord.VoiceChannel | None = None): - try: - channel = channel or ctx.author.channel.voice - except AttributeError: - return await ctx.send('No voice channel to connect to. Please either provide one or join one.') - - # vc is short for voice client... - # Our "vc" will be our wavelink.Player as type-hinted below... - # wavelink.Player is also a VoiceProtocol... - - vc: wavelink.Player = await channel.connect(cls=wavelink.Player) - return vc - - -**A custom Player setup:** - -.. code:: python3 - - import discord - import wavelink - from typing import Any - - from discord.ext import commands - - - class Player(wavelink.Player): - """A Player with a DJ attribute.""" - - def __init__(self, dj: discord.Member, *args: Any, **kwargs: Any): - super().__init__(*args, **kwargs) - self.dj = dj - - - @commands.command() - async def connect(self, ctx: commands.Context, *, channel: discord.VoiceChannel | None = None): - try: - channel = channel or ctx.author.channel.voice - except AttributeError: - return await ctx.send('No voice channel to connect to. Please either provide one or join one.') - - # vc is short for voice client... - # Our "vc" will be our Player as type hinted below... - # Player is also a VoiceProtocol... - - player = Player(dj=ctx.author) - vc: Player = await channel.connect(cls=player) - - return vc - - -**Accessing the Player(VoiceProtocol) (with ctx or guild):** - -.. code:: python3 - - @commands.command() - async def play(self, ctx: commands.Context, *, track: wavelink.YouTubeTrack): - vc: wavelink.Player = ctx.voice_client - - if not vc: - # Call a connect command or similar that returns a vc... - vc = ... - - # You can also access player from anywhere you have guild... - vc = ctx.guild.voice_client - - -**Accessing a Player from your Node:** - -.. code:: python3 - - # Could return None, if the Player was not found... - - node = wavelink.NodePool.get_node() - player = node.get_player(ctx.guild.id) - - -Common Operations ------------------ -Below are some common operations used with WaveLink. -See the documentation for more info. - -.. code:: python3 - - # Play a track... - await player.play(track) - - # Turn AutoPlay on... - player.autoplay = True - - # Similarly turn AutoPlay off... - player.autoplay = False - - # Pause the current song... - await player.pause() - - # Resume the current song from pause state... - await player.resume() - - # Stop the current song from playing... - await player.stop() - - # Stop the current song from playing and disconnect and cleanup the player... - await player.disconnect() - - # Move the player to another channel... - await player.move_to(channel) - - # Set the player volume... - await player.set_volume(30) - - # Seek the currently playing song (position is an integer of milliseconds)... - await player.seek(position) - - # Check if the player is playing... - player.is_playing() - - # Check if the player is paused... - player.is_paused() - - # Check of the player is connected... - player.is_connected() - - # Get the best connected node... - node = wavelink.NodePool.get_connected_node() - - # Shuffle the player queue... - player.queue.shuffle() - - # Turn on singular track looping... - player.queue.loop = True - - # Turn on multi track looping... - player.queue.loop_all = True - - # Common node properties... - node.uri - node.id - node.players - node.status - - # Common player properties... - player.queue # The players inbuilt queue... - player.guild # The guild associated with the player... - player.current # The currently playing song... - player.position # The currently playing songs position in milliseconds... - player.ping # The ping of this current player... \ No newline at end of file +Coming soon... \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt index 9138c6ac..a5321f9e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -6,6 +6,5 @@ sphinxcontrib-websupport Pygments furo discord.py>=2.0.1 -sphinxext-opengraph sphinx-hoverxref sphinxcontrib_trio \ No newline at end of file diff --git a/docs/wavelink.rst b/docs/wavelink.rst index 608904af..684c4f04 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -3,13 +3,9 @@ API Reference ------------- -The wavelink API Reference. - +The wavelink 3 API Reference. This section outlines the API and all it's components within wavelink. -Wavelink is a robust and powerful Lavalink wrapper for Discord.py. Featuring, -a fully asynchronous API that's intuitive and easy to use with built in Spotify Support, Node Pool Balancing, -advanced Queues, autoplay feature and looping features built in. Event Reference --------------- @@ -17,235 +13,239 @@ Event Reference WaveLink Events are events dispatched when certain events happen in Lavalink and Wavelink. All events must be coroutines. -Events are dispatched via discord.py and as such can be used with listener syntax. -All Track Events receive the :class:`payloads.TrackEventPayload` payload. +Events are dispatched via discord.py and as such can be used with discord.py listener syntax. +All Track Events receive the appropriate payload. + **For example:** -An event listener in a cog... +An event listener in a cog. .. code-block:: python3 @commands.Cog.listener() - async def on_wavelink_node_ready(node: Node) -> None: - print(f"Node {node.id} is ready!") + async def on_wavelink_node_ready(self, payload: wavelink.NodeReadyEventPayload) -> None: + print(f"Node {payload.node!r} is ready!") -.. function:: on_wavelink_node_ready(node: Node) +.. function:: on_wavelink_node_ready(payload: wavelink.NodeReadyEventPayload) Called when the Node you are connecting to has initialised and successfully connected to Lavalink. + This event can be called many times throughout your bots lifetime, as it will be called when Wavelink successfully + reconnects to your node in the event of a disconnect. + +.. function:: on_wavelink_stats_update(payload: wavelink.StatsEventPayload) + + Called when the ``stats`` OP is received by Lavalink. -.. function:: on_wavelink_track_event(payload: TrackEventPayload) +.. function:: on_wavelink_player_update(payload: wavelink.PlayerUpdateEventPayload) - Called when any Track Event occurs. + Called when the ``playerUpdate`` OP is received from Lavalink. + This event contains information about a specific connected player on the node. -.. function:: on_wavelink_track_start(payload: TrackEventPayload) +.. function:: on_wavelink_track_start(payload: wavelink.TrackStartEventPayload) Called when a track starts playing. -.. function:: on_wavelink_track_end(payload: TrackEventPayload) + .. note:: + + It is preferred to use this method when sending feedback about the now playing track etc. + +.. function:: on_wavelink_track_end(payload: wavelink.TrackEndEventPayload) Called when the current track has finished playing. -.. function:: on_wavelink_websocket_closed(payload: WebsocketClosedPayload) + .. warning:: - Called when the websocket to the voice server is closed. + If you are using AutoPlay, please make sure you take this into consideration when using this event. + See: :func:`on_wavelink_track_start` for an event for performing logic when a new track starts playing. +.. function:: on_wavelink_track_exception(payload: wavelink.TrackExceptionEventPayload) -Payloads ---------- -.. attributetable:: TrackEventPayload + called when an exception occurs while playing a track. -.. autoclass:: TrackEventPayload - :members: +.. function:: on_wavelink_track_stuck(payload: wavelink.TrackStuckEventPayload) -.. attributetable:: WebsocketClosedPayload + Called when a track gets stuck while playing. -.. autoclass:: WebsocketClosedPayload - :members: +.. function:: on_wavelink_websocket_closed(payload: wavelink.WebsocketClosedEventPayload) + Called when the websocket to the voice server is closed. -Enums +.. function:: on_wavelink_node_closed(node: wavelink.Node, disconnected: list[wavelink.Player]) + + Called when a node has been closed and cleaned-up. The second parameter ``disconnected`` is a list of + :class:`wavelink.Player` that were connected on this Node and are now disconnected. + + +Types ----- -.. attributetable:: NodeStatus +.. attributetable:: Search -.. autoclass:: NodeStatus - :members: +.. py:class:: Search -.. attributetable:: TrackSource + A type hint used when searching tracks. Used in :meth:`Playable.search` and :meth:`Pool.fetch_tracks` -.. autoclass:: TrackSource - :members: -.. attributetable:: LoadType +Payloads +--------- +.. attributetable:: NodeReadyEventPayload -.. autoclass:: LoadType +.. autoclass:: NodeReadyEventPayload :members: -.. attributetable:: TrackEventType +.. attributetable:: TrackStartEventPayload -.. autoclass:: TrackEventType +.. autoclass:: TrackStartEventPayload :members: -.. attributetable:: DiscordVoiceCloseType +.. attributetable:: TrackEndEventPayload -.. autoclass:: DiscordVoiceCloseType +.. autoclass:: TrackEndEventPayload :members: +.. attributetable:: TrackExceptionEventPayload -Abstract Base Classes ---------------------- -.. attributetable:: wavelink.tracks.Playable - -.. autoclass:: wavelink.tracks.Playable +.. autoclass:: TrackExceptionEventPayload :members: -.. attributetable:: wavelink.tracks.Playlist +.. attributetable:: TrackStuckEventPayload -.. autoclass:: wavelink.tracks.Playlist +.. autoclass:: TrackStuckEventPayload :members: +.. attributetable:: WebsocketClosedEventPayload -NodePool --------- -.. attributetable:: NodePool - -.. autoclass:: NodePool +.. autoclass:: WebsocketClosedEventPayload :members: -Node ----- - -.. attributetable:: Node +.. attributetable:: PlayerUpdateEventPayload -.. autoclass:: Node +.. autoclass:: PlayerUpdateEventPayload :members: -Tracks ------- - -Tracks inherit from :class:`Playable`. Not all fields will be available for each track type. +.. attributetable:: StatsEventPayload -GenericTrack -~~~~~~~~~~~~ +.. autoclass:: StatsEventPayload + :members: -.. attributetable:: GenericTrack +.. attributetable:: StatsEventMemory -.. autoclass:: GenericTrack +.. autoclass:: StatsEventMemory :members: - :inherited-members: - -YouTubeTrack -~~~~~~~~~~~~ -.. attributetable:: YouTubeTrack +.. attributetable:: StatsEventCPU -.. autoclass:: YouTubeTrack +.. autoclass:: StatsEventCPU :members: - :inherited-members: - -YouTubeMusicTrack -~~~~~~~~~~~~~~~~~ -.. attributetable:: YouTubeMusicTrack +.. attributetable:: StatsEventFrames -.. autoclass:: YouTubeMusicTrack +.. autoclass:: StatsEventFrames :members: - :inherited-members: -SoundCloudTrack -~~~~~~~~~~~~~~~ -.. attributetable:: SoundCloudTrack +Enums +----- +.. attributetable:: NodeStatus -.. autoclass:: SoundCloudTrack +.. autoclass:: NodeStatus :members: - :inherited-members: -YouTubePlaylist -~~~~~~~~~~~~~~~ - -.. attributetable:: YouTubePlaylist +.. attributetable:: TrackSource -.. autoclass:: YouTubePlaylist +.. autoclass:: TrackSource :members: - :inherited-members: +.. attributetable:: DiscordVoiceCloseType -Player ------- +.. autoclass:: DiscordVoiceCloseType + :members: -.. attributetable:: Player +.. attributetable:: AutoPlayMode -.. autoclass:: Player +.. autoclass:: AutoPlayMode :members: -Queues ------- - -.. attributetable:: BaseQueue +Pool +-------- +.. attributetable:: Pool -.. autoclass:: BaseQueue +.. autoclass:: Pool :members: -.. attributetable:: Queue +Node +---- -.. autoclass:: Queue +.. attributetable:: Node + +.. autoclass:: Node :members: -Filters -------- +Tracks +------ + +Tracks in wavelink 3 have been simplified. Please read the docs for :class:`Playable` +Additionally the following data classes are provided on every :class:`Playable` -.. attributetable:: Filter +.. attributetable:: Artist -.. autoclass:: Filter +.. autoclass:: Artist :members: -.. attributetable:: Equalizer +.. attributetable:: Album -.. autoclass:: Equalizer +.. autoclass:: Album :members: -.. attributetable:: Karaoke -.. autoclass:: Karaoke - :members: +Playable +~~~~~~~~~~~~ -.. attributetable:: Timescale +.. attributetable:: Playable -.. autoclass:: Timescale +.. autoclass:: Playable :members: -.. attributetable:: Tremolo +Playlists +~~~~~~~~~~~~~~~ + +.. attributetable:: Playlist -.. autoclass:: Tremolo +.. autoclass:: Playlist :members: -.. attributetable:: Vibrato +.. attributetable:: PlaylistInfo -.. autoclass:: Vibrato +.. autoclass:: PlaylistInfo :members: -.. attributetable:: Rotation -.. autoclass:: Rotation - :members: +Player +------ -.. attributetable:: Distortion +.. attributetable:: Player -.. autoclass:: Distortion +.. autoclass:: Player :members: -.. attributetable:: ChannelMix -.. autoclass:: ChannelMix - :members: +Queue +------ -.. attributetable:: LowPass +.. attributetable:: Queue -.. autoclass:: LowPass +.. autoclass:: Queue :members: + :inherited-members: + +Filters +------- + +.. warning:: + + Filters have not yet been implemented, but are planned in the near future. Exceptions @@ -254,51 +254,64 @@ Exceptions .. exception_hierarchy:: - :exc:`~WavelinkException` - - :exc:`~AuthorizationFailed` - - :exc:`~InvalidNode` - - :exc:`~InvalidLavalinkVersion` - - :exc:`~InvalidLavalinkResponse` - - :exc:`~NoTracksError` + - :exc:`~NodeException` + - :exc:`~InvalidClientException` + - :exc:`~AuthorizationFailedException` + - :exc:`~InvalidNodeException` + - :exc:`~LavalinkException` + - :exc:`~LavalinkLoadException` + - :exc:`~InvalidChannelStateException` + - :exc:`~ChannelTimeoutException` - :exc:`~QueueEmpty` - - :exc:`~InvalidChannelStateError` - - :exc:`~InvalidChannelPermissions` .. py:exception:: WavelinkException - Base wavelink exception. + Base wavelink Exception class. + All wavelink exceptions derive from this exception. -.. py:exception:: AuthorizationFailed +.. py:exception:: NodeException - Exception raised when password authorization failed for this Lavalink node. + Error raised when an Unknown or Generic error occurs on a Node. -.. py:exception:: InvalidNode +.. py:exception:: InvalidClientException -.. py:exception:: InvalidLavalinkVersion + Exception raised when an invalid :class:`discord.Client` + is provided while connecting a :class:`wavelink.Node`. - Exception raised when you try to use wavelink 2 with a Lavalink version under 3.7. +.. py:exception:: AuthorizationFailedException -.. py:exception:: InvalidLavalinkResponse + Exception raised when Lavalink fails to authenticate a :class:`~wavelink.Node`, with the provided password. - Exception raised when wavelink receives an invalid response from Lavalink. +.. py:exception:: InvalidNodeException - status: :class:`int` | :class:`None` - The status code. Could be :class:`None`. + Exception raised when a :class:`Node` is tried to be retrieved from the + :class:`Pool` without existing, or the ``Pool`` is empty. -.. py:exception:: NoTracksError +.. py:exception:: LavalinkException - Exception raised when no tracks could be found. + Exception raised when Lavalink returns an invalid response. -.. py:exception:: QueueEmpty + Attributes + ---------- + status: int + The response status code. + reason: str | None + The response reason. Could be ``None`` if no reason was provided. - Exception raised when you try to retrieve from an empty queue. +.. py:exception:: LavalinkLoadException -.. py:exception:: InvalidChannelStateError + Exception raised when loading tracks failed via Lavalink. - Base exception raised when an error occurs trying to connect to a :class:`discord.VoiceChannel`. +.. py:exception:: InvalidChannelStateException -.. py:exception:: InvalidChannelPermissions + Exception raised when a :class:`~wavelink.Player` tries to connect to an invalid channel or + has invalid permissions to use this channel. - Exception raised when the client does not have correct permissions to join the channel. +.. py:exception:: ChannelTimeoutException + + Exception raised when connecting to a voice channel times out. + +.. py:exception:: QueueEmpty - Could also be raised when there are too many users already in a user limited channel. + Exception raised when you try to retrieve from an empty queue via ``.get()``. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a0e67242..b8d839bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,6 @@ line-length = 120 profile = "black" [tool.pyright] -ignore = ["test*.py", "examples/*.py"] +ignore = ["test*.py", "examples/*.py", "docs/*"] typeCheckingMode = "strict" reportPrivateUsage = false diff --git a/wavelink/enums.py b/wavelink/enums.py index eac64774..38647cce 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -111,6 +111,20 @@ class DiscordVoiceCloseType(enum.Enum): class AutoPlayMode(enum.Enum): + """Enum representing the various AutoPlay modes. + + Attributes + ---------- + enabled + When enabled, AutoPlay will work fully autonomously and fill the auto_queue with recommended tracks. + If a song is put into a players standard queue, AutoPlay will use it as a priority. + partial + When partial, AutoPlay will work fully autonomously but **will not** fill the auto_queue with + recommended tracks. + disabled + When disabled, AutoPlay will not do anything automatically. + """ + enabled = 0 partial = 1 disabled = 2 diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py index d7a2799b..e835fa6f 100644 --- a/wavelink/exceptions.py +++ b/wavelink/exceptions.py @@ -95,6 +95,18 @@ def __init__(self, msg: str | None = None, /, *, data: ErrorResponse) -> None: class LavalinkLoadException(WavelinkException): + """Exception raised when an error occurred loading tracks via Lavalink. + + Attributes + ---------- + error: str + The error message from Lavalink. + severity: str + The severity of this error sent via Lavalink. + cause: str + The cause of this error sent via Lavalink. + """ + def __init__(self, msg: str | None = None, /, *, data: LoadedErrorPayload) -> None: self.error: str = data["message"] self.severity: str = data["severity"] diff --git a/wavelink/node.py b/wavelink/node.py index 25d9371a..18de5f67 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -78,6 +78,47 @@ class Node: + """The Node represents a connection to Lavalink. + + The Node is responsible for keeping the websocket alive, resuming session, sending API requests and keeping track + of connected all :class:`~wavelink.Player`. + + .. container:: operations + + .. describe:: node == other + + Equality check to determine whether this Node is equal to another reference of a Node. + + .. describe:: repr(node) + + The official string representation of this Node. + + Parameters + ---------- + identifier: str | None + A unique identifier for this Node. Could be ``None`` to generate a random one on creation. + uri: str + The URL/URI that wavelink will use to connect to Lavalink. Usually this is in the form of something like: + ``http://localhost:2333`` which includes the port. But you could also provide a domain which won't require a + port like ``https://lavalink.example.com`` or a public IP address and port like ``http://111.333.444.55:2333``. + password: str + The password used to connect and authorize this Node. + session: aiohttp.ClientSession | None + An optional :class:`aiohttp.ClientSession` used to connect this Node over websocket and REST. + If ``None``, one will be generated for you. Defaults to ``None``. + heartbeat: Optional[float] + A ``float`` in seconds to ping your websocket keep alive. Usually you would not change this. + retries: int | None + A ``int`` of retries to attempt when connecting or reconnecting this Node. When the retries are exhausted + the Node will be closed and cleaned-up. ``None`` will retry forever. Defaults to ``None``. + client: :class:`discord.Client` | None + The :class:`discord.Client` or subclasses, E.g. ``commands.Bot`` used to connect this Node. If this is *not* + passed you must pass this to :meth:`wavelink.Pool.connect`. + resume_timeout: Optional[int] + The seconds this Node should configure Lavalink for resuming its current session in case of network issues. + If this is ``0`` or below, resuming will be disabled. Defaults to ``60``. + """ + def __init__( self, *, @@ -121,6 +162,12 @@ def __eq__(self, other: object) -> bool: @property def headers(self) -> dict[str, str]: + """A property that returns the headers configured for sending API and websocket requests. + + .. warning:: + + This includes your Node password. Please be vigilant when using this property. + """ assert self.client is not None assert self.client.user is not None @@ -138,6 +185,7 @@ def identifier(self) -> str: .. versionchanged:: 3.0.0 + This property was previously known as ``id``. """ return self._identifier @@ -162,7 +210,7 @@ def players(self) -> dict[int, Player]: @property def client(self) -> discord.Client | None: - """The :class:`discord.Client` associated with this :class:`Node`. + """Returns the :class:`discord.Client` associated with this :class:`Node`. Could be ``None`` if it has not been set yet. @@ -173,7 +221,7 @@ def client(self) -> discord.Client | None: @property def password(self) -> str: - """The password used to connect this :class:`Node` to Lavalink. + """Returns the password used to connect this :class:`Node` to Lavalink. .. versionadded:: 3.0.0 """ @@ -181,7 +229,7 @@ def password(self) -> str: @property def heartbeat(self) -> float: - """The duration in seconds that the :class:`Node` websocket should send a heartbeat. + """Returns the duration in seconds that the :class:`Node` websocket should send a heartbeat. .. versionadded:: 3.0.0 """ @@ -189,7 +237,7 @@ def heartbeat(self) -> float: @property def session_id(self) -> str | None: - """The Lavalink session ID. Could be None if this :class:`Node` has not connected yet. + """Returns the Lavalink session ID. Could be None if this :class:`Node` has not connected yet. .. versionadded:: 3.0.0 """ @@ -205,6 +253,12 @@ async def _pool_closer(self) -> None: await self.close() async def close(self) -> None: + """Method to close this Node and cleanup. + + After this method has finished, the event ``on_wavelink_node_closed`` will be fired. + + This method renders the Node websocket disconnected and disconnects all players. + """ disconnected: list[Player] = [] for player in self._players.values(): @@ -439,9 +493,6 @@ def get_player(self, guild_id: int, /) -> Player | None: guild_id: int The :attr:discord.Guild.id` to retrieve a :class:`~wavelink.Player` for. - .. positional-only:: - / - Returns ------- Optional[:class:`~wavelink.Player`] @@ -452,6 +503,15 @@ def get_player(self, guild_id: int, /) -> Player | None: class Pool: + """The wavelink Pool represents a collection of :class:~wavelink.Node` and helper methods for searching tracks. + + To connect a :class:`~wavelink.Node` please use this Pool. + + .. note:: + + All methods and attributes on this class are class level, not instance. Do not create an instance of this class. + """ + __nodes: dict[str, Node] = {} __cache: LFUCache | None = None @@ -463,12 +523,14 @@ async def connect( Parameters ---------- - * nodes: Iterable[:class:`Node`] The :class:`Node`'s to connect to Lavalink. client: :class:`discord.Client` | None The :class:`discord.Client` to use to connect the :class:`Node`. If the Node already has a client set, this method will **not** override it. Defaults to None. + cache_capacity: int | None + An optional integer of the amount of track searches to cache. This is an experimental mode. + Passing ``None`` will disable this experiment. Defaults to ``None``. Returns ------- @@ -476,8 +538,20 @@ async def connect( A mapping of :attr:`Node.identifier` to :class:`Node` associated with the :class:`Pool`. + Raises + ------ + AuthorizationFailedException + The node password was incorrect. + InvalidClientException + The :class:`discord.Client` passed was not valid. + NodeException + The node failed to connect properly. Please check that your Lavalink version is version 4. + + .. versionchanged:: 3.0.0 + The ``client`` parameter is no longer required. + Added the ``cache_capacity`` parameter. """ for node in nodes: client_ = node.client or client @@ -538,6 +612,13 @@ async def reconnect(cls) -> dict[str, Node]: @classmethod async def close(cls) -> None: + """Close and clean up all :class:`~wavelink.Node` on this Pool. + + This calls :meth:`wavelink.Node.close` on each node. + + + .. versionadded:: 3.0.0 + """ for node in cls.__nodes.values(): await node.close() @@ -547,6 +628,7 @@ def nodes(cls) -> dict[str, Node]: .. versionchanged:: 3.0.0 + This property now returns a copy. """ nodes = cls.__nodes.copy() @@ -563,9 +645,6 @@ def get_node(cls, identifier: str | None = None, /) -> Node: identifier: str | None An optional identifier to retrieve a :class:`Node`. - .. positional-only:: - / - Raises ------ InvalidNodeException @@ -573,6 +652,7 @@ def get_node(cls, identifier: str | None = None, /) -> Node: .. versionchanged:: 3.0.0 + The ``id`` parameter was changed to ``identifier`` and is positional only. """ if identifier: @@ -597,9 +677,6 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: The query to search tracks for. If this is not a URL based search you should provide the appropriate search prefix, E.g. "ytsearch:Rick Roll" - .. positional-only:: - / - Returns ------- list[Playable] | Playlist @@ -613,6 +690,7 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: .. versionchanged:: 3.0.0 + This method was previously known as both ``.get_tracks`` and ``.get_playlist``. This method now searches for both :class:`~wavelink.Playable` and :class:`~wavelink.Playlist` and returns the appropriate type, or an empty list if no results were found. diff --git a/wavelink/payloads.py b/wavelink/payloads.py index e6fd4ee8..c1a4760b 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -47,10 +47,25 @@ "PlayerUpdateEventPayload", "StatsEventPayload", "NodeReadyEventPayload", + "StatsEventMemory", + "StatsEventCPU", + "StatsEventFrames", ) class NodeReadyEventPayload: + """Payload received in the :func:`on_wavelink_node_ready` event. + + Attributes + ---------- + node: :class:`~wavelink.Node` + The node that has connected or reconnected. + resumed: bool + Whether this node was successfully resumed. + session_id: str + The session ID associated with this node. + """ + def __init__(self, node: Node, resumed: bool, session_id: str) -> None: self.node = node self.resumed = resumed @@ -58,6 +73,19 @@ def __init__(self, node: Node, resumed: bool, session_id: str) -> None: class TrackStartEventPayload: + """Payload received in the :func:`on_wavelink_track_start` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + original: :class:`~wavelink.Playable` | None + The original track associated this event. E.g. the track that was passed to :meth:`~wavelink.Player.play` or + inserted into the queue, with all your additional attributes assigned. Could be ``None``. + """ + def __init__(self, player: Player | None, track: Playable) -> None: self.player = player self.track = track @@ -68,6 +96,21 @@ def __init__(self, player: Player | None, track: Playable) -> None: class TrackEndEventPayload: + """Payload received in the :func:`on_wavelink_track_end` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + reason: str + The reason lavalink ended this track. + original: :class:`~wavelink.Playable` | None + The original track associated this event. E.g. the track that was passed to :meth:`~wavelink.Player.play` or + inserted into the queue, with all your additional attributes assigned. Could be ``None``. + """ + def __init__(self, player: Player | None, track: Playable, reason: str) -> None: self.player = player self.track = track @@ -79,6 +122,18 @@ def __init__(self, player: Player | None, track: Playable, reason: str) -> None: class TrackExceptionEventPayload: + """Payload received in the :func:`on_wavelink_track_exception` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + exception: TrackExceptionPayload + The exception data received via Lavalink. + """ + def __init__(self, player: Player | None, track: Playable, exception: TrackExceptionPayload) -> None: self.player = cast(wavelink.Player, player) self.track = track @@ -86,6 +141,18 @@ def __init__(self, player: Player | None, track: Playable, exception: TrackExcep class TrackStuckEventPayload: + """Payload received in the :func:`on_wavelink_track_stuck` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + track: :class:`~wavelink.Playable` + The track received from Lavalink regarding this event. + threshold: int + The Lavalink threshold associated with this event. + """ + def __init__(self, player: Player | None, track: Playable, threshold: int) -> None: self.player = cast(wavelink.Player, player) self.track = track @@ -93,6 +160,20 @@ def __init__(self, player: Player | None, track: Playable, threshold: int) -> No class WebsocketClosedEventPayload: + """Payload received in the :func:`on_wavelink_websocket_closed` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + code: :class:`wavelink.DiscordVoiceCloseType` + The close code enum value. + reason: str + The reason the websocket was closed. + by_remote: bool + ``True`` if discord closed the websocket. ``False`` otherwise. + """ + def __init__(self, player: Player | None, code: int, reason: str, by_remote: bool) -> None: self.player = player self.code: DiscordVoiceCloseType = DiscordVoiceCloseType(code) @@ -101,6 +182,22 @@ def __init__(self, player: Player | None, code: int, reason: str, by_remote: boo class PlayerUpdateEventPayload: + """Payload received in the :func:`on_wavelink_player_update` event. + + Attributes + ---------- + player: :class:`~wavelink.Player` | None + The player associated with this event. Could be None. + time: int + Unix timestamp in milliseconds, when this event fired. + position: int + The position of the currently playing track in milliseconds. + connected: bool + Whether Lavalink is connected to the voice gateway. + ping: int + The ping of the node to the Discord voice server in milliseconds (-1 if not connected). + """ + def __init__(self, player: Player | None, state: PlayerState) -> None: self.player = cast(wavelink.Player, player) self.time: int = state["time"] @@ -110,6 +207,20 @@ def __init__(self, player: Player | None, state: PlayerState) -> None: class StatsEventMemory: + """Represents Memory Stats. + + Attributes + ---------- + free: int + The amount of free memory in bytes. + used: int + The amount of used memory in bytes. + allocated: int + The amount of allocated memory in bytes. + reservable: int + The amount of reservable memory in bytes + """ + def __init__(self, data: MemoryStats) -> None: self.free: int = data["free"] self.used: int = data["used"] @@ -118,6 +229,18 @@ def __init__(self, data: MemoryStats) -> None: class StatsEventCPU: + """Represents CPU Stats. + + Attributes + ---------- + cores: int + The amount of cores the node has. + system_load: float + The system load of the node. + lavalink_load: float + The load of Lavalink on the node. + """ + def __init__(self, data: CPUStats) -> None: self.cores: int = data["cores"] self.system_load: float = data["systemLoad"] @@ -125,6 +248,18 @@ def __init__(self, data: CPUStats) -> None: class StatsEventFrames: + """Represents Frame Stats. + + Attributes + ---------- + sent: int + The amount of frames sent to Discord + nulled: int + The amount of frames that were nulled + deficit: int + The difference between sent frames and the expected amount of frames. + """ + def __init__(self, data: FrameStats) -> None: self.sent: int = data["sent"] self.nulled: int = data["nulled"] @@ -132,6 +267,24 @@ def __init__(self, data: FrameStats) -> None: class StatsEventPayload: + """Payload received in the :func:`on_wavelink_stats_update` event. + + Attributes + ---------- + players: int + The amount of players connected to the node (Lavalink). + playing: int + The amount of players playing a track. + uptime: int + The uptime of the node in milliseconds. + memory: :class:`wavelink.StatsEventMemory` + See Also: :class:`wavelink.StatsEventMemory` + cpu: :class:`wavelink.StatsEventCPU` + See Also: :class:`wavelink.StatsEventCPU` + frames: :class:`wavelink.StatsEventFrames` + See Also: :class:`wavelink.StatsEventFrames` + """ + def __init__(self, data: StatsOP) -> None: self.players: int = data["players"] self.playing: int = data["playingPlayers"] diff --git a/wavelink/player.py b/wavelink/player.py index ca9eb70d..dc74c4cc 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -69,16 +69,16 @@ class Player(discord.VoiceProtocol): - """ + """The Player is a :class:`discord.VoiceProtocol` used to connect your :class:`discord.Client` to a + :class:`discord.VoiceChannel`. + + The player controls the music elements of the bot including playing tracks, the queue, connecting etc. + See Also: The various methods available. - Attributes - ---------- - client: discord.Client - The :class:`discord.Client` associated with this :class:`Player`. - channel: discord.abc.Connectable | None - The currently connected :class:`discord.VoiceChannel`. - Could be None if this :class:`Player` has not been connected or has previously been disconnected. + .. note:: + Since the Player is a :class:`discord.VoiceProtocol`, it is attached to the various ``voice_client`` attributes + in discord.py, including ``guild.voice_client``, ``ctx.voice_client`` and ``interaction.voice_client``. """ channel: VocalGuildChannel @@ -282,6 +282,15 @@ async def _search(query: str | None) -> T_a: @property def autoplay(self) -> AutoPlayMode: + """A property which returns the :class:`wavelink.AutoPlayMode` the player is currently in. + + This property can be set with any :class:`wavelink.AutoPlayMode` enum value. + + + .. versionchanged:: 3.0.0 + + This property now accepts and returns a :class:`wavelink.AutoPlayMode` enum value. + """ return self._autoplay @autoplay.setter @@ -297,6 +306,7 @@ def node(self) -> Node: .. versionchanged:: 3.0.0 + This property was previously known as ``current_node``. """ return self._node @@ -314,6 +324,7 @@ def connected(self) -> bool: """Returns a bool indicating if the player is currently connected to a voice channel. .. versionchanged:: 3.0.0 + This property was previously known as ``is_connected``. """ return self.channel and self._connected @@ -357,6 +368,7 @@ def playing(self) -> bool: This property will return ``True`` in cases where the player is paused *and* has a track loaded. .. versionchanged:: 3.0.0 + This property used to be known as the `is_playing()` method. """ return self._connected and self._current is not None @@ -373,6 +385,7 @@ def position(self) -> int: This property will return ``0`` if no update has been received from Lavalink. .. versionchanged:: 3.0.0 + This property now uses a monotonic clock. """ if self.current is None or not self.playing: @@ -443,6 +456,23 @@ async def _dispatch_voice_update(self) -> None: async def connect( self, *, timeout: float = 5.0, reconnect: bool, self_deaf: bool = False, self_mute: bool = False ) -> None: + """ + + .. warning:: + + Do not use this method directly on the player. See: :meth:`discord.VoiceChannel.connect` for more details. + + + Pass the :class:`wavelink.Player` to ``cls=`` in :meth:`discord.VoiceChannel.connect`. + + + Raises + ------ + ChannelTimeoutException + Connecting to the voice channel timed out. + InvalidChannelStateException + You tried to connect this player without an appropriate voice channel. + """ if self.channel is MISSING: msg: str = 'Please use "discord.VoiceChannel.connect(cls=...)" and pass this Player to cls.' raise InvalidChannelStateException(f"Player tried to connect without a valid channel: {msg}") @@ -509,6 +539,7 @@ async def play( .. versionchanged:: 3.0.0 + Added the ``paused`` parameter. Parameters ``replace``, ``start``, ``end``, ``volume`` and ``paused`` are now all keyword-only arguments. @@ -570,6 +601,7 @@ async def pause(self, value: bool, /) -> None: .. versionchanged:: 3.0.0 + This method now expects a positional-only bool value. The ``resume`` method has been removed. """ assert self.guild is not None @@ -590,6 +622,7 @@ async def seek(self, position: int = 0, /) -> None: .. versionchanged:: 3.0.0 + The ``position`` parameter is now positional-only, and has a default of 0. """ assert self.guild is not None @@ -616,6 +649,7 @@ async def set_volume(self, value: int = 100, /) -> None: .. versionchanged:: 3.0.0 + The ``value`` parameter is now positional-only, and has a default of 100. """ assert self.guild is not None @@ -651,6 +685,7 @@ async def stop(self, *, force: bool = True) -> Playable | None: See Also: :meth:`skip` for more information. .. versionchanged:: 3.0.0 + This method is now known as ``skip``, but the alias ``stop`` has been kept for backwards compatability. """ return await self.skip(force=force) @@ -671,6 +706,7 @@ async def skip(self, *, force: bool = True) -> Playable | None: .. versionchanged:: 3.0.0 + This method was previously known as ``stop``. To avoid confusion this method is now known as ``skip``. This method now returns the :class:`~wavelink.Playable` that was skipped. """ diff --git a/wavelink/queue.py b/wavelink/queue.py index d71b39ee..2a02d906 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -116,6 +116,49 @@ def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: class Queue(_Queue): + """The default custom wavelink Queue designed specifically for :class:`wavelink.Player`. + + .. container:: operations + + .. describe:: str(queue) + + A string representation of this queue. + + .. describe:: repr(queue) + + The official string representation of this queue. + + .. describe:: if queue + + Bool check whether this queue has items or not. + + .. describe:: queue(track) + + Put a track in the queue. + + .. describe:: len(queue) + + The amount of tracks in the queue. + + .. describe:: queue[1] + + Peek at an item in the queue. Does not change the queue. + + .. describe:: for item in queue + + Iterate over the queue. + + .. describe:: if item in queue + + Check whether a specific track is in the queue. + + + Attributes + ---------- + history: :class:`wavelink.Queue` + A queue of tracks that have been added to history. + """ + def __init__(self) -> None: super().__init__() self.history: _Queue = _Queue() @@ -145,9 +188,35 @@ def _get(self) -> Playable: return super()._get() def get(self) -> Playable: + """Retrieve a track from the left side of the queue. E.g. the first. + + This method does not block. + + Returns + ------- + :class:`wavelink.Playable` + The track retrieved from the queue. + + + Raises + ------ + QueueEmpty + The queue was empty when retrieving a track. + """ return self._get() async def get_wait(self) -> Playable: + """This method returns the first :class:`wavelink.Playable` if one is present or + waits indefinitely until one is. + + This method is asynchronous. + + Returns + ------- + :class:`wavelink.Playable` + The track retrieved from the queue. + + """ while not self: loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() waiter: asyncio.Future[None] = loop.create_future() @@ -172,12 +241,52 @@ async def get_wait(self) -> Playable: return self.get() def put(self, item: Playable | Playlist, /, *, atomic: bool = True) -> int: + """Put an item into the end of the queue. + + Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` + + Parameters + ---------- + item: :class:`wavelink.Playable` | :class:`wavelink.Playlist` + The item to enter into the queue. + atomic: bool + Whether the items should be inserted atomically. If set to ``True`` this method won't enter any tracks if + it encounters an error. Defaults to ``True`` + + + Returns + ------- + int + The amount of tracks added to the queue. + """ added: int = super().put(item, atomic=atomic) self._wakeup_next() return added async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomic: bool = True) -> int: + """Put an item or items into the end of the queue asynchronously. + + Accepts a :class:`wavelink.Playable` or :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`] + + .. note:: + + This method implements a lock to preserve insert order. + + Parameters + ---------- + item: :class:`wavelink.Playable` | :class:`wavelink.Playlist` | list[:class:`wavelink.Playable`] + The item or items to enter into the queue. + atomic: bool + Whether the items should be inserted atomically. If set to ``True`` this method won't enter any tracks if + it encounters an error. Defaults to ``True`` + + + Returns + ------- + int + The amount of tracks added to the queue. + """ added: int = 0 async with self._lock: @@ -203,5 +312,23 @@ async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomi return added async def delete(self, index: int, /) -> None: + """Method to delete an item in the queue by index. + + This method is asynchronous an implements/wait for a lock. + + Raises + ------ + IndexError + No track exists at this index. + + + Examples + -------- + + .. code:: python3 + + await queue.delete(1) + # Deletes the track at index 1 (The second track). + """ async with self._lock: self._queue.__delitem__(index) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 562829bb..7cbcbfd7 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -54,18 +54,59 @@ class Album: + """Dataclass representing Album data received via Lavalink. + + Attributes + ---------- + name: str | None + The album name. Could be ``None``. + url: str | None + The album url. Could be ``None``. + """ + def __init__(self, *, data: dict[Any, Any]) -> None: self.name: str | None = data.get("albumName") self.url: str | None = data.get("albumUrl") class Artist: + """Dataclass representing Artist data received via Lavalink. + + Attributes + ---------- + url: str | None + The artist url. Could be ``None``. + artwork: str | None + The artist artwork url. Could be ``None``. + """ + def __init__(self, *, data: dict[Any, Any]) -> None: self.url: str | None = data.get("artistUrl") self.artwork: str | None = data.get("artistArtworkUrl") class Playable: + """The wavelink playable object which represents all tracks in Wavelink 3. + + .. note:: + + You should not construct this class manually. + + .. container:: operations + + .. describe:: str(track) + + The title of this playable. + + .. describe:: repr(track) + + The official string representation of this playable. + + .. describe:: track == other + + Whether this track is equal to another. Checks both the track encoding and identifier. + """ + def __init__(self, data: TrackPayload, *, playlist: PlaylistInfo | None = None) -> None: info: TrackInfoPayload = data["info"] @@ -109,74 +150,100 @@ def __eq__(self, other: object) -> bool: @property def encoded(self) -> str: + """Property returning the encoded track string from Lavalink.""" return self._encoded @property def identifier(self) -> str: + """Property returning the identifier of this track from its source. + + E.g. YouTube ID or Spotify ID. + """ return self._identifier @property def is_seekable(self) -> bool: + """Property returning a bool whether this track can be used in seeking.""" return self._is_seekable @property def author(self) -> str: + """Property returning the name of the author of this track.""" return self._author @property def length(self) -> int: + """Property returning the tracks duration in milliseconds as an int.""" return self._length @property def is_stream(self) -> bool: + """Property returning a bool indicating whether this track is a stream.""" return self._is_stream @property def position(self) -> int: + """Property returning starting position of this track in milliseconds as an int.""" return self._position @property def title(self) -> str: + """Property returning the title/name of this track.""" return self._title @property def uri(self) -> str | None: + """Property returning the URL to this track. Could be ``None``.""" return self._uri @property def artwork(self) -> str | None: + """Property returning the URL of the artwork of this track. Could be ``None``.""" return self._artwork @property def isrc(self) -> str | None: + """Property returning the ISRC (International Standard Recording Code) of this track. Could be ``None``.""" return self._isrc @property def source(self) -> str: + """Property returning the source of this track as a ``str``. + + E.g. "spotify" or "youtube" + """ return self._source @property def album(self) -> Album: + """Property returning album data for this track. See Also: :class:`wavelink.Album`.""" return self._album @property def artist(self) -> Artist: + """Property returning artist data for this track. See Also: :class:`wavelink.Artist`.""" return self._artist @property def preview_url(self) -> str | None: + """Property returning the preview URL for this track. Could be ``None``.""" return self._preview_url @property def is_preview(self) -> bool | None: + """Property returning a bool indicating if this track is a preview. Could be ``None`` if unknown.""" return self._is_preview @property def playlist(self) -> PlaylistInfo | None: + """Property returning a :class:`wavelink.PlaylistInfo`. Could be ``None`` + if this track is not a part of a playlist. + """ return self._playlist @property def recommended(self) -> bool: + """Property returning a bool indicating whether this track was recommended via AutoPlay.""" return self._recommended @classmethod @@ -202,10 +269,6 @@ async def search(cls, query: str, /, *, source: TrackSource | str | None = Track or it's default. If this query **is a URL**, a search prefix will **not** be used. - - .. positional-only:: - / - * source: :class:`TrackSource` | str | None This parameter determines which search prefix to use when searching for tracks. If ``None`` is provided, no prefix will be used, however this behaviour is default regardless of what @@ -260,6 +323,7 @@ async def search(cls, query: str, /, *, source: TrackSource | str | None = Track .. versionchanged:: 3.0.0 + This method has been changed significantly in version ``3.0.0``. This method has been simplified to provide an easier interface for searching tracks. See the above documentation and examples. @@ -440,6 +504,31 @@ def track_extras(self, **attrs: Any) -> None: class PlaylistInfo: + """The wavelink PlaylistInfo container class. + + It contains various information about the playlist but **does not** contain the tracks associated with this + playlist. + + This class is used to provided information about the original :class:`wavelink.Playlist` on tracks. + + Attributes + ---------- + name: str + The name of this playlist. + selected: int + The index of the selected track from Lavalink. + tracks: int + The amount of tracks this playlist originally contained. + type: str | None + An optional ``str`` identifying the type of playlist this is. Only available when a plugin is used. + url: str | None + An optional ``str`` to the URL of this playlist. Only available when a plugin is used. + artwork: str | None + An optional ``str`` to the artwork of this playlist. Only available when a plugin is used. + author: str | None + An optional ``str`` of the author of this playlist. Only available when a plugin is used. + """ + __slots__ = ("name", "selected", "tracks", "type", "url", "artwork", "author") def __init__(self, data: PlaylistPayload) -> None: From 303c955caf11786e27871fdfbf1012e9a25cc595 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 18 Oct 2023 23:04:29 +1000 Subject: [PATCH 079/132] Remove system packages .readthedocs.yml --- .readthedocs.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index d6629d49..b4b329db 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -11,5 +11,4 @@ python: - requirements: requirements.txt - requirements: docs/requirements.txt - method: pip - path: . - system_packages: true \ No newline at end of file + path: . \ No newline at end of file From c250bfcc68954cbe542d3368ca6f45383c587fb7 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:49:06 +0100 Subject: [PATCH 080/132] Add shuffle and clear to Queue Add shuffle and clear to Queue --- wavelink/queue.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/wavelink/queue.py b/wavelink/queue.py index 2a02d906..28578a81 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -26,6 +26,7 @@ import asyncio from collections import deque from typing import Any, Iterator, overload +import random from .exceptions import QueueEmpty from .tracks import * @@ -332,3 +333,34 @@ async def delete(self, index: int, /) -> None: """ async with self._lock: self._queue.__delitem__(index) + + def shuffle(self) -> None: + """Shuffles the queue in place. This does **not** return anything. + + Example + ------- + + .. code:: python3 + + player.queue.shuffle() + # Your queue has now been shuffled... + """ + random.shuffle(self._queue) + + def clear(self) -> None: + """Remove all items from the queue. + + + .. note:: + + This does not reset the queue. + + Example + ------- + + .. code:: python3 + + player.queue.clear() + # Your queue is now empty... + """ + self._queue.clear() From 8618ff3d5b2a3fb8904b5ee8b227551c140b94b5 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:49:32 +0100 Subject: [PATCH 081/132] Version bump Version bump --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b8d839bb..18147189 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b11" +version = "3.0.0b12" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index d1dd8da3..971a5cb7 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b11" +__version__ = "3.0.0b12" from .enums import * From f3cdf7639aa7e43209d450b0b8305900d29939dc Mon Sep 17 00:00:00 2001 From: Ayush <89258126+itzayush69@users.noreply.github.com> Date: Wed, 18 Oct 2023 22:02:29 +0530 Subject: [PATCH 082/132] Fixed typos in the docs (#243) * Fixed typos in the docs * Fixed typos in the docs * The lingering typo --- docs/installing.rst | 2 +- docs/migrating.rst | 5 ++++- docs/recipes.rst | 3 +++ docs/wavelink.rst | 6 +++--- wavelink/node.py | 10 +++++----- wavelink/payloads.py | 10 +++++----- wavelink/tracks.py | 12 ++++++------ 7 files changed, 27 insertions(+), 21 deletions(-) diff --git a/docs/installing.rst b/docs/installing.rst index 8414192f..2ba70b45 100644 --- a/docs/installing.rst +++ b/docs/installing.rst @@ -20,5 +20,5 @@ Debugging --------- Make sure you have the latest version of Python installed, or if you prefer, a Python version of 3.10 or greater. -If you have have any other issues feel free to search for duplicates and then create a new issue on GitHub with as much detail as +If you have any other issues feel free to search for duplicates and then create a new issue on GitHub with as much detail as possible. Including providing the output of pip, your OS details and Python version. \ No newline at end of file diff --git a/docs/migrating.rst b/docs/migrating.rst index d4edfd02..6cb2c037 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -1 +1,4 @@ -Coming Soon... \ No newline at end of file +Migrating +========= + +Coming soon... \ No newline at end of file diff --git a/docs/recipes.rst b/docs/recipes.rst index fdf702fb..ac33c18b 100644 --- a/docs/recipes.rst +++ b/docs/recipes.rst @@ -1 +1,4 @@ +Recipes and Examples +==================== + Coming soon... \ No newline at end of file diff --git a/docs/wavelink.rst b/docs/wavelink.rst index 684c4f04..c2eb156e 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -62,7 +62,7 @@ An event listener in a cog. .. function:: on_wavelink_track_exception(payload: wavelink.TrackExceptionEventPayload) - called when an exception occurs while playing a track. + Called when an exception occurs while playing a track. .. function:: on_wavelink_track_stuck(payload: wavelink.TrackStuckEventPayload) @@ -186,8 +186,8 @@ Node Tracks ------ -Tracks in wavelink 3 have been simplified. Please read the docs for :class:`Playable` -Additionally the following data classes are provided on every :class:`Playable` +Tracks in wavelink 3 have been simplified. Please read the docs for :class:`Playable`. +Additionally the following data classes are provided on every :class:`Playable`. .. attributetable:: Artist diff --git a/wavelink/node.py b/wavelink/node.py index 18de5f67..edf0f63d 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -324,7 +324,7 @@ async def send( The method to use when making this request. Available methods are "GET", "POST", "PATCH", "PUT", "DELETE" and "OPTIONS". Defaults to "GET". path: str - The path to make this request to. E.g. "/v4/stats" + The path to make this request to. E.g. "/v4/stats". data: Any | None The optional JSON data to send along with your request to Lavalink. This should be a dict[str, Any] and able to be converted to JSON. @@ -486,12 +486,12 @@ async def _fetch_version(self) -> str: raise LavalinkException(data=exc_data) def get_player(self, guild_id: int, /) -> Player | None: - """Return a :class:`~wavelink.Player` associated with the provided :attr:discord.Guild.id`. + """Return a :class:`~wavelink.Player` associated with the provided :attr:`discord.Guild.id`. Parameters ---------- guild_id: int - The :attr:discord.Guild.id` to retrieve a :class:`~wavelink.Player` for. + The :attr:`discord.Guild.id` to retrieve a :class:`~wavelink.Player` for. Returns ------- @@ -503,7 +503,7 @@ def get_player(self, guild_id: int, /) -> Player | None: class Pool: - """The wavelink Pool represents a collection of :class:~wavelink.Node` and helper methods for searching tracks. + """The wavelink Pool represents a collection of :class:`~wavelink.Node` and helper methods for searching tracks. To connect a :class:`~wavelink.Node` please use this Pool. @@ -675,7 +675,7 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: ---------- query: str The query to search tracks for. If this is not a URL based search you should provide the appropriate search - prefix, E.g. "ytsearch:Rick Roll" + prefix, e.g. "ytsearch:Rick Roll" Returns ------- diff --git a/wavelink/payloads.py b/wavelink/payloads.py index c1a4760b..272c3cda 100644 --- a/wavelink/payloads.py +++ b/wavelink/payloads.py @@ -105,7 +105,7 @@ class TrackEndEventPayload: track: :class:`~wavelink.Playable` The track received from Lavalink regarding this event. reason: str - The reason lavalink ended this track. + The reason Lavalink ended this track. original: :class:`~wavelink.Playable` | None The original track associated this event. E.g. the track that was passed to :meth:`~wavelink.Player.play` or inserted into the queue, with all your additional attributes assigned. Could be ``None``. @@ -218,7 +218,7 @@ class StatsEventMemory: allocated: int The amount of allocated memory in bytes. reservable: int - The amount of reservable memory in bytes + The amount of reservable memory in bytes. """ def __init__(self, data: MemoryStats) -> None: @@ -234,7 +234,7 @@ class StatsEventCPU: Attributes ---------- cores: int - The amount of cores the node has. + The number of CPU cores available on the node. system_load: float The system load of the node. lavalink_load: float @@ -253,9 +253,9 @@ class StatsEventFrames: Attributes ---------- sent: int - The amount of frames sent to Discord + The amount of frames sent to Discord. nulled: int - The amount of frames that were nulled + The amount of frames that were nulled. deficit: int The difference between sent frames and the expected amount of frames. """ diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 7cbcbfd7..7bb93e1a 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -54,7 +54,7 @@ class Album: - """Dataclass representing Album data received via Lavalink. + """Container class representing Album data received via Lavalink. Attributes ---------- @@ -70,7 +70,7 @@ def __init__(self, *, data: dict[Any, Any]) -> None: class Artist: - """Dataclass representing Artist data received via Lavalink. + """Container class representing Artist data received via Lavalink. Attributes ---------- @@ -86,7 +86,7 @@ def __init__(self, *, data: dict[Any, Any]) -> None: class Playable: - """The wavelink playable object which represents all tracks in Wavelink 3. + """The Wavelink Playable object which represents all tracks in Wavelink 3. .. note:: @@ -210,18 +210,18 @@ def isrc(self) -> str | None: def source(self) -> str: """Property returning the source of this track as a ``str``. - E.g. "spotify" or "youtube" + E.g. "spotify" or "youtube". """ return self._source @property def album(self) -> Album: - """Property returning album data for this track. See Also: :class:`wavelink.Album`.""" + """Property returning album data for this track.""" return self._album @property def artist(self) -> Artist: - """Property returning artist data for this track. See Also: :class:`wavelink.Artist`.""" + """Property returning artist data for this track.""" return self._artist @property From 93a4c248e9ee6ba46e35aa46b0cce3fa7b853334 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 19 Oct 2023 15:20:56 +1000 Subject: [PATCH 083/132] Basic fault handler in AutoPlay. --- wavelink/player.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/wavelink/player.py b/wavelink/player.py index dc74c4cc..fdc54ead 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -138,13 +138,26 @@ def __init__( # We are screwed... self._auto_lock: asyncio.Lock = asyncio.Lock() + self._error_count: int = 0 + async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if self._autoplay is AutoPlayMode.disabled: return + if self._error_count >= 3: + logger.warning("AutoPlay was unable to continue as you have received too many consecutive errors." + "Please check the error log on Lavalink.") + if payload.reason == "replaced": + self._error_count = 0 return + elif payload.reason == "loadFailed": + self._error_count += 1 + + else: + self._error_count = 0 + if self.node.status is not NodeStatus.CONNECTED: logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to disconnected Node.') return From a545bc59a81609166edd76d829c88ce0291c419d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 19 Oct 2023 15:21:50 +1000 Subject: [PATCH 084/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 18147189..486a8483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b12" +version = "3.0.0b13" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 971a5cb7..4ce569aa 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b12" +__version__ = "3.0.0b13" from .enums import * From 98088985a129a1c5fb6cbd57d367586df3068b18 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 15:46:29 +1000 Subject: [PATCH 085/132] Fix some Playlis docs --- wavelink/tracks.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 7bb93e1a..664dc319 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -376,30 +376,39 @@ class Playlist: .. container:: operations .. describe:: str(x) + Return the name associated with this playlist. .. describe:: repr(x) + Return the official string representation of this playlist. .. describe:: x == y + Compare the equality of playlist. .. describe:: len(x) + Return an integer representing the amount of tracks contained in this playlist. .. describe:: x[0] + Return a track contained in this playlist with the given index. .. describe:: x[0:2] + Return a slice of tracks contained in this playlist. .. describe:: for x in y + Iterate over the tracks contained in this playlist. .. describe:: reversed(x) + Reverse the tracks contained in this playlist. .. describe:: x in y + Check if a :class:`Playable` is contained in this playlist. From 7bae9cf1143f7c8c6017ee71fabfd83f2087366d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 17:11:34 +1000 Subject: [PATCH 086/132] Add QueueMode for queue looping. --- docs/wavelink.rst | 11 +++++++++++ wavelink/enums.py | 30 +++++++++++++++++++++++------- wavelink/player.py | 20 +++++++++++++------- wavelink/queue.py | 45 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/docs/wavelink.rst b/docs/wavelink.rst index c2eb156e..a2e605fe 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -86,6 +86,12 @@ Types A type hint used when searching tracks. Used in :meth:`Playable.search` and :meth:`Pool.fetch_tracks` + **Example:** + + .. code:: python3 + + tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive") + Payloads --------- @@ -167,6 +173,11 @@ Enums .. autoclass:: AutoPlayMode :members: +.. attributetable:: QueueMode + +.. autoclass:: QueueMode + :members: + Pool -------- diff --git a/wavelink/enums.py b/wavelink/enums.py index 38647cce..f3c07687 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -23,7 +23,7 @@ """ import enum -__all__ = ("NodeStatus", "TrackSource", "DiscordVoiceCloseType", "AutoPlayMode") +__all__ = ("NodeStatus", "TrackSource", "DiscordVoiceCloseType", "AutoPlayMode", "QueueMode") class NodeStatus(enum.Enum): @@ -32,11 +32,11 @@ class NodeStatus(enum.Enum): Attributes ---------- DISCONNECTED - 0 + The Node has been disconnected or has never been connected previously. CONNECTING - 1 + The Node is currently attempting to connect. CONNECTED - 2 + The Node is currently connected. """ DISCONNECTED = 0 @@ -50,11 +50,11 @@ class TrackSource(enum.Enum): Attributes ---------- YouTube - 0 + A source representing a track that comes from YouTube. YouTubeMusic - 1 + A source representing a track that comes from YouTube Music. SoundCloud - 2 + A source representing a track that comes from SoundCloud. """ YouTube = 0 @@ -128,3 +128,19 @@ class AutoPlayMode(enum.Enum): enabled = 0 partial = 1 disabled = 2 + +class QueueMode(enum.Enum): + """Enum representing the various modes on :class:`wavelink.Queue` + + Attributes + ---------- + normal + When set, the queue will not loop either track or history. This is the default. + loop + When set, the track will continuously loop. + loop_all + When set, the queue will continuously loop through all tracks. + """ + normal = 0 + loop = 1 + loop_all = 2 diff --git a/wavelink/player.py b/wavelink/player.py index fdc54ead..2ef1fd4e 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -38,7 +38,7 @@ import wavelink -from .enums import AutoPlayMode, NodeStatus +from .enums import AutoPlayMode, NodeStatus, QueueMode from .exceptions import ( ChannelTimeoutException, InvalidChannelStateException, @@ -134,10 +134,7 @@ def __init__( self._autoplay: AutoPlayMode = AutoPlayMode.disabled self.__previous_seeds: asyncio.Queue[str] = asyncio.Queue(maxsize=self._previous_seeds_cutoff) - # We need an asyncio lock primitive because if either of the queues are changed during recos.. - # We are screwed... self._auto_lock: asyncio.Lock = asyncio.Lock() - self._error_count: int = 0 async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: @@ -165,22 +162,28 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if not isinstance(self.queue, Queue) or not isinstance(self.auto_queue, Queue): # type: ignore logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to unsupported Queue.') return + + if self.queue.mode is QueueMode.loop: + await self._do_partial(history=False) + + elif self.queue.mode is QueueMode.loop_all: + await self._do_partial() - if self._autoplay is AutoPlayMode.partial or self.queue: + elif self._autoplay is AutoPlayMode.partial or self.queue: await self._do_partial() elif self._autoplay is AutoPlayMode.enabled: async with self._auto_lock: await self._do_recommendation() - async def _do_partial(self) -> None: + async def _do_partial(self, *, history: bool = True) -> None: if self._current is None: try: track: Playable = self.queue.get() except QueueEmpty: return - await self.play(track) + await self.play(track, add_history=history) async def _do_recommendation(self): assert self.guild is not None @@ -725,6 +728,9 @@ async def skip(self, *, force: bool = True) -> Playable | None: """ assert self.guild is not None old: Playable | None = self._current + + if force: + self.queue._loaded = None request: RequestPayload = {"encodedTrack": None} await self.node._update_player(self.guild.id, data=request, replace=True) diff --git a/wavelink/queue.py b/wavelink/queue.py index 28578a81..af55e8bf 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -28,6 +28,7 @@ from typing import Any, Iterator, overload import random +from .enums import QueueMode from .exceptions import QueueEmpty from .tracks import * @@ -158,11 +159,18 @@ class Queue(_Queue): ---------- history: :class:`wavelink.Queue` A queue of tracks that have been added to history. + + Even though the history queue is the same class as this Queue some differences apply. + Mainly you can not set the ``mode``. """ - def __init__(self) -> None: + def __init__(self, history: bool = True) -> None: super().__init__() - self.history: _Queue = _Queue() + + if history: + self._loaded: Playable | None = None + self._mode: QueueMode = QueueMode.normal + self.history: Queue = Queue(history=False) self._waiters: deque[asyncio.Future[None]] = deque() self._finished: asyncio.Event = asyncio.Event() @@ -185,8 +193,17 @@ def _wakeup_next(self) -> None: break def _get(self) -> Playable: - # TODO ... Looping Logic, history Logic. - return super()._get() + if self.mode is QueueMode.loop and self._loaded: + return self._loaded + + if self.mode is QueueMode.loop_all and not self: + self._queue.extend(self.history._queue) + self.history.clear() + + track: Playable = super()._get() + self._loaded = track + + return track def get(self) -> Playable: """Retrieve a track from the left side of the queue. E.g. the first. @@ -315,7 +332,7 @@ async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomi async def delete(self, index: int, /) -> None: """Method to delete an item in the queue by index. - This method is asynchronous an implements/wait for a lock. + This method is asynchronous and implements/waits for a lock. Raises ------ @@ -364,3 +381,21 @@ def clear(self) -> None: # Your queue is now empty... """ self._queue.clear() + + @property + def mode(self) -> QueueMode: + """Property which returns a :class:`~wavelink.QueueMode` indicating which mode the + :class:`~wavelink.Queue` is in. + + This property can be set with any :class:`~wavelink.QueueMode`. + + .. versionadded:: 3.0.0 + """ + return self._mode + + @mode.setter + def mode(self, value: QueueMode): + if not hasattr(self, "_mode"): + raise AttributeError("This queues mode can not be set.") + + self._mode = value From f6d1e3283808dbf86a6b2c4603853f3461894213 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 17:12:00 +1000 Subject: [PATCH 087/132] Exclude venv from pyright. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 486a8483..190a2c66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ line-length = 120 profile = "black" [tool.pyright] +exclude = ["venv"] ignore = ["test*.py", "examples/*.py", "docs/*"] typeCheckingMode = "strict" reportPrivateUsage = false From 5a61dc21652536d676b251106be26d295c25a40c Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 17:12:57 +1000 Subject: [PATCH 088/132] Add .vscode/ to .gitignore. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 68bc17f9..5e8f6be6 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,5 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +.vscode/ \ No newline at end of file From d2d07408e76981f5ce2c2b3bc3a6d873a737d154 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 17:14:33 +1000 Subject: [PATCH 089/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 190a2c66..225a5fd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b13" +version = "3.0.0b14" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 4ce569aa..508b1bf5 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b13" +__version__ = "3.0.0b14" from .enums import * From fa1cc916c3926538e18ee836e95d47e77bdab3c4 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 17:51:21 +1000 Subject: [PATCH 090/132] Update version 3 readme. --- README.rst | 111 ++++++++++++++--------------------------------------- 1 file changed, 28 insertions(+), 83 deletions(-) diff --git a/README.rst b/README.rst index c7c560dd..2c4a56d6 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,7 @@ :target: https://www.python.org -.. image:: https://img.shields.io/github/license/EvieePy/Wavelink.svg +.. image:: https://img.shields.io/github/license/PythonistaGuild/Wavelink.svg :target: LICENSE @@ -25,26 +25,31 @@ Wavelink is a robust and powerful Lavalink wrapper for `Discord.py `_. -Wavelink features a fully asynchronous API that's intuitive and easy to use with built in Spotify Support and Node Pool Balancing. +Wavelink features a fully asynchronous API that's intuitive and easy to use. + + +**Migrating from Version 2 to Version 3:** +`Migrating Guide `_ **Features:** -- Fully Asynchronous -- Auto-Play and Looping (With the inbuilt Queue system) -- Spotify Support -- Node Balancing and Fail-over -- Supports Lavalink 3.7+ +- Full asynchronous design. +- Lavalink v4+ Supported with REST API. +- discord.py v2.0.0+ Support. +- Advanced AutoPlay and track recommendations for continuous play. +- Object orientated design with stateful objects and payloads. +- Fully annotated and complies with Pyright strict typing. Documentation --------------------------- -`Official Documentation `_ +`Official Documentation `_ Support --------------------------- For support using WaveLink, please join the official `support server -`_ on `Discord `_. +`_ on `Discord `_. .. image:: https://discordapp.com/api/guilds/490948346773635102/widget.png?style=banner2 :target: https://discord.gg/RAKc3HF @@ -52,96 +57,36 @@ For support using WaveLink, please join the official `support server Installation --------------------------- -The following commands are currently the valid ways of installing WaveLink. - -**WaveLink 2 requires Python 3.10+** +**WaveLink 3 requires Python 3.10+** **Windows** .. code:: sh - py -3.10 -m pip install -U Wavelink + py -3.10 -m pip install -U wavelink --pre **Linux** .. code:: sh - python3.10 -m pip install -U Wavelink - -Getting Started ----------------------------- - -**See also:** `Examples `_ - -.. code:: py - - import discord - import wavelink - from discord.ext import commands - - - class Bot(commands.Bot): - - def __init__(self) -> None: - intents = discord.Intents.default() - intents.message_content = True + python3.10 -m pip install -U wavelink --pre - super().__init__(intents=intents, command_prefix='?') - - async def on_ready(self) -> None: - print(f'Logged in {self.user} | {self.user.id}') - - async def setup_hook(self) -> None: - # Wavelink 2.0 has made connecting Nodes easier... Simply create each Node - # and pass it to NodePool.connect with the client/bot. - node: wavelink.Node = wavelink.Node(uri='http://localhost:2333', password='youshallnotpass') - await wavelink.NodePool.connect(client=self, nodes=[node]) - - - bot = Bot() - - - @bot.command() - async def play(ctx: commands.Context, *, search: str) -> None: - """Simple play command.""" - - if not ctx.voice_client: - vc: wavelink.Player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - else: - vc: wavelink.Player = ctx.voice_client - - tracks = await wavelink.YouTubeTrack.search(search) - if not tracks: - await ctx.send(f'No tracks found with query: `{search}`') - return - - track = tracks[0] - await vc.play(track) +**Virtual Environments** +.. code:: sh - @bot.command() - async def disconnect(ctx: commands.Context) -> None: - """Simple disconnect command. + pip install -U wavelink --pre - This command assumes there is a currently connected Player. - """ - vc: wavelink.Player = ctx.voice_client - await vc.disconnect() +Getting Started +---------------------------- -Lavalink Installation ---------------------- +**See Examples:** `Examples `_ -Head to the official `Lavalink repo `_ and give it a star! -- Create a folder for storing Lavalink.jar and related files/folders. -- Copy and paste the example `application.yml `_ to ``application.yml`` in the folder we created earlier. You can open the yml in Notepad or any simple text editor. -- Change your password in the ``application.yml`` and store it in a config for your bot. -- Set local to true in the ``application.yml`` if you wish to use ``wavelink.LocalTrack`` for local machine search options... Otherwise ignore. -- Save and exit. -- Install `Java 17(Windows) `_ or **Java 13+** on the machine you are running. -- Download `Lavalink.jar `_ and place it in the folder created earlier. -- Open a cmd prompt or terminal and change directory ``cd`` into the folder we made earlier. -- Run: ``java -jar Lavalink.jar`` +Notes +----- -If you are having any problems installing Lavalink, please join the official Discord Server listed above for help. +- Wavelink **3** is compatible with Lavalink **v4+**. +- Wavelink has built in support for Lavalink Plugins including LavaSrc and SponsorBlock. +- Wavelink is fully typed in compliance with Pyright Strict, though some nuances remain between discord.py and wavelink. From 06c7bf9fa8307ab89043c86c347a4cff94d7beb0 Mon Sep 17 00:00:00 2001 From: Mysty Date: Sun, 5 Nov 2023 17:56:43 +1000 Subject: [PATCH 091/132] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2c4a56d6..3fe7150a 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Wavelink features a fully asynchronous API that's intuitive and easy to use. Documentation --------------------------- -`Official Documentation `_ +`Official Documentation `_ Support --------------------------- From cbf6cfb8e2aeb36a464ad7ef147dc186aec613f4 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 18:39:01 +1000 Subject: [PATCH 092/132] Add async_timeout to requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index baea3836..b5d12bd7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ aiohttp>=3.7.4,<4 discord.py>=2.0.1 -yarl==1.9.2 \ No newline at end of file +yarl==1.9.2 +async_timeout \ No newline at end of file From 4c12ddc33584a33144073b17a5ab043c3d17d2f0 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 18:40:33 +1000 Subject: [PATCH 093/132] Bump version for Pre-Release. --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 225a5fd6..4236eb8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b14" +version = "3.0.0b15" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 508b1bf5..cc4f0ce3 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b14" +__version__ = "3.0.0b15" from .enums import * From 1615d1d630f50b512e9fcf07336591779afc0515 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 5 Nov 2023 23:58:44 +1000 Subject: [PATCH 094/132] Add start of migrating guide. --- docs/migrating.rst | 246 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 1 deletion(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index 6cb2c037..9c87a86b 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -1,4 +1,248 @@ Migrating +--------- + +Version **3** of wavelink has brought about many changes. This short guide should help you get started when moving +from version **2**. + + +**Some things may be missing from this page. If you see anything wrong or missing please make an issue on GitHub.** + + +Key Notes ========= -Coming soon... \ No newline at end of file +- Version **3** is now fully typed and compliant with pyright strict. +- Version **3** only works on Lavalink **v4+**. +- Version **3** now uses black and isort formatting, for consistency. +- Version **3** now better uses positional-only and keyword-only arguments in many places. +- Version **3** has better error handling with requests to your Lavalink nodes. +- Version **3** has Lavalink websocket completeness. All events have been implemented, with corresponding payloads. +- Version **3** has an experimental LFU request cache, saving you requests to YouTube and Spotify etc. + + +Removed +******* + +- Spotify Extension + - Wavelink no longer has it's own Spotify Extension. Instead it has native support for LavaSrc and other source plugins. + - Using LavaSrc with Wavelink **3** is just as simple as using the built-in Lavalink search types. +- Removed all Track Types E.g. (YouTubeTrack, SoundCloudTrack) + - Wavelink **3** uses one class for all tracks. :class:`wavelink.Playable`. + - :class:`wavelink.Playable` is a much easier and simple to use track that provides a powerful interface. +- :class:`wavelink.TrackSource` removed `Local` and `Unknown` + + +Changed +******* + +- All events have unique payloads. +- Playlists can not be used to search. +- :class:`wavelink.Playable` was changed significantly. Please see the docs for more info. +- :meth:`wavelink.Playable.search` was changed significantly. Please see the docs for more info. +- `Node.id` is now `Node.identifier`. +- `wavelink.NodePool` is now `wavelink.Pool`. +- :meth:`wavelink.Pool.connect` no longer requires the `client` keyword argument. +- :meth:`wavelink.Pool.get_node` the `id`` parameter is now known as `identifier` and is positional-only. This parameter is also optional. +- :meth:`wavelink.Pool.fetch_tracks` was previously known as both `.get_tracks` and `.get_playlist`. This method now returns either the appropriate :class:`wavelink.Playlist`` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a `LavalinkException` (The request failed somehow) or `LavalinkLoadException` there was an error loading the search (Request didn't fail). +- :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist`` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to `False`, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. +- :meth:`wavelink.Queue.put_wait` and :meth:`wavelink.Queue.put` now return an int of the amount of tracks added. +- :meth:`wavelink.Player.stop` is now known as :meth:`wavelink.Player.skip`, though they both exist as aliases. +- `Player.current_node` is now known as :attr:`wavelink.Player.node`. +- `Player.is_connected()` is now known as :attr:`wavelink.Player.connected`. +- `Player.is_paused()` is now known as :attr:`wavelink.Player.paused`. +- `Player.is_playing()` is now known as :attr:`wavelink.Player.playing`. +- :meth:`wavelink.Player.connect` now accepts a timeout argument as a float in seconds. +- :meth:`wavelink.Player.play` has had additional arguments added. See the docs. +- `Player.resume()` logic was moved to :meth:`wavelink.Player.pause`. +- :meth:`wavelink.Player.seek` the `position` parameter is now positional-only, and has a default of 0 which restarts the track from the beginning. +- :meth:`wavelink.Player.set_volume` the `value` parameter is now positional-only, and has a default of `100`. +- :attr:`wavelink.Player.autoplay` accepts a :class:`wavelink.AutoPlayMode` instead of a bool. AutoPlay has been changed to be more effecient and better with recomendations. +- :class:`wavelink.Queue` accepts a :class:`wavelink.QueueMode` in :attr:`wavelink.Queue.mode` for looping. + + +Added +***** + +- :class:`wavelink.PlaylistInfo` +- :meth:`wavelink.Playlist.track_extras` +- :attr:`wavelink.Node.client` property was added. This is the Bot/Client associated with the node. +- :attr:`wavelink.Node.password` property was added. This is the password used to connect and make requests with this node. +- :attr:`wavelink.Node.heartbeat` property was added. This is the seconds as a float that aiohttp will send a heartbeat over websocket. +- :attr:`wavelink.Node.session_id` property was added. This is the Lavalink session ID associated with this node. +- :class:`wavelink.AutoPlayMode` +- :class:`wavelink.QueueMode` +- :meth:`wavelink.Node.close` +- :meth:`wavelink.Pool.close` +- :func:`wavelink.on_wavelink_node_closed` +- :meth:`wavelink.Node.send` +- :class:`wavelink.Search` +- LFU (Least Frequently Used) Cache for request caching. + + +Connecting +========== +Connecting in version **3** is similar to version **2**. +It is recommended to use discord.py `setup_hook` to connect your nodes. + + +.. code:: python3 + + async def setup_hook(self) -> None: + nodes = [wavelink.Node(uri="...", password="...")] + + # cache_capacity is EXPERIMENTAL. Turn it off by passing None + await wavelink.Pool.connect(nodes=nodes, client=self, cache_capacity=100) + +When your node connects you will recieve the :class:`wavelink.NodeReadyEventPayload` via :func:`wavelink.on_wavelink_node_ready`. + + +Searching and Playing +===================== +Searching and playing tracks in version **3** is different, though should feel quite similar but easier. + + +.. code:: python3 + + # Search for tracks, with the default "ytsearch:" prefix. + tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive") + if not tracks: + # No tracks were found... + ... + + # Search for tracks, with a URL. + tracks: wavelink.Search = await wavelink.Playable.search("https://www.youtube.com/watch?v=KDxJlW6cxRk") + + # Search for tracks, using Spotify and the LavaSrc Plugin. + tracks: wavelink.Search = await wavelink.Playable.search("4b93D55xv3YCH5mT4p6HPn", source="spsearch") + + # Search for tracks, using Spotify and the LavaSrc Plugin, with a URL. + # Notice we don't need to pass a source argument with URL based searches... + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/track/4b93D55xv3YCH5mT4p6HPn") + + # Search for a playlist, using Spotify and the LavaSrc Plugin. + # or alternatively any other playlist URL from another source like YouTube. + tracks: wavelink.Search = await wavelink.Playable.search("https://open.spotify.com/playlist/37i9dQZF1DWXRqgorJj26U") + + +:class:`wavelink.Search` should be used to annotate your variables. +`.search` always returns a list[:class:`wavelink.Playable`] or :class:`wavelink.Playlist`, if no tracks were found +this method will return an empty `list` which should be checked, E.g: + +.. code:: python3 + + tracks: wavelink.Search = await wavelink.Playable.search(query) + if not tracks: + # No tracks were found... + return + + if isinstance(tracks, wavelink.Playlist): + # tracks is a playlist... + added: int = await player.queue.put_wait(tracks) + await ctx.send(f"Added the playlist **`{tracks.name}`** ({added} songs) to the queue.") + else: + track: wavelink.Playable = tracks[0] + await player.queue.put_wait(track) + await ctx.send(f"Added **`{track}`** to the queue.") + + +when playing a song from a command it is advised to check whether the Player is currently playing anything first, with +:attr:`wavelink.Player.playing` + +.. code:: python3 + + if not player.playing: + await player.play(track) + + +You can skip adding any track to your history queue in version **3** by passing `add_history=False` to `.play`. + +Wavelink **does not** advise using the `on_wavelink_track_end` event in most cases. Use this event only when you plan to +not use `AutoPlay` at all. Since version **3** implements `AutPlayMode.partial`, a setting which skips fetching and recommending tracks, +using this event is no longer recommended in most use cases. + +To send track updates or do player updates, consider using :func:`wavelink.on_wavelink_track_start` instead. + +.. code:: python3 + + async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload) -> None: + player: wavelink.Player | None = payload.player + if not player: + return + + original: wavelink.Playable | None = payload.original + track: wavelink.Playable = payload.track + + embed: discord.Embed = discord.Embed(title="Now Playing") + embed.description = f"**{track.title}** by `{track.author}`" + + if track.artwork: + embed.set_image(url=track.artwork) + + if original and original.recommended: + embed.description += f"\n\n`This track was recommended via {track.source}`" + + if track.album.name: + embed.add_field(name="Album", value=track.album.name) + + # Send this embed to a channel... + # See: simple.py example on GitHub. + + +.. note:: + + Please read the AutoPlay section for advice on how to properly use version **3** with AutoPlay. + + +AutoPlay +======== +Version **3** optimized AutoPlay and how it recommends tracks. + +Available are currently **3** different AutoPlay modes. +See: :class:`wavelink.AutoPlayMode` + +Setting :attr:`wavelink.Player.autoplay` to :attr:`wavelink.AutoPlayMode.enabled` will allow the player to fetch and recommend tracks +based on your current listening history. This currently works with Spotify, YouTube and YouTube Music. This mode handles everything including looping, and prioritizes the Queue +over the AutoQueue. + +Setting :attr:`wavelink.Player.autoplay` to :attr:`wavelink.AutoPlayMode.partial` will allow the player to handle the automatic playing of the next track +but **will NOT** recommend or fetch recommendations for playing in the future. This mode handles everything including looping. + +Setting :attr:`wavelink.Player.autoplay` to :attr:`wavelink.AutoPlayMode.disabled` will stop the player from automatically playing tracks. You will need +to use :func:`wavelink.on_wavelink_track_end` in this case. + +AutoPlay also implements error safety. In the case of too many consecutive errors trying to play a track, AutoPlay will stop attempting until manually restarted +by playing a track E.g. with :meth:`wavelink.Player.play`. + + +Pausing and Resuming +==================== +Version **3** slightly changes pausing behaviour. + +All logic is done in :meth:`wavelink.Player.pause` and you simply pass a bool (`True` to pause and `False` to resume). + +.. code:: python3 + + await player.pause(not player.paused) + + +Queue +===== +Version **3** made some internal changes to :class:`wavelink.Queue`. + +The most noticeable is :attr:`wavelink.Queue.mode` which allows you to turn the Queue to either, +:attr:`wavelink.QueueMode.loop`, :attr:`wavelink.QueueMode.loop_all` or :attr:`wavelink.QueueMode.normal`. + +- :attr:`wavelink.QueueMode.normal` means the queue will not loop at all. +- :attr:`wavelink.QueueMode.loop_all` will loop every song in the history when the queue has been exhausted. +- :attr:`wavelink.QueueMode.loop` will loop the current track continuously until turned off or skipped via :meth:`wavelink.Player.skip` with `force=True`. + + +Lavalink Plugins +================ +Version **3** supports plugins in most cases without the need for any extra steps. + +In some cases though you may need to send additional data. +You can use :meth:`wavelink.Node.send` for this purpose. + +See the docs for more info. + From ad0905ebce0dba38e789fae7995210a255c82fd6 Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Sun, 5 Nov 2023 14:14:12 +0000 Subject: [PATCH 095/132] Remove extra backticks Remove extra backticks --- docs/migrating.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index 9c87a86b..2d3cffcd 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -42,9 +42,9 @@ Changed - `Node.id` is now `Node.identifier`. - `wavelink.NodePool` is now `wavelink.Pool`. - :meth:`wavelink.Pool.connect` no longer requires the `client` keyword argument. -- :meth:`wavelink.Pool.get_node` the `id`` parameter is now known as `identifier` and is positional-only. This parameter is also optional. +- :meth:`wavelink.Pool.get_node` the `id` parameter is now known as `identifier` and is positional-only. This parameter is also optional. - :meth:`wavelink.Pool.fetch_tracks` was previously known as both `.get_tracks` and `.get_playlist`. This method now returns either the appropriate :class:`wavelink.Playlist`` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a `LavalinkException` (The request failed somehow) or `LavalinkLoadException` there was an error loading the search (Request didn't fail). -- :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist`` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to `False`, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. +- :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to `False`, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. - :meth:`wavelink.Queue.put_wait` and :meth:`wavelink.Queue.put` now return an int of the amount of tracks added. - :meth:`wavelink.Player.stop` is now known as :meth:`wavelink.Player.skip`, though they both exist as aliases. - `Player.current_node` is now known as :attr:`wavelink.Player.node`. From f56d7a7aa5d6d2bf6c639a80e9c24221bb6e0b7b Mon Sep 17 00:00:00 2001 From: chillymosh <86857777+chillymosh@users.noreply.github.com> Date: Sun, 5 Nov 2023 14:16:57 +0000 Subject: [PATCH 096/132] Remove another extra backtick Remove another extra backtick --- docs/migrating.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index 2d3cffcd..f4e70b51 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -43,7 +43,7 @@ Changed - `wavelink.NodePool` is now `wavelink.Pool`. - :meth:`wavelink.Pool.connect` no longer requires the `client` keyword argument. - :meth:`wavelink.Pool.get_node` the `id` parameter is now known as `identifier` and is positional-only. This parameter is also optional. -- :meth:`wavelink.Pool.fetch_tracks` was previously known as both `.get_tracks` and `.get_playlist`. This method now returns either the appropriate :class:`wavelink.Playlist`` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a `LavalinkException` (The request failed somehow) or `LavalinkLoadException` there was an error loading the search (Request didn't fail). +- :meth:`wavelink.Pool.fetch_tracks` was previously known as both `.get_tracks` and `.get_playlist`. This method now returns either the appropriate :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a `LavalinkException` (The request failed somehow) or `LavalinkLoadException` there was an error loading the search (Request didn't fail). - :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to `False`, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. - :meth:`wavelink.Queue.put_wait` and :meth:`wavelink.Queue.put` now return an int of the amount of tracks added. - :meth:`wavelink.Player.stop` is now known as :meth:`wavelink.Player.skip`, though they both exist as aliases. From 4b1e43f47294540d29b6183ddd5548436a116d08 Mon Sep 17 00:00:00 2001 From: Thanos <111999343+Sachaa-Thanasius@users.noreply.github.com> Date: Tue, 7 Nov 2023 22:54:18 -0500 Subject: [PATCH 097/132] Fix/v3 missing py.typed file (#245) * Create py.typed * Update pyproject.toml to include `py.typed` with the built distributions (e.g. wheel) - Explicitly identify the wavelink package with setuptools's settings - Explicitly add the `py.typed` file as package data to include. setuptools doesn't automatically add the `py.typed` file to the wheel unless called out by name in pyproject.toml like this. At least, that's been my experience. Can easily be reverted if unnecessary. * Remove unnecessary quotation marks. --- pyproject.toml | 6 ++++++ wavelink/py.typed | 1 + 2 files changed, 7 insertions(+) create mode 100644 wavelink/py.typed diff --git a/pyproject.toml b/pyproject.toml index 4236eb8e..acbdd118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,9 +28,15 @@ classifiers = [ [project.urls] "Homepage" = "https://github.com/PythonistaGuild/Wavelink" +[tool.setuptools] +packages = ["wavelink"] + [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} +[tool.setuptools.package-data] +wavelink = ["py.typed"] + [tool.black] line-length = 120 diff --git a/wavelink/py.typed b/wavelink/py.typed new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/wavelink/py.typed @@ -0,0 +1 @@ + From eca4e427411441aedf738cd71b8e3037fe71c2b1 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 14:01:22 +1000 Subject: [PATCH 098/132] Run black and isort. --- wavelink/enums.py | 4 +++- wavelink/player.py | 12 +++++++----- wavelink/queue.py | 22 +++++++++++----------- wavelink/tracks.py | 18 +++++++++--------- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/wavelink/enums.py b/wavelink/enums.py index f3c07687..7f2f0dac 100644 --- a/wavelink/enums.py +++ b/wavelink/enums.py @@ -129,9 +129,10 @@ class AutoPlayMode(enum.Enum): partial = 1 disabled = 2 + class QueueMode(enum.Enum): """Enum representing the various modes on :class:`wavelink.Queue` - + Attributes ---------- normal @@ -141,6 +142,7 @@ class QueueMode(enum.Enum): loop_all When set, the queue will continuously loop through all tracks. """ + normal = 0 loop = 1 loop_all = 2 diff --git a/wavelink/player.py b/wavelink/player.py index 2ef1fd4e..5ecdfa38 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -142,8 +142,10 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: return if self._error_count >= 3: - logger.warning("AutoPlay was unable to continue as you have received too many consecutive errors." - "Please check the error log on Lavalink.") + logger.warning( + "AutoPlay was unable to continue as you have received too many consecutive errors." + "Please check the error log on Lavalink." + ) if payload.reason == "replaced": self._error_count = 0 @@ -162,10 +164,10 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if not isinstance(self.queue, Queue) or not isinstance(self.auto_queue, Queue): # type: ignore logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to unsupported Queue.') return - + if self.queue.mode is QueueMode.loop: await self._do_partial(history=False) - + elif self.queue.mode is QueueMode.loop_all: await self._do_partial() @@ -728,7 +730,7 @@ async def skip(self, *, force: bool = True) -> Playable | None: """ assert self.guild is not None old: Playable | None = self._current - + if force: self.queue._loaded = None diff --git a/wavelink/queue.py b/wavelink/queue.py index af55e8bf..a794758e 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -24,9 +24,9 @@ from __future__ import annotations import asyncio +import random from collections import deque from typing import Any, Iterator, overload -import random from .enums import QueueMode from .exceptions import QueueEmpty @@ -159,14 +159,14 @@ class Queue(_Queue): ---------- history: :class:`wavelink.Queue` A queue of tracks that have been added to history. - + Even though the history queue is the same class as this Queue some differences apply. Mainly you can not set the ``mode``. """ def __init__(self, history: bool = True) -> None: super().__init__() - + if history: self._loaded: Playable | None = None self._mode: QueueMode = QueueMode.normal @@ -195,14 +195,14 @@ def _wakeup_next(self) -> None: def _get(self) -> Playable: if self.mode is QueueMode.loop and self._loaded: return self._loaded - + if self.mode is QueueMode.loop_all and not self: self._queue.extend(self.history._queue) self.history.clear() track: Playable = super()._get() self._loaded = track - + return track def get(self) -> Playable: @@ -381,21 +381,21 @@ def clear(self) -> None: # Your queue is now empty... """ self._queue.clear() - + @property def mode(self) -> QueueMode: - """Property which returns a :class:`~wavelink.QueueMode` indicating which mode the + """Property which returns a :class:`~wavelink.QueueMode` indicating which mode the :class:`~wavelink.Queue` is in. - + This property can be set with any :class:`~wavelink.QueueMode`. - + .. versionadded:: 3.0.0 """ return self._mode - + @mode.setter def mode(self, value: QueueMode): if not hasattr(self, "_mode"): raise AttributeError("This queues mode can not be set.") - + self._mode = value diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 664dc319..04f144fe 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -376,39 +376,39 @@ class Playlist: .. container:: operations .. describe:: str(x) - + Return the name associated with this playlist. .. describe:: repr(x) - + Return the official string representation of this playlist. .. describe:: x == y - + Compare the equality of playlist. .. describe:: len(x) - + Return an integer representing the amount of tracks contained in this playlist. .. describe:: x[0] - + Return a track contained in this playlist with the given index. .. describe:: x[0:2] - + Return a slice of tracks contained in this playlist. .. describe:: for x in y - + Iterate over the tracks contained in this playlist. .. describe:: reversed(x) - + Reverse the tracks contained in this playlist. .. describe:: x in y - + Check if a :class:`Playable` is contained in this playlist. From d42cb1440612a0b0e3e14782ce9f57ead37125ae Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 14:01:31 +1000 Subject: [PATCH 099/132] Add missing return --- wavelink/player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wavelink/player.py b/wavelink/player.py index 5ecdfa38..b7edee29 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -146,6 +146,7 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: "AutoPlay was unable to continue as you have received too many consecutive errors." "Please check the error log on Lavalink." ) + return if payload.reason == "replaced": self._error_count = 0 From d4f5adae3ff0ae09ca6c76c84054f5314c646932 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 14:16:07 +1000 Subject: [PATCH 100/132] Fix pyright ignore statements in websocket. --- wavelink/websocket.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index e60cbbdd..7f978441 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -134,15 +134,15 @@ async def keep_alive(self) -> None: while True: message: aiohttp.WSMessage = await self.socket.receive() - - if message.type in ( + + if message.type in ( # pyright: ignore[reportUnknownMemberType] aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, - ): # pyright: ignore[reportUnknownMemberType] + ): asyncio.create_task(self.connect()) break - if message.data is None: + if message.data is None: # pyright: ignore[reportUnknownMemberType] logger.debug("Received an empty message from Lavalink websocket. Disregarding.") continue From f7354e92fa0fe8e083b5f2910426eb7e7462205d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 22:30:48 +1000 Subject: [PATCH 101/132] Add Filters implementation. --- wavelink/__init__.py | 1 + wavelink/filters.py | 814 +++++++++++++++++++++++++++++++++++++ wavelink/player.py | 59 ++- wavelink/types/filters.py | 95 +++++ wavelink/types/request.py | 3 + wavelink/types/response.py | 2 + 6 files changed, 972 insertions(+), 2 deletions(-) create mode 100644 wavelink/filters.py create mode 100644 wavelink/types/filters.py diff --git a/wavelink/__init__.py b/wavelink/__init__.py index cc4f0ce3..82ba1894 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -30,6 +30,7 @@ from .enums import * from .exceptions import * +from .filters import * from .lfu import CapacityZero as CapacityZero from .node import * from .payloads import * diff --git a/wavelink/filters.py b/wavelink/filters.py new file mode 100644 index 00000000..9a68b9fb --- /dev/null +++ b/wavelink/filters.py @@ -0,0 +1,814 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-Present PythonistaGuild, EvieePy + +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 __future__ import annotations + +from typing import TYPE_CHECKING, Optional, TypedDict + +if TYPE_CHECKING: + from types.filters import ChannelMix as ChannelMixPayload + from types.filters import Distortion as DistortionPayload + from types.filters import Equalizer as EqualizerPayload + from types.filters import FilterPayload + from types.filters import Karaoke as KaraokePayload + from types.filters import LowPass as LowPassPayload + from types.filters import Rotation as RotationPayload + from types.filters import Timescale as TimescalePayload + from types.filters import Tremolo as TremoloPayload + from types.filters import Vibrato as VibratoPayload + from typing import Self, Unpack + + +__all__ = ( + "FiltersOptions", + "Filters", + "Equalizer", + "Karaoke", + "Timescale", + "Tremolo", + "Vibrato", + "Rotation", + "Distortion", + "ChannelMix", + "LowPass", +) + + +class FiltersOptions(TypedDict, total=False): + volume: float + equalizer: Equalizer + karaoke: Karaoke + timescale: Timescale + tremolo: Tremolo + vibrato: Vibrato + rotation: Rotation + distortion: Distortion + channel_mix: ChannelMix + low_pass: LowPass + reset: bool + + +class EqualizerOptions(TypedDict): + bands: Optional[list[EqualizerPayload]] + + +class KaraokeOptions(TypedDict): + level: Optional[float] + mono_level: Optional[float] + filter_band: Optional[float] + filter_width: Optional[float] + + +class RotationOptions(TypedDict): + rotation_hz: Optional[float] + + +class DistortionOptions(TypedDict): + sin_offset: Optional[float] + sin_scale: Optional[float] + cos_offset: Optional[float] + cos_scale: Optional[float] + tan_offset: Optional[float] + tan_scale: Optional[float] + offset: Optional[float] + scale: Optional[float] + + +class ChannelMixOptions(TypedDict): + left_to_left: Optional[float] + left_to_right: Optional[float] + right_to_left: Optional[float] + right_to_right: Optional[float] + + +class Equalizer: + """Equalizer Filter Class. + + There are 15 bands ``0`` to ``14`` that can be changed. + Each band has a ``gain`` which is the multiplier for the given band. ``gain`` defaults to ``0``. + + Valid ``gain`` values range from ``-0.25`` to ``1.0``, where ``-0.25`` means the given band is completely muted, + and ``0.25`` means it will be doubled. + + Modifying the ``gain`` could also change the volume of the output. + """ + + def __init__(self, payload: list[EqualizerPayload] | None = None) -> None: + if payload and len(payload) == 15: + self._payload = self._set(payload) + + else: + payload_: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + self._payload = payload_ + + def _set(self, payload: list[EqualizerPayload]) -> dict[int, EqualizerPayload]: + default: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + + for eq in payload: + band: int = eq["band"] + if band > 14 or band < 0: + continue + + default[band] = eq + + return default + + def set(self, **options: Unpack[EqualizerOptions]) -> Self: + """Set the bands of the Equalizer class. + + Accepts a keyword argument ``bands`` which is a ``list`` of ``dict`` containing the keys ``band`` and ``gain``. + + ``band`` can be an ``int`` beteween ``0`` and ``14``. + ``gain`` can be a float between ``-0.25`` and ``1.0``, where ``-0.25`` means the given band is completely muted, + and ``0.25`` means it will be doubled. + + Using this method changes **all** bands, resetting any bands not provided. + To change specific bands, consider accessing :attr:`~wavelink.Equalizer.payload` first. + """ + default: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + payload: list[EqualizerPayload] | None = options.get("bands", None) + + if payload is None: + self._payload = default + return self + + self._payload = self._set(payload) + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: dict[int, EqualizerPayload] = {n: {"band": n, "gain": 0.0} for n in range(15)} + return self + + @property + def payload(self) -> dict[int, EqualizerPayload]: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Equalizer" + + def __repr__(self) -> str: + return f"" + + +class Karaoke: + """Karaoke Filter class. + + Uses equalization to eliminate part of a band, usually targeting vocals. + """ + + def __init__(self, payload: KaraokePayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[KaraokeOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + level: Optional[float] + The level ``0`` to ``1.0`` where ``0.0`` is no effect and ``1.0`` is full effect. + mono_level: Optional[float] + The mono level ``0`` to ``1.0`` where ``0.0`` is no effect and ``1.0`` is full effect. + filter_band: Optional[float] + The filter band in Hz. + filter_width: Optional[float] + The filter width. + """ + self._payload: KaraokePayload = { + "level": options.get("level", self._payload.get("level")), + "monoLevel": options.get("mono_level", self._payload.get("monoLevel")), + "filterBand": options.get("filter_band", self._payload.get("filterBand")), + "filterWidth": options.get("filter_width", self._payload.get("filterWidth")), + } + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: KaraokePayload = {} + return self + + @property + def payload(self) -> KaraokePayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Karaoke" + + def __repr__(self) -> str: + return f"" + + +class Timescale: + """Timescale Filter class. + + Changes the speed, pitch, and rate. + """ + + def __init__(self, payload: TimescalePayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[TimescalePayload]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + speed: Optional[float] + The playback speed. + pitch: Optional[float] + The pitch. + rate: Optional[float] + The rate. + """ + self._payload.update(options) + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: TimescalePayload = {} + return self + + @property + def payload(self) -> TimescalePayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Timescale" + + def __repr__(self) -> str: + return f"" + + +class Tremolo: + """The Tremolo Filter class. + + Uses amplification to create a shuddering effect, where the volume quickly oscillates. + Demo: https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv + """ + def __init__(self, payload: TremoloPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[TremoloPayload]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + frequency: Optional[float] + The frequency. + depth: Optional[float] + The tremolo depth. + """ + self._payload.update(options) + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: TremoloPayload = {} + return self + + @property + def payload(self) -> TremoloPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Tremolo" + + def __repr__(self) -> str: + return f"" + + +class Vibrato: + """The Vibrato Filter class. + + Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch. + """ + + def __init__(self, payload: VibratoPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[VibratoPayload]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + frequency: Optional[float] + The frequency. + depth: Optional[float] + The vibrato depth. + """ + self._payload.update(options) + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: VibratoPayload = {} + return self + + @property + def payload(self) -> VibratoPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Vibrato" + + def __repr__(self) -> str: + return f"" + + +class Rotation: + """The Rotation Filter class. + + Rotates the sound around the stereo channels/user headphones (aka Audio Panning). + It can produce an effect similar to https://youtu.be/QB9EB8mTKcc (without the reverb). + """ + + def __init__(self, payload: RotationPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[RotationOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + rotation_hz: Optional[float] + The frequency of the audio rotating around the listener in Hz. ``0.2`` is similar to the example video. + """ + self._payload: RotationPayload = {"rotationHz": options.get("rotation_hz", self._payload.get("rotationHz"))} + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: RotationPayload = {} + return self + + @property + def payload(self) -> RotationPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Rotation" + + def __repr__(self) -> str: + return f"" + + +class Distortion: + """The Distortion Filter class. + + According to Lavalink "It can generate some pretty unique audio effects." + """ + + def __init__(self, payload: DistortionPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[DistortionOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + sin_offset: Optional[float] + The sin offset. + sin_scale: Optional[float] + The sin scale. + cos_offset: Optional[float] + The cos offset. + cos_scale: Optional[float] + The cos scale. + tan_offset: Optional[float] + The tan offset. + tan_scale: Optional[float] + The tan scale. + offset: Optional[float] + The offset. + scale: Optional[float] + The scale. + """ + self._payload: DistortionPayload = { + "sinOffset": options.get("sin_offset", self._payload.get("sinOffset")), + "sinScale": options.get("sin_scale", self._payload.get("sinScale")), + "cosOffset": options.get("cos_offset", self._payload.get("cosOffset")), + "cosScale": options.get("cos_scale", self._payload.get("cosScale")), + "tanOffset": options.get("tan_offset", self._payload.get("tanOffset")), + "tanScale": options.get("tan_scale", self._payload.get("tanScale")), + "offset": options.get("offset", self._payload.get("offset")), + "scale": options.get("scale", self._payload.get("scale")), + } + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: DistortionPayload = {} + return self + + @property + def payload(self) -> DistortionPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "Distortion" + + def __repr__(self) -> str: + return f"" + + +class ChannelMix: + """The ChannelMix Filter class. + + Mixes both channels (left and right), with a configurable factor on how much each channel affects the other. + With the defaults, both channels are kept independent of each other. + + Setting all factors to ``0.5`` means both channels get the same audio. + """ + + def __init__(self, payload: ChannelMixPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[ChannelMixOptions]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + left_to_left: Optional[float] + The left to left channel mix factor. Between ``0.0`` and ``1.0``. + left_to_right: Optional[float] + The left to right channel mix factor. Between ``0.0`` and ``1.0``. + right_to_left: Optional[float] + The right to left channel mix factor. Between ``0.0`` and ``1.0``. + right_to_right: Optional[float] + The right to right channel mix factor. Between ``0.0`` and ``1.0``. + """ + self._payload: ChannelMixPayload = { + "leftToLeft": options.get("left_to_left", self._payload.get("leftToLeft")), + "leftToRight": options.get("left_to_right", self._payload.get("leftToRight")), + "rightToLeft": options.get("right_to_left", self._payload.get("rightToLeft")), + "rightToRight": options.get("right_to_right", self._payload.get("rightToRight")), + } + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: ChannelMixPayload = {} + return self + + @property + def payload(self) -> ChannelMixPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "ChannelMix" + + def __repr__(self) -> str: + return f"" + + +class LowPass: + """The LowPass Filter class. + + Higher frequencies get suppressed, while lower frequencies pass through this filter, thus the name low pass. + Any smoothing values equal to or less than ``1.0`` will disable the filter. + """ + + def __init__(self, payload: LowPassPayload) -> None: + self._payload = payload + + def set(self, **options: Unpack[LowPassPayload]) -> Self: + """Set the properties of the this filter. + + This method accepts keyword argument pairs. + This method does not override existing settings if they are not provided. + + Parameters + ---------- + smoothing: Optional[float] + The smoothing factor. + """ + self._payload.update(options) + return self + + def reset(self) -> Self: + """Reset this filter to its defaults.""" + self._payload: LowPassPayload = {} + return self + + @property + def payload(self) -> LowPassPayload: + """The raw payload associated with this filter. + + This property returns a copy. + """ + return self._payload.copy() + + def __str__(self) -> str: + return "LowPass" + + def __repr__(self) -> str: + return f"" + + +class Filters: + """The wavelink Filters class. + + This class contains the information associated with each of Lavalinks filter objects, as Python classes. + Each filter can be ``set`` or ``reset`` individually. + + Using ``set`` on an individual filter only updates any ``new`` values you pass. + Using ``reset`` on an individual filter, resets it's payload, and can be used before ``set`` when you want a clean + state for that filter. + + See: :meth:`~wavelink.Filters.reset` to reset **every** individual filter. + + This class is already applied an instantiated on all new :class:`~wavelink.Player`. + + See: :meth:`~wavelink.Player.set_filters` for information on applying this class to your :class:`~wavelink.Player`. + See: :attr:`~wavelink.Player.filters` for retrieving the applied filters. + + To retrieve the ``payload`` for this Filters class, you can call an instance of this class. + + Examples + -------- + + .. code:: python3 + + import wavelink + + # Create a brand new Filters and apply it... + # You can use player.set_filters() for an easier way to reset. + filters: wavelink.Filters = wavelink.Filters() + await player.set_filters(filters) + + + # Retrieve the payload of any Filters instance... + filters: wavelink.Filters = player.filters + print(filters()) + + + # Set some filters... + # You can set and reset individual filters at the same time... + filters: wavelink.Filters = player.filters + filters.timescale.set(pitch=1.2, speed=1.1, rate=1) + filters.rotation.set(rotation_hz=0.2) + filters.equalizer.reset() + + await player.set_filters(filters) + + + # Reset a filter... + filters: wavelink.Filters = player.filters + filters.timescale.reset() + + await player.set_filters(filters) + + + # Reset all filters... + filters: wavelink.Filters = player.filters + filters.reset() + + await player.set_filters(filters) + + + # Reset and apply filters easier method... + await player.set_filters() + """ + + def __init__(self, *, data: FilterPayload | None = None) -> None: + self._volume: float | None = None + self._equalizer: Equalizer = Equalizer(None) + self._karaoke: Karaoke = Karaoke({}) + self._timescale: Timescale = Timescale({}) + self._tremolo: Tremolo = Tremolo({}) + self._vibrato: Vibrato = Vibrato({}) + self._rotation: Rotation = Rotation({}) + self._distortion: Distortion = Distortion({}) + self._channel_mix: ChannelMix = ChannelMix({}) + self._low_pass: LowPass = LowPass({}) + + if data: + self._create_from(data) + + def _create_from(self, data: FilterPayload) -> None: + self._volume = data.get("volume") + self._equalizer = Equalizer(data.get("equalizer", None)) + self._karaoke = Karaoke(data.get("karaoke", {})) + self._timescale = Timescale(data.get("timescale", {})) + self._tremolo = Tremolo(data.get("tremolo", {})) + self._vibrato = Vibrato(data.get("vibrato", {})) + self._rotation = Rotation(data.get("rotation", {})) + self._distortion = Distortion(data.get("distortion", {})) + self._channel_mix = ChannelMix(data.get("channelMix", {})) + self._low_pass = LowPass(data.get("lowPass", {})) + + def _set_with_reset(self, filters: FiltersOptions) -> None: + self._volume = filters.get("volume") + self._equalizer = filters.get("equalizer", Equalizer(None)) + self._karaoke = filters.get("karaoke", Karaoke({})) + self._timescale = filters.get("timescale", Timescale({})) + self._tremolo = filters.get("tremolo", Tremolo({})) + self._vibrato = filters.get("vibrato", Vibrato({})) + self._rotation = filters.get("rotation", Rotation({})) + self._distortion = filters.get("distortion", Distortion({})) + self._channel_mix = filters.get("channelMix", ChannelMix({})) + self._low_pass = filters.get("lowPass", LowPass({})) + + def set_filters(self, **filters: Unpack[FiltersOptions]) -> None: + # TODO: document this later maybe? + + reset: bool = filters.get("reset", False) + if reset: + self._set_with_reset(filters) + return + + self._volume = filters.get("volume", self._volume) + self._equalizer = filters.get("equalizer", self._equalizer) + self._karaoke = filters.get("karaoke", self._karaoke) + self._timescale = filters.get("timescale", self._timescale) + self._tremolo = filters.get("tremolo", self._tremolo) + self._vibrato = filters.get("vibrato", self._vibrato) + self._rotation = filters.get("rotation", self._rotation) + self._distortion = filters.get("distortion", self._distortion) + self._channel_mix = filters.get("channelMix", self._channel_mix) + self._low_pass = filters.get("lowPass", self._low_pass) + + def _reset(self) -> None: + self._volume = None + self._equalizer = Equalizer(None) + self._karaoke = Karaoke({}) + self._timescale = Timescale({}) + self._tremolo = Tremolo({}) + self._vibrato = Vibrato({}) + self._rotation = Rotation({}) + self._distortion = Distortion({}) + self._channel_mix = ChannelMix({}) + self._low_pass = LowPass({}) + + def reset(self) -> None: + """Method which resets this object to an original state. + + Using this method will clear all indivdual filters, and assign the wavelink default classes. + """ + self._reset() + + @classmethod + def from_filters(cls, **filters: Unpack[FiltersOptions]) -> Self: + # TODO: document this later maybe? + + self = cls() + self._set_with_reset(filters) + + return self + + @property + def volume(self) -> float | None: + """Property which returns the volume ``float`` associated with this Filters payload. + + Adjusts the player volume from 0.0 to 5.0, where 1.0 is 100%. Values >1.0 may cause clipping. + """ + return self._volume + + @volume.setter + def volume(self, value: float) -> None: + self._volume = value + + @property + def equalizer(self) -> Equalizer: + """Property which returns the :class:`~wavelink.Equalizer` filter associated with this Filters payload.""" + return self._equalizer + + @property + def karaoke(self) -> Karaoke: + """Property which returns the :class:`~wavelink.Karaoke` filter associated with this Filters payload.""" + return self._karaoke + + @property + def timescale(self) -> Timescale: + """Property which returns the :class:`~wavelink.Timescale` filter associated with this Filters payload.""" + return self._timescale + + @property + def tremolo(self) -> Tremolo: + """Property which returns the :class:`~wavelink.Tremolo` filter associated with this Filters payload.""" + return self._tremolo + + @property + def vibrato(self) -> Vibrato: + """Property which returns the :class:`~wavelink.Vibrato` filter associated with this Filters payload.""" + return self._vibrato + + @property + def rotation(self) -> Rotation: + """Property which returns the :class:`~wavelink.Rotation` filter associated with this Filters payload.""" + return self._rotation + + @property + def distortion(self) -> Distortion: + """Property which returns the :class:`~wavelink.Distortion` filter associated with this Filters payload.""" + return self._distortion + + @property + def channel_mix(self) -> ChannelMix: + """Property which returns the :class:`~wavelink.ChannelMix` filter associated with this Filters payload.""" + return self._channel_mix + + @property + def low_pass(self) -> LowPass: + """Property which returns the :class:`~wavelink.LowPass` filter associated with this Filters payload.""" + return self._low_pass + + def __call__(self) -> FilterPayload: + payload: FilterPayload = { + "volume": self._volume, + "equalizer": list(self._equalizer._payload.values()), + "karaoke": self._karaoke._payload, + "timescale": self._timescale._payload, + "tremolo": self._tremolo._payload, + "vibrato": self._vibrato._payload, + "rotation": self._rotation._payload, + "distortion": self._distortion._payload, + "channelMix": self._channel_mix._payload, + "lowPass": self._low_pass._payload, + } + + for key, value in payload.copy().items(): + if not value: + del payload[key] + + return payload + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/wavelink/player.py b/wavelink/player.py index b7edee29..2a16b0c6 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -46,6 +46,7 @@ LavalinkLoadException, QueueEmpty, ) +from .filters import * from .node import Pool from .payloads import PlayerUpdateEventPayload, TrackEndEventPayload from .queue import Queue @@ -137,6 +138,8 @@ def __init__( self._auto_lock: asyncio.Lock = asyncio.Lock() self._error_count: int = 0 + self._filters: Filters = Filters() + async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if self._autoplay is AutoPlayMode.disabled: return @@ -361,6 +364,18 @@ def volume(self) -> int: """ return self._volume + @property + def filters(self) -> Filters: + """Property which returns the :class:`~wavelink.Filters` currently assigned to the Player. + + See: :meth:`~wavelink.Player.set_filters` for setting the players filters. + + .. versionchanged:: 3.0.0 + + This property was previously known as ``filter``. + """ + return self._filters + @property def paused(self) -> bool: """Returns the paused status of the player. A currently paused player will return ``True``. @@ -520,6 +535,7 @@ async def play( volume: int | None = None, paused: bool | None = None, add_history: bool = True, + filters: Filters | None = None, ) -> Playable: """Play the provided :class:`~wavelink.Playable`. @@ -549,6 +565,9 @@ async def play( :class:`wavelink.Queue` history, if loading the track was successful. If ``False`` this track will not be added to your history. This does not directly affect the ``AutoPlay Queue`` but will alter how ``AutoPlay`` recommends songs in the future. Defaults to ``True``. + filters: Optional[:class:`~wavelink.Filters`] + An Optional[:class:`~wavelink.Filters`] to apply when playing this track. Defaults to ``None``. + If this is ``None`` the currently set filters on the player will be applied. Returns @@ -563,6 +582,8 @@ async def play( are now all keyword-only arguments. Added the ``add_history`` keyword-only argument. + + Added the ``filters`` keyword-only argument. """ assert self.guild is not None @@ -585,12 +606,16 @@ async def play( else: pause = self._paused + if filters: + self._filters = filters + request: RequestPayload = { "encodedTrack": track.encoded, "volume": vol, "position": start, "endTime": end, "paused": pause, + "filters": self._filters(), } try: @@ -652,8 +677,38 @@ async def seek(self, position: int = 0, /) -> None: request: RequestPayload = {"position": position} await self.node._update_player(self.guild.id, data=request) - async def set_filter(self) -> None: - raise NotImplementedError + async def set_filters(self, filters: Filters | None = None, /, *, seek: bool = False) -> None: + """Set the :class:`wavelink.Filters` on the player. + + Parameters + ---------- + filters: Optional[:class:`~wavelink.Filters`] + The filters to set on the player. Could be ```None`` to reset the currently applied filters. + Defaults to ``None``. + seek: bool + Whether to seek immediately when applying these filters. Seeking uses more resources, but applies the + filters immediately. Defaults to ``False``. + + .. versionchanged:: 3.0.0 + + This method now accepts a positional-only argument of filters, which now defaults to None. Filters + were redesigned in this version, see: :class:`wavelink.Filters`. + + .. versionchanged:: 3.0.0 + + This method was previously known as ``set_filter``. + """ + assert self.guild is not None + + if filters is None: + filters = Filters() + + request: RequestPayload = {"filters": filters()} + await self.node._update_player(self.guild.id, data=request) + self._filters = filters + + if self.playing and seek: + await self.seek(self.position) async def set_volume(self, value: int = 100, /) -> None: """Set the :class:`Player` volume, as a percentage, between 0 and 1000. diff --git a/wavelink/types/filters.py b/wavelink/types/filters.py new file mode 100644 index 00000000..c375764d --- /dev/null +++ b/wavelink/types/filters.py @@ -0,0 +1,95 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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 TYPE_CHECKING, Any, Optional, TypedDict + +if TYPE_CHECKING: + from typing_extensions import NotRequired + + +class Equalizer(TypedDict): + band: int + gain: float + + +class Karaoke(TypedDict): + level: NotRequired[Optional[float]] + monoLevel: NotRequired[Optional[float]] + filterBand: NotRequired[Optional[float]] + filterWidth: NotRequired[Optional[float]] + + +class Timescale(TypedDict): + speed: NotRequired[Optional[float]] + pitch: NotRequired[Optional[float]] + rate: NotRequired[Optional[float]] + + +class Tremolo(TypedDict): + frequency: NotRequired[Optional[float]] + depth: NotRequired[Optional[float]] + + +class Vibrato(TypedDict): + frequency: NotRequired[Optional[float]] + depth: NotRequired[Optional[float]] + + +class Rotation(TypedDict): + rotationHz: NotRequired[Optional[float]] + + +class Distortion(TypedDict): + sinOffset: NotRequired[Optional[float]] + sinScale: NotRequired[Optional[float]] + cosOffset: NotRequired[Optional[float]] + cosScale: NotRequired[Optional[float]] + tanOffset: NotRequired[Optional[float]] + tanScale: NotRequired[Optional[float]] + offset: NotRequired[Optional[float]] + scale: NotRequired[Optional[float]] + + +class ChannelMix(TypedDict): + leftToLeft: NotRequired[Optional[float]] + leftToRight: NotRequired[Optional[float]] + rightToLeft: NotRequired[Optional[float]] + rightToRight: NotRequired[Optional[float]] + + +class LowPass(TypedDict): + smoothing: NotRequired[Optional[float]] + + +class FilterPayload(TypedDict): + volume: NotRequired[Optional[float]] + equalizer: NotRequired[Optional[list[Equalizer]]] + karaoke: NotRequired[Karaoke] + timescale: NotRequired[Timescale] + tremolo: NotRequired[Tremolo] + vibrato: NotRequired[Vibrato] + rotation: NotRequired[Rotation] + distortion: NotRequired[Distortion] + channelMix: NotRequired[ChannelMix] + lowPass: NotRequired[LowPass] + pluginFilters: NotRequired[dict[str, Any]] diff --git a/wavelink/types/request.py b/wavelink/types/request.py index 25396193..2d1aa269 100644 --- a/wavelink/types/request.py +++ b/wavelink/types/request.py @@ -28,6 +28,8 @@ if TYPE_CHECKING: from typing_extensions import NotRequired, TypeAlias + from .filters import FilterPayload + class VoiceRequest(TypedDict): token: str @@ -41,6 +43,7 @@ class _BaseRequest(TypedDict, total=False): endTime: Optional[int] volume: int paused: bool + filters: FilterPayload class EncodedTrackRequest(_BaseRequest): diff --git a/wavelink/types/response.py b/wavelink/types/response.py index 081d4f5e..a2e73ba6 100644 --- a/wavelink/types/response.py +++ b/wavelink/types/response.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from typing_extensions import Never, NotRequired + from .filters import FilterPayload from .state import PlayerState, VoiceState from .stats import CPUStats, FrameStats, MemoryStats from .tracks import PlaylistPayload, TrackPayload @@ -53,6 +54,7 @@ class PlayerResponse(TypedDict): paused: bool state: PlayerState voice: VoiceState + filters: FilterPayload class UpdateResponse(TypedDict): From bc62064eeba7fe8935d16443334eee28b99e55f4 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 22:31:07 +1000 Subject: [PATCH 102/132] Run black and isort. --- wavelink/node.py | 4 ++-- wavelink/websocket.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index edf0f63d..9a3c745f 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -575,7 +575,7 @@ async def connect( except NodeException: logger.error( f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " - f"and that you are trying to connect to Lavalink on the correct port." + "and that you are trying to connect to Lavalink on the correct port." ) else: cls.__nodes[node.identifier] = node @@ -605,7 +605,7 @@ async def reconnect(cls) -> dict[str, Node]: except NodeException: logger.error( f"Failed to connect to {node!r}. Check that your Lavalink major version is '4' " - f"and that you are trying to connect to Lavalink on the correct port." + "and that you are trying to connect to Lavalink on the correct port." ) return cls.nodes diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 7f978441..659bb379 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -134,7 +134,7 @@ async def keep_alive(self) -> None: while True: message: aiohttp.WSMessage = await self.socket.receive() - + if message.type in ( # pyright: ignore[reportUnknownMemberType] aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING, From fc8f7f36ab68f1b07f91654fde1bd2a80966090f Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 22:45:34 +1000 Subject: [PATCH 103/132] Update docs for filters. --- docs/migrating.rst | 97 ++++++++++++++++++++++++++++++++++++---------- docs/wavelink.rst | 51 +++++++++++++++++++++++- 2 files changed, 125 insertions(+), 23 deletions(-) diff --git a/docs/migrating.rst b/docs/migrating.rst index f4e70b51..e2c7a09b 100644 --- a/docs/migrating.rst +++ b/docs/migrating.rst @@ -29,7 +29,7 @@ Removed - Removed all Track Types E.g. (YouTubeTrack, SoundCloudTrack) - Wavelink **3** uses one class for all tracks. :class:`wavelink.Playable`. - :class:`wavelink.Playable` is a much easier and simple to use track that provides a powerful interface. -- :class:`wavelink.TrackSource` removed `Local` and `Unknown` +- :class:`wavelink.TrackSource` removed ``Local`` and ``Unknown`` Changed @@ -39,25 +39,28 @@ Changed - Playlists can not be used to search. - :class:`wavelink.Playable` was changed significantly. Please see the docs for more info. - :meth:`wavelink.Playable.search` was changed significantly. Please see the docs for more info. -- `Node.id` is now `Node.identifier`. -- `wavelink.NodePool` is now `wavelink.Pool`. -- :meth:`wavelink.Pool.connect` no longer requires the `client` keyword argument. -- :meth:`wavelink.Pool.get_node` the `id` parameter is now known as `identifier` and is positional-only. This parameter is also optional. -- :meth:`wavelink.Pool.fetch_tracks` was previously known as both `.get_tracks` and `.get_playlist`. This method now returns either the appropriate :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a `LavalinkException` (The request failed somehow) or `LavalinkLoadException` there was an error loading the search (Request didn't fail). -- :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to `False`, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. +- ``Node.id`` is now ``Node.identifier``. +- ``wavelink.NodePool`` is now ``wavelink.Pool``. +- :meth:`wavelink.Pool.connect` no longer requires the ``client`` keyword argument. +- :meth:`wavelink.Pool.get_node` the ``id`` parameter is now known as ``identifier`` and is positional-only. This parameter is also optional. +- :meth:`wavelink.Pool.fetch_tracks` was previously known as both ``.get_tracks`` and ``.get_playlist``. This method now returns either the appropriate :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. If there is an error when searching, this method raises either a ``LavalinkException`` (The request failed somehow) or ``LavalinkLoadException`` there was an error loading the search (Request didn't fail). +- :meth:`wavelink.Queue.put_wait` now has an option to atomically add tracks from a :class:`wavelink.Playlist` or list[:class:`wavelink.Playable`]. This defaults to True. This currently checks if the track in the Playlist is Playable and if any errors occur will not add any tracks from the Playlist to the queue. IF set to ``False``, Playable tracks will be added to the Queue up until an error occurs or every track was successfully added. - :meth:`wavelink.Queue.put_wait` and :meth:`wavelink.Queue.put` now return an int of the amount of tracks added. - :meth:`wavelink.Player.stop` is now known as :meth:`wavelink.Player.skip`, though they both exist as aliases. -- `Player.current_node` is now known as :attr:`wavelink.Player.node`. -- `Player.is_connected()` is now known as :attr:`wavelink.Player.connected`. -- `Player.is_paused()` is now known as :attr:`wavelink.Player.paused`. -- `Player.is_playing()` is now known as :attr:`wavelink.Player.playing`. +- ``Player.current_node`` is now known as :attr:`wavelink.Player.node`. +- ``Player.is_connected()`` is now known as :attr:`wavelink.Player.connected`. +- ``Player.is_paused()`` is now known as :attr:`wavelink.Player.paused`. +- ``Player.is_playing()`` is now known as :attr:`wavelink.Player.playing`. - :meth:`wavelink.Player.connect` now accepts a timeout argument as a float in seconds. - :meth:`wavelink.Player.play` has had additional arguments added. See the docs. -- `Player.resume()` logic was moved to :meth:`wavelink.Player.pause`. -- :meth:`wavelink.Player.seek` the `position` parameter is now positional-only, and has a default of 0 which restarts the track from the beginning. -- :meth:`wavelink.Player.set_volume` the `value` parameter is now positional-only, and has a default of `100`. +- ``Player.resume()`` logic was moved to :meth:`wavelink.Player.pause`. +- :meth:`wavelink.Player.seek` the ``position`` parameter is now positional-only, and has a default of ``0`` which restarts the track from the beginning. +- :meth:`wavelink.Player.set_volume` the ``value`` parameter is now positional-only, and has a default of ``100``. - :attr:`wavelink.Player.autoplay` accepts a :class:`wavelink.AutoPlayMode` instead of a bool. AutoPlay has been changed to be more effecient and better with recomendations. - :class:`wavelink.Queue` accepts a :class:`wavelink.QueueMode` in :attr:`wavelink.Queue.mode` for looping. +- Filters have been completely reworked. See: :class:`wavelink.Filters` +- ``Player.set_filter`` is now known as :meth:`wavelink.Player.set_filters` +- ``Player.filter`` is now known as :attr:`wavelink.Player.filters` Added @@ -82,7 +85,7 @@ Added Connecting ========== Connecting in version **3** is similar to version **2**. -It is recommended to use discord.py `setup_hook` to connect your nodes. +It is recommended to use discord.py ``setup_hook`` to connect your nodes. .. code:: python3 @@ -126,7 +129,7 @@ Searching and playing tracks in version **3** is different, though should feel q :class:`wavelink.Search` should be used to annotate your variables. `.search` always returns a list[:class:`wavelink.Playable`] or :class:`wavelink.Playlist`, if no tracks were found -this method will return an empty `list` which should be checked, E.g: +this method will return an empty ``list`` which should be checked, E.g: .. code:: python3 @@ -154,10 +157,10 @@ when playing a song from a command it is advised to check whether the Player is await player.play(track) -You can skip adding any track to your history queue in version **3** by passing `add_history=False` to `.play`. +You can skip adding any track to your history queue in version **3** by passing ``add_history=False`` to ``.play``. -Wavelink **does not** advise using the `on_wavelink_track_end` event in most cases. Use this event only when you plan to -not use `AutoPlay` at all. Since version **3** implements `AutPlayMode.partial`, a setting which skips fetching and recommending tracks, +Wavelink **does not** advise using the ``on_wavelink_track_end`` event in most cases. Use this event only when you plan to +not use ``AutoPlay`` at all. Since version **3** implements ``AutPlayMode.partial``, a setting which skips fetching and recommending tracks, using this event is no longer recommended in most use cases. To send track updates or do player updates, consider using :func:`wavelink.on_wavelink_track_start` instead. @@ -218,7 +221,7 @@ Pausing and Resuming ==================== Version **3** slightly changes pausing behaviour. -All logic is done in :meth:`wavelink.Player.pause` and you simply pass a bool (`True` to pause and `False` to resume). +All logic is done in :meth:`wavelink.Player.pause` and you simply pass a bool (``True`` to pause and ``False`` to resume). .. code:: python3 @@ -234,7 +237,59 @@ The most noticeable is :attr:`wavelink.Queue.mode` which allows you to turn the - :attr:`wavelink.QueueMode.normal` means the queue will not loop at all. - :attr:`wavelink.QueueMode.loop_all` will loop every song in the history when the queue has been exhausted. -- :attr:`wavelink.QueueMode.loop` will loop the current track continuously until turned off or skipped via :meth:`wavelink.Player.skip` with `force=True`. +- :attr:`wavelink.QueueMode.loop` will loop the current track continuously until turned off or skipped via :meth:`wavelink.Player.skip` with ``force=True``. + + +Filters +======= +Version **3** has reworked the filters to hopefully be easier to use and feel more intuitive. + +See: :class:`~wavelink.Filters`. +See: :attr:`~wavelink.Player.filters` +See: :meth:`~wavelink.Player.set_filters` +See: :meth:`~wavelink.Player.play` + +**Some common recipes:** + +.. code:: python3 + + # Create a brand new Filters and apply it... + # You can use player.set_filters() for an easier way to reset. + filters: wavelink.Filters = wavelink.Filters() + await player.set_filters(filters) + + + # Retrieve the payload of any Filters instance... + filters: wavelink.Filters = player.filters + print(filters()) + + + # Set some filters... + # You can set and reset individual filters at the same time... + filters: wavelink.Filters = player.filters + filters.timescale.set(pitch=1.2, speed=1.1, rate=1) + filters.rotation.set(rotation_hz=0.2) + filters.equalizer.reset() + + await player.set_filters(filters) + + + # Reset a filter... + filters: wavelink.Filters = player.filters + filters.timescale.reset() + + await player.set_filters(filters) + + + # Reset all filters... + filters: wavelink.Filters = player.filters + filters.reset() + + await player.set_filters(filters) + + + # Reset and apply filters easier method... + await player.set_filters() Lavalink Plugins diff --git a/docs/wavelink.rst b/docs/wavelink.rst index a2e605fe..6bb48e99 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -251,12 +251,59 @@ Queue :members: :inherited-members: + Filters ------- -.. warning:: +.. attributetable:: Filters + +.. autoclass:: Filters + :members: + +.. attributetable:: Equalizer + +.. autoclass:: Equalizer + :members: + +.. attributetable:: Karaoke + +.. autoclass:: Karaoke + :members: + +.. attributetable:: Timescale + +.. autoclass:: Timescale + :members: + +.. attributetable:: Tremolo + +.. autoclass:: Tremolo + :members: - Filters have not yet been implemented, but are planned in the near future. +.. attributetable:: Vibrato + +.. autoclass:: Vibrato + :members: + +.. attributetable:: Rotation + +.. autoclass:: Rotation + :members: + +.. attributetable:: Distortion + +.. autoclass:: Distortion + :members: + +.. attributetable:: ChannelMix + +.. autoclass:: ChannelMix + :members: + +.. attributetable:: LowPass + +.. autoclass:: LowPass + :members: Exceptions From db661a78497c786d81aef4731d5330770d84b1f1 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 22:46:14 +1000 Subject: [PATCH 104/132] Bump version (Beta) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index acbdd118..a50b4d6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b15" +version = "3.0.0b16" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 82ba1894..55300645 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b15" +__version__ = "3.0.0b16" from .enums import * From ac6f428f3e511db1f053b246d2a2384514aa4ed6 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Wed, 8 Nov 2023 22:52:22 +1000 Subject: [PATCH 105/132] Add a filters example to simple.py --- examples/simple.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index 04ba5506..116de012 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -81,15 +81,21 @@ async def on_wavelink_track_start(self, payload: wavelink.TrackStartEventPayload @bot.command() async def play(ctx: commands.Context, *, query: str) -> None: """Play a song with the given query.""" - player: wavelink.Player - - try: - player = await ctx.author.voice.channel.connect(cls=wavelink.Player) - except discord.ClientException: - player = cast(wavelink.Player, ctx.voice_client) - except AttributeError: - await ctx.send("Please join a voice channel first.") + if not ctx.guild: return + + player: wavelink.Player + player = cast(wavelink.Player, ctx.voice_client) # type: ignore + + if not player: + try: + player = await ctx.author.voice.channel.connect(cls=wavelink.Player) # type: ignore + except AttributeError: + await ctx.send("Please join a voice channel first before using this command.") + return + except discord.ClientException: + await ctx.send("I was unable to join this voice channel. Please try again.") + return # Turn on AutoPlay to enabled mode. # enabled = AutoPlay will play songs for us and fetch recommendations... @@ -144,6 +150,20 @@ async def skip(ctx: commands.Context) -> None: await ctx.message.add_reaction("\u2705") +@bot.command() +async def nightcore(ctx: commands.Context) -> None: + """Set the filter to a nightcore style.""" + player: wavelink.Player = cast(wavelink.Player, ctx.voice_client) + if not player: + return + + filters: wavelink.Filters = player.filters + filters.timescale.set(pitch=1.2, speed=1.2, rate=1) + await player.set_filters(filters) + + await ctx.message.add_reaction("\u2705") + + @bot.command(name="toggle", aliases=["pause", "resume"]) async def pause_resume(ctx: commands.Context) -> None: """Pause or Resume the Player depending on its current state.""" From 664e1e734585e435f7079d2748ac11018fff51c9 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:39:56 +1000 Subject: [PATCH 106/132] Add GitHub actions --- .github/workflows/coverage_and_lint.yml | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/coverage_and_lint.yml diff --git a/.github/workflows/coverage_and_lint.yml b/.github/workflows/coverage_and_lint.yml new file mode 100644 index 00000000..4ba8f883 --- /dev/null +++ b/.github/workflows/coverage_and_lint.yml @@ -0,0 +1,55 @@ +name: Type Coverage and Linting + +on: + push: + branches: + - main + pull_request: + branches: + - main + types: [opened, reopened, synchronize] + +jobs: + check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.x"] + + name: "Type Coverage and Linting @ ${{ matrix.python-version }}" + steps: + - name: "Checkout Repository" + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: "Setup Python @ ${{ matrix.python-version }}" + id: setup-python + uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + cache: "pip" + + - name: "Install Python deps @ ${{ matrix.python-version }}" + id: install-deps + run: | + pip install -U -r requirements.txt + - name: "Run Pyright @ ${{ matrix.python-version }}" + uses: jakebailey/pyright-action@v1 + with: + no-comments: ${{ matrix.python-version != '3.x' }} + warnings: false + + - name: Lint + if: ${{ always() && steps.install-deps.outcome == 'success' }} + uses: github/super-linter/slim@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEFAULT_BRANCH: main + VALIDATE_ALL_CODEBASE: false + VALIDATE_PYTHON_BLACK: true + VALIDATE_PYTHON_ISORT: true + LINTER_RULES_PATH: / + PYTHON_ISORT_CONFIG_FILE: pyproject.toml + PYTHON_BLACK_CONFIG_FILE: pyproject.toml \ No newline at end of file From 480a77750fa436c502e06e5fb615bd60f826dcfa Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:41:45 +1000 Subject: [PATCH 107/132] Add status to NodeException. --- wavelink/exceptions.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py index e835fa6f..ad9830b4 100644 --- a/wavelink/exceptions.py +++ b/wavelink/exceptions.py @@ -51,7 +51,20 @@ class WavelinkException(Exception): class NodeException(WavelinkException): - """Error raised when an Unknown or Generic error occurs on a Node.""" + """Error raised when an Unknown or Generic error occurs on a Node. + + This exception may be raised when an error occurs reaching your Node. + + Attributes + ---------- + status: int | None + The status code received when making a request. Could be None. + """ + + def __init__(self, msg: str | None = None, status: int | None = None) -> None: + super().__init__(msg) + + self.status = status class InvalidClientException(WavelinkException): From 169a00f3a46ef0af6081017543dd7713f84eeed5 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:42:37 +1000 Subject: [PATCH 108/132] Raise NodeExcpetion in fatal requests. --- wavelink/node.py | 88 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 66 insertions(+), 22 deletions(-) diff --git a/wavelink/node.py b/wavelink/node.py index 9a3c745f..e985a12d 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -290,21 +290,13 @@ async def _connect(self, *, client: discord.Client | None) -> None: self._client = client_ - websocket: Websocket = Websocket(node=self) - self._websocket = websocket - await websocket.connect() - self._has_closed = False if not self._session or self._session.closed: self._session = aiohttp.ClientSession() - info: InfoResponse = await self._fetch_info() - if "spotify" in info["sourceManagers"]: - self._spotify_enabled = True - - if self._resume_timeout > 0: - udata: UpdateSessionRequest = {"resuming": True, "timeout": self._resume_timeout} - await self._update_session(data=udata) + websocket: Websocket = Websocket(node=self) + self._websocket = websocket + await websocket.connect() async def send( self, method: Method = "GET", *, path: str, data: Any | None = None, params: dict[str, Any] | None = None @@ -342,6 +334,9 @@ async def send( ------ LavalinkException An error occurred while making this request to Lavalink. + NodeException + An error occured while making this request to Lavalink, + and Lavalink was unable to send any error information. """ clean_path: str = path.removesuffix("/") uri: str = f"{self.uri}/{clean_path}" @@ -356,7 +351,12 @@ async def send( return if resp.status >= 300: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) try: @@ -382,7 +382,12 @@ async def _fetch_players(self) -> list[PlayerResponse]: return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _fetch_player(self, guild_id: int, /) -> PlayerResponse: @@ -394,7 +399,12 @@ async def _fetch_player(self, guild_id: int, /) -> PlayerResponse: return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _update_player(self, guild_id: int, /, *, data: Request, replace: bool = False) -> PlayerResponse: @@ -408,7 +418,12 @@ async def _update_player(self, guild_id: int, /, *, data: Request, replace: bool return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _destroy_player(self, guild_id: int, /) -> None: @@ -418,7 +433,12 @@ async def _destroy_player(self, guild_id: int, /) -> None: if resp.status == 204: return - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _update_session(self, *, data: UpdateSessionRequest) -> UpdateResponse: @@ -430,7 +450,12 @@ async def _update_session(self, *, data: UpdateSessionRequest) -> UpdateResponse return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _fetch_tracks(self, query: str) -> LoadedResponse: @@ -442,7 +467,12 @@ async def _fetch_tracks(self, query: str) -> LoadedResponse: return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _decode_track(self) -> TrackPayload: @@ -460,7 +490,12 @@ async def _fetch_info(self) -> InfoResponse: return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _fetch_stats(self) -> StatsResponse: @@ -472,7 +507,12 @@ async def _fetch_stats(self) -> StatsResponse: return resp_data else: - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) async def _fetch_version(self) -> str: @@ -482,7 +522,12 @@ async def _fetch_version(self) -> str: if resp.status == 200: return await resp.text() - exc_data: ErrorResponse = await resp.json() + try: + exc_data: ErrorResponse = await resp.json() + except Exception as e: + logger.warning(f"An error occured making a request on {self!r}: {e}") + raise NodeException(status=resp.status) + raise LavalinkException(data=exc_data) def get_player(self, guild_id: int, /) -> Player | None: @@ -697,7 +742,6 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: This method no longer accepts the ``cls`` parameter. """ - # TODO: Documentation Extension for `.. positional-only::` marker. encoded_query: str = cast(str, urllib.parse.quote(query)) # type: ignore if cls.__cache is not None: From 3069cabd4ede6c18839aaca846552bd568f1d7d0 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:43:14 +1000 Subject: [PATCH 109/132] Ensure we only update node when it's ready. --- wavelink/websocket.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 659bb379..5fec9997 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -39,6 +39,8 @@ if TYPE_CHECKING: from .node import Node from .player import Player + from .types.request import UpdateSessionRequest + from .types.response import InfoResponse from .types.state import PlayerState from .types.websocket import TrackExceptionPayload, WebsocketOP @@ -74,6 +76,15 @@ def headers(self) -> dict[str, str]: def is_connected(self) -> bool: return self.socket is not None and not self.socket.closed + async def _update_node(self) -> None: + if self.node._resume_timeout > 0: + udata: UpdateSessionRequest = {"resuming": True, "timeout": self.node._resume_timeout} + await self.node._update_session(data=udata) + + info: InfoResponse = await self.node._fetch_info() + if "spotify" in info["sourceManagers"]: + self.node._spotify_enabled = True + async def connect(self) -> None: self.node._status = NodeStatus.CONNECTING @@ -154,6 +165,8 @@ async def keep_alive(self) -> None: self.node._status = NodeStatus.CONNECTED self.node._session_id = session_id + + await self._update_node() ready_payload: NodeReadyEventPayload = NodeReadyEventPayload( node=self.node, resumed=resumed, session_id=session_id From 57584f73037c32fa29f0e4b733cb028609d0114d Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:44:25 +1000 Subject: [PATCH 110/132] Run black and isort. --- examples/simple.py | 6 +++--- wavelink/filters.py | 5 +++-- wavelink/player.py | 2 ++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/examples/simple.py b/examples/simple.py index 116de012..197949b3 100644 --- a/examples/simple.py +++ b/examples/simple.py @@ -83,10 +83,10 @@ async def play(ctx: commands.Context, *, query: str) -> None: """Play a song with the given query.""" if not ctx.guild: return - + player: wavelink.Player player = cast(wavelink.Player, ctx.voice_client) # type: ignore - + if not player: try: player = await ctx.author.voice.channel.connect(cls=wavelink.Player) # type: ignore @@ -160,7 +160,7 @@ async def nightcore(ctx: commands.Context) -> None: filters: wavelink.Filters = player.filters filters.timescale.set(pitch=1.2, speed=1.2, rate=1) await player.set_filters(filters) - + await ctx.message.add_reaction("\u2705") diff --git a/wavelink/filters.py b/wavelink/filters.py index 9a68b9fb..ebaf6947 100644 --- a/wavelink/filters.py +++ b/wavelink/filters.py @@ -278,10 +278,11 @@ def __repr__(self) -> str: class Tremolo: """The Tremolo Filter class. - - Uses amplification to create a shuddering effect, where the volume quickly oscillates. + + Uses amplification to create a shuddering effect, where the volume quickly oscillates. Demo: https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv """ + def __init__(self, payload: TremoloPayload) -> None: self._payload = payload diff --git a/wavelink/player.py b/wavelink/player.py index 2a16b0c6..abe9218e 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -689,11 +689,13 @@ async def set_filters(self, filters: Filters | None = None, /, *, seek: bool = F Whether to seek immediately when applying these filters. Seeking uses more resources, but applies the filters immediately. Defaults to ``False``. + .. versionchanged:: 3.0.0 This method now accepts a positional-only argument of filters, which now defaults to None. Filters were redesigned in this version, see: :class:`wavelink.Filters`. + .. versionchanged:: 3.0.0 This method was previously known as ``set_filter``. From 96d003c6e172d976e4176213813c72a5b5b622eb Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:50:21 +1000 Subject: [PATCH 111/132] Fix homepage docs --- docs/index.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 1a1cbabd..b3c26f62 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,8 +30,6 @@
    • For help with installing visit: Installing -
    • Your first steps with the library: Quickstart -
    • Frequently asked questions: FAQ

    API Reference

    From 0698616b4ea06dd6f2ae420fce2a0a34eb4cd5ee Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 02:53:26 +1000 Subject: [PATCH 112/132] Bump version (Release Candidate) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a50b4d6f..97489d07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0b16" +version = "3.0.0rc1" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 55300645..3a25ac79 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0b16" +__version__ = "3.0.0rc1" from .enums import * From 05dc4be4b401851b0078dbb2933f73095b3051cc Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 09:41:18 +1000 Subject: [PATCH 113/132] Remove voice protocol special member docs. --- docs/wavelink.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/wavelink.rst b/docs/wavelink.rst index 6bb48e99..caca9279 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -240,6 +240,7 @@ Player .. autoclass:: Player :members: + :exclude-members: on_voice_state_update, on_voice_server_update Queue From 7cf73a03d0337016e5044e248e282482ff20f1a3 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Thu, 9 Nov 2023 09:42:45 +1000 Subject: [PATCH 114/132] Some queue fixes. --- wavelink/player.py | 4 +++- wavelink/queue.py | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index abe9218e..424d88ca 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -193,6 +193,7 @@ async def _do_partial(self, *, history: bool = True) -> None: async def _do_recommendation(self): assert self.guild is not None + assert self.queue.history is not None and self.auto_queue.history is not None if len(self.auto_queue) > self._auto_cutoff + 1: track: Playable = self.auto_queue.get() @@ -285,7 +286,7 @@ async def _search(query: str | None) -> T_a: self.auto_queue.history.put(now) await self.play(now, add_history=False) - + # Possibly adjust these thresholds? history: list[Playable] = ( self.auto_queue[:40] + self.queue[:40] + self.queue.history[:-41:-1] + self.auto_queue.history[:-61:-1] @@ -630,6 +631,7 @@ async def play( self._paused = pause if add_history: + assert self.queue.history is not None self.queue.history.put(track) return track diff --git a/wavelink/queue.py b/wavelink/queue.py index a794758e..1bd569ee 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -166,11 +166,12 @@ class Queue(_Queue): def __init__(self, history: bool = True) -> None: super().__init__() + self.history: Queue | None = None if history: self._loaded: Playable | None = None self._mode: QueueMode = QueueMode.normal - self.history: Queue = Queue(history=False) + self.history = Queue(history=False) self._waiters: deque[asyncio.Future[None]] = deque() self._finished: asyncio.Event = asyncio.Event() @@ -195,8 +196,10 @@ def _wakeup_next(self) -> None: def _get(self) -> Playable: if self.mode is QueueMode.loop and self._loaded: return self._loaded - + if self.mode is QueueMode.loop_all and not self: + assert self.history is not None + self._queue.extend(self.history._queue) self.history.clear() From 3ca08161faf822c5d08a053d6fabead9d374efe2 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Fri, 10 Nov 2023 16:03:53 +1000 Subject: [PATCH 115/132] Add certain attrs back to history --- wavelink/queue.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index 1bd569ee..558d3a4b 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -169,10 +169,10 @@ def __init__(self, history: bool = True) -> None: self.history: Queue | None = None if history: - self._loaded: Playable | None = None - self._mode: QueueMode = QueueMode.normal self.history = Queue(history=False) - + + self._loaded: Playable | None = None + self._mode: QueueMode = QueueMode.normal self._waiters: deque[asyncio.Future[None]] = deque() self._finished: asyncio.Event = asyncio.Event() self._finished.set() From 5830f9be6c6d2c3eff9c5e9b25ee34bb34bfda63 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 18 Nov 2023 23:43:26 +1000 Subject: [PATCH 116/132] Fix LFU cache. --- wavelink/__init__.py | 2 +- wavelink/lfu.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 3a25ac79..2337d399 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -31,7 +31,7 @@ from .enums import * from .exceptions import * from .filters import * -from .lfu import CapacityZero as CapacityZero +from .lfu import CapacityZero as CapacityZero, LFUCache as LFUCache from .node import * from .payloads import * from .player import Player as Player diff --git a/wavelink/lfu.py b/wavelink/lfu.py index 46185db3..fe41a90a 100644 --- a/wavelink/lfu.py +++ b/wavelink/lfu.py @@ -128,7 +128,7 @@ def put(self, key: Any, value: Any) -> None: least_freq_key: DLLNode | None = least_freq.popleft() if least_freq_key: - self._cache.pop(least_freq_key) + self._cache.pop(least_freq_key.value) self._used -= 1 data: DataNode = DataNode(key=key, value=value, frequency=1, node=DLLNode(key)) From 7f4bcac4c6dcdf6ee1842db36922407fa77d184c Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sat, 18 Nov 2023 23:44:07 +1000 Subject: [PATCH 117/132] Bump Version (RC2) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 97489d07..e4bd4406 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0rc1" +version = "3.0.0rc2" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 2337d399..d79839f6 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0rc1" +__version__ = "3.0.0rc2" from .enums import * From 55106607278853f4cacae209003937684fee3de1 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 19 Nov 2023 10:18:46 +1000 Subject: [PATCH 118/132] Use YTM as the default search --- wavelink/tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 04f144fe..9350aa29 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -247,7 +247,7 @@ def recommended(self) -> bool: return self._recommended @classmethod - async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTube) -> Search: + async def search(cls, query: str, /, *, source: TrackSource | str | None = TrackSource.YouTubeMusic) -> Search: """Search for a list of :class:`~wavelink.Playable` or a :class:`~wavelink.Playlist`, with the given query. .. note:: From 17154deece8878c35374565ead5fe9b62c8545f3 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Sun, 19 Nov 2023 10:19:09 +1000 Subject: [PATCH 119/132] Add types to pyproject --- pyproject.toml | 2 +- wavelink/types/__init__.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 wavelink/types/__init__.py diff --git a/pyproject.toml b/pyproject.toml index e4bd4406..98d9d897 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ "Homepage" = "https://github.com/PythonistaGuild/Wavelink" [tool.setuptools] -packages = ["wavelink"] +packages = ["wavelink", "wavelink.types"] [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} diff --git a/wavelink/types/__init__.py b/wavelink/types/__init__.py new file mode 100644 index 00000000..f9837fff --- /dev/null +++ b/wavelink/types/__init__.py @@ -0,0 +1,23 @@ +""" +MIT License + +Copyright (c) 2019-Current PythonistaGuild, EvieePy + +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. +""" \ No newline at end of file From 94ea73e66d3916318af9d418e6306dff480721ab Mon Sep 17 00:00:00 2001 From: Thanos <111999343+Sachaa-Thanasius@users.noreply.github.com> Date: Sun, 26 Nov 2023 16:58:05 -0500 Subject: [PATCH 120/132] Clean up some typing syntax (#247) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * More consistency with typing for py3.10+ - Using ruff and stricter pyright settings. Will have to remove later. * Fix another union and eliminate an unnecessary cast. * Revert pyproject.toml configuration. - Leave two extra pyright settings on — they can't hurt, right? * Incorporate feedback and also change filter payload imports to relative. - Not as sure on the relative import change. In my opinion, this is a clearer indicator of where the imports are coming from, is more consistent with similar imports in other files, and doesn't run the risk of messing with an import of builtin types module, unlikely as that is to happen. Might be a matter of style though. * More minor explicit type-aliasing. --- pyproject.toml | 2 +- wavelink/backoff.py | 1 + wavelink/filters.py | 61 ++++++++++++------------ wavelink/lfu.py | 4 +- wavelink/node.py | 15 +++--- wavelink/player.py | 9 ++-- wavelink/queue.py | 13 ++--- wavelink/tracks.py | 5 +- wavelink/types/filters.py | 95 ++++++++++++++++++------------------- wavelink/types/request.py | 10 ++-- wavelink/types/state.py | 4 +- wavelink/types/websocket.py | 24 +++++----- 12 files changed, 124 insertions(+), 119 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 98d9d897..99eab574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ line-length = 120 profile = "black" [tool.pyright] -exclude = ["venv"] ignore = ["test*.py", "examples/*.py", "docs/*"] +pythonVersion = "3.10" typeCheckingMode = "strict" reportPrivateUsage = false diff --git a/wavelink/backoff.py b/wavelink/backoff.py index 5fac35bc..df0ccf2f 100644 --- a/wavelink/backoff.py +++ b/wavelink/backoff.py @@ -29,6 +29,7 @@ class Backoff: """An implementation of an Exponential Backoff. + Parameters ---------- base: int diff --git a/wavelink/filters.py b/wavelink/filters.py index ebaf6947..17871a74 100644 --- a/wavelink/filters.py +++ b/wavelink/filters.py @@ -23,20 +23,21 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, TypedDict +from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: - from types.filters import ChannelMix as ChannelMixPayload - from types.filters import Distortion as DistortionPayload - from types.filters import Equalizer as EqualizerPayload - from types.filters import FilterPayload - from types.filters import Karaoke as KaraokePayload - from types.filters import LowPass as LowPassPayload - from types.filters import Rotation as RotationPayload - from types.filters import Timescale as TimescalePayload - from types.filters import Tremolo as TremoloPayload - from types.filters import Vibrato as VibratoPayload - from typing import Self, Unpack + from typing_extensions import Self, Unpack + + from .types.filters import ChannelMix as ChannelMixPayload + from .types.filters import Distortion as DistortionPayload + from .types.filters import Equalizer as EqualizerPayload + from .types.filters import FilterPayload + from .types.filters import Karaoke as KaraokePayload + from .types.filters import LowPass as LowPassPayload + from .types.filters import Rotation as RotationPayload + from .types.filters import Timescale as TimescalePayload + from .types.filters import Tremolo as TremoloPayload + from .types.filters import Vibrato as VibratoPayload __all__ = ( @@ -69,36 +70,36 @@ class FiltersOptions(TypedDict, total=False): class EqualizerOptions(TypedDict): - bands: Optional[list[EqualizerPayload]] + bands: list[EqualizerPayload] | None class KaraokeOptions(TypedDict): - level: Optional[float] - mono_level: Optional[float] - filter_band: Optional[float] - filter_width: Optional[float] + level: float | None + mono_level: float | None + filter_band: float | None + filter_width: float | None class RotationOptions(TypedDict): - rotation_hz: Optional[float] + rotation_hz: float | None class DistortionOptions(TypedDict): - sin_offset: Optional[float] - sin_scale: Optional[float] - cos_offset: Optional[float] - cos_scale: Optional[float] - tan_offset: Optional[float] - tan_scale: Optional[float] - offset: Optional[float] - scale: Optional[float] + sin_offset: float | None + sin_scale: float | None + cos_offset: float | None + cos_scale: float | None + tan_offset: float | None + tan_scale: float | None + offset: float | None + scale: float | None class ChannelMixOptions(TypedDict): - left_to_left: Optional[float] - left_to_right: Optional[float] - right_to_left: Optional[float] - right_to_right: Optional[float] + left_to_left: float | None + left_to_right: float | None + right_to_left: float | None + right_to_right: float | None class Equalizer: diff --git a/wavelink/lfu.py b/wavelink/lfu.py index fe41a90a..d02b61c7 100644 --- a/wavelink/lfu.py +++ b/wavelink/lfu.py @@ -25,7 +25,7 @@ from collections import defaultdict from dataclasses import dataclass -from typing import Any, DefaultDict +from typing import Any from .exceptions import WavelinkException @@ -81,7 +81,7 @@ def __init__(self, *, capacity: int) -> None: self._capacity = capacity self._cache: dict[Any, DataNode] = {} - self._freq_map: DefaultDict[int, DLL] = defaultdict(DLL) + self._freq_map: defaultdict[int, DLL] = defaultdict(DLL) self._min: int = 1 self._used: int = 0 diff --git a/wavelink/node.py b/wavelink/node.py index e985a12d..bfe79cd7 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -25,8 +25,9 @@ import logging import secrets -import urllib -from typing import TYPE_CHECKING, Any, Iterable, Literal, Union, cast +import urllib.parse +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Literal, TypeAlias import aiohttp import discord @@ -63,9 +64,9 @@ ) from .types.tracks import TrackPayload - LoadedResponse = Union[ - TrackLoadedResponse, SearchLoadedResponse, PlaylistLoadedResponse, EmptyLoadedResponse, ErrorLoadedResponse - ] + LoadedResponse: TypeAlias = ( + TrackLoadedResponse | SearchLoadedResponse | PlaylistLoadedResponse | EmptyLoadedResponse | ErrorLoadedResponse + ) __all__ = ("Node", "Pool") @@ -742,7 +743,9 @@ async def fetch_tracks(cls, query: str, /) -> list[Playable] | Playlist: This method no longer accepts the ``cls`` parameter. """ - encoded_query: str = cast(str, urllib.parse.quote(query)) # type: ignore + + # TODO: Documentation Extension for `.. positional-only::` marker. + encoded_query: str = urllib.parse.quote(query) if cls.__cache is not None: potential: list[Playable] | Playlist = cls.__cache.get(encoded_query, None) diff --git a/wavelink/player.py b/wavelink/player.py index 424d88ca..a08dd2ae 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -27,9 +27,8 @@ import logging import random import time -import typing from collections import deque -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, TypeAlias import async_timeout import discord @@ -46,7 +45,7 @@ LavalinkLoadException, QueueEmpty, ) -from .filters import * +from .filters import Filters from .node import Pool from .payloads import PlayerUpdateEventPayload, TrackEndEventPayload from .queue import Queue @@ -61,12 +60,12 @@ from .types.request import Request as RequestPayload from .types.state import PlayerVoiceState, VoiceState - VocalGuildChannel = Union[discord.VoiceChannel, discord.StageChannel] + VocalGuildChannel = discord.VoiceChannel | discord.StageChannel logger: logging.Logger = logging.getLogger(__name__) -T_a: typing.TypeAlias = "list[Playable] | Playlist" +T_a: TypeAlias = list[Playable] | Playlist class Player(discord.VoiceProtocol): diff --git a/wavelink/queue.py b/wavelink/queue.py index 558d3a4b..6d8d4461 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -26,11 +26,12 @@ import asyncio import random from collections import deque -from typing import Any, Iterator, overload +from collections.abc import Iterator +from typing import overload from .enums import QueueMode from .exceptions import QueueEmpty -from .tracks import * +from .tracks import Playable, Playlist __all__ = ("Queue",) @@ -71,11 +72,11 @@ def __getitem__(self, index: int | slice) -> Playable | list[Playable]: def __iter__(self) -> Iterator[Playable]: return self._queue.__iter__() - def __contains__(self, item: Any) -> bool: + def __contains__(self, item: object) -> bool: return item in self._queue @staticmethod - def _check_compatability(item: Any) -> None: + def _check_compatability(item: object) -> None: if not isinstance(item, Playable): raise TypeError("This queue is restricted to Playable objects.") @@ -311,7 +312,7 @@ async def put_wait(self, item: list[Playable] | Playable | Playlist, /, *, atomi added: int = 0 async with self._lock: - if isinstance(item, (list, Playlist)): + if isinstance(item, list | Playlist): if atomic: super()._check_atomic(item) @@ -397,7 +398,7 @@ def mode(self) -> QueueMode: return self._mode @mode.setter - def mode(self, value: QueueMode): + def mode(self, value: QueueMode) -> None: if not hasattr(self, "_mode"): raise AttributeError("This queues mode can not be set.") diff --git a/wavelink/tracks.py b/wavelink/tracks.py index 9350aa29..aa782110 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -23,7 +23,8 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterator, TypeAlias, overload +from collections.abc import Iterator +from typing import TYPE_CHECKING, Any, TypeAlias, overload import yarl @@ -482,7 +483,7 @@ def __contains__(self, item: Playable) -> bool: def pop(self, index: int = -1) -> Playable: return self.tracks.pop(index) - def track_extras(self, **attrs: Any) -> None: + def track_extras(self, **attrs: object) -> None: """Method which sets attributes to all :class:`Playable` in this playlist, with the provided keyword arguments. This is useful when you need to attach state to your :class:`Playable`, E.g. create a requester attribute. diff --git a/wavelink/types/filters.py b/wavelink/types/filters.py index c375764d..08a07f51 100644 --- a/wavelink/types/filters.py +++ b/wavelink/types/filters.py @@ -21,10 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import TYPE_CHECKING, Any, Optional, TypedDict - -if TYPE_CHECKING: - from typing_extensions import NotRequired +from typing import Any, TypedDict class Equalizer(TypedDict): @@ -32,64 +29,64 @@ class Equalizer(TypedDict): gain: float -class Karaoke(TypedDict): - level: NotRequired[Optional[float]] - monoLevel: NotRequired[Optional[float]] - filterBand: NotRequired[Optional[float]] - filterWidth: NotRequired[Optional[float]] +class Karaoke(TypedDict, total=False): + level: float | None + monoLevel: float | None + filterBand: float | None + filterWidth: float | None -class Timescale(TypedDict): - speed: NotRequired[Optional[float]] - pitch: NotRequired[Optional[float]] - rate: NotRequired[Optional[float]] +class Timescale(TypedDict, total=False): + speed: float | None + pitch: float | None + rate: float | None -class Tremolo(TypedDict): - frequency: NotRequired[Optional[float]] - depth: NotRequired[Optional[float]] +class Tremolo(TypedDict, total=False): + frequency: float | None + depth: float | None -class Vibrato(TypedDict): - frequency: NotRequired[Optional[float]] - depth: NotRequired[Optional[float]] +class Vibrato(TypedDict, total=False): + frequency: float | None + depth: float | None -class Rotation(TypedDict): - rotationHz: NotRequired[Optional[float]] +class Rotation(TypedDict, total=False): + rotationHz: float | None -class Distortion(TypedDict): - sinOffset: NotRequired[Optional[float]] - sinScale: NotRequired[Optional[float]] - cosOffset: NotRequired[Optional[float]] - cosScale: NotRequired[Optional[float]] - tanOffset: NotRequired[Optional[float]] - tanScale: NotRequired[Optional[float]] - offset: NotRequired[Optional[float]] - scale: NotRequired[Optional[float]] +class Distortion(TypedDict, total=False): + sinOffset: float | None + sinScale: float | None + cosOffset: float | None + cosScale: float | None + tanOffset: float | None + tanScale: float | None + offset: float | None + scale: float | None -class ChannelMix(TypedDict): - leftToLeft: NotRequired[Optional[float]] - leftToRight: NotRequired[Optional[float]] - rightToLeft: NotRequired[Optional[float]] - rightToRight: NotRequired[Optional[float]] +class ChannelMix(TypedDict, total=False): + leftToLeft: float | None + leftToRight: float | None + rightToLeft: float | None + rightToRight: float | None -class LowPass(TypedDict): - smoothing: NotRequired[Optional[float]] +class LowPass(TypedDict, total=False): + smoothing: float | None -class FilterPayload(TypedDict): - volume: NotRequired[Optional[float]] - equalizer: NotRequired[Optional[list[Equalizer]]] - karaoke: NotRequired[Karaoke] - timescale: NotRequired[Timescale] - tremolo: NotRequired[Tremolo] - vibrato: NotRequired[Vibrato] - rotation: NotRequired[Rotation] - distortion: NotRequired[Distortion] - channelMix: NotRequired[ChannelMix] - lowPass: NotRequired[LowPass] - pluginFilters: NotRequired[dict[str, Any]] +class FilterPayload(TypedDict, total=False): + volume: float | None + equalizer: list[Equalizer] | None + karaoke: Karaoke + timescale: Timescale + tremolo: Tremolo + vibrato: Vibrato + rotation: Rotation + distortion: Distortion + channelMix: ChannelMix + lowPass: LowPass + pluginFilters: dict[str, Any] diff --git a/wavelink/types/request.py b/wavelink/types/request.py index 2d1aa269..5325eb5f 100644 --- a/wavelink/types/request.py +++ b/wavelink/types/request.py @@ -23,24 +23,24 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, TypedDict +from typing import TYPE_CHECKING, TypeAlias, TypedDict if TYPE_CHECKING: - from typing_extensions import NotRequired, TypeAlias + from typing_extensions import NotRequired from .filters import FilterPayload class VoiceRequest(TypedDict): token: str - endpoint: Optional[str] + endpoint: str | None sessionId: str class _BaseRequest(TypedDict, total=False): voice: VoiceRequest position: int - endTime: Optional[int] + endTime: int | None volume: int paused: bool filters: FilterPayload @@ -59,4 +59,4 @@ class UpdateSessionRequest(TypedDict): timeout: NotRequired[int] -Request: TypeAlias = "_BaseRequest | EncodedTrackRequest | IdentifierRequest" +Request: TypeAlias = _BaseRequest | EncodedTrackRequest | IdentifierRequest diff --git a/wavelink/types/state.py b/wavelink/types/state.py index 25fd4220..03f09269 100644 --- a/wavelink/types/state.py +++ b/wavelink/types/state.py @@ -21,7 +21,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import TYPE_CHECKING, Optional, TypedDict +from typing import TYPE_CHECKING, TypedDict if TYPE_CHECKING: from typing_extensions import NotRequired @@ -36,7 +36,7 @@ class PlayerState(TypedDict): class VoiceState(TypedDict, total=False): token: str - endpoint: Optional[str] + endpoint: str | None session_id: str diff --git a/wavelink/types/websocket.py b/wavelink/types/websocket.py index 661cf6c1..f5bb9d02 100644 --- a/wavelink/types/websocket.py +++ b/wavelink/types/websocket.py @@ -21,7 +21,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import TYPE_CHECKING, Literal, TypedDict, Union +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict if TYPE_CHECKING: from typing_extensions import NotRequired @@ -99,13 +101,13 @@ class WebsocketClosedEvent(TypedDict): byRemote: bool -WebsocketOP = Union[ - ReadyOP, - PlayerUpdateOP, - StatsOP, - TrackStartEvent, - TrackEndEvent, - TrackExceptionEvent, - TrackStuckEvent, - WebsocketClosedEvent, -] +WebsocketOP: TypeAlias = ( + ReadyOP + | PlayerUpdateOP + | StatsOP + | TrackStartEvent + | TrackEndEvent + | TrackExceptionEvent + | TrackStuckEvent + | WebsocketClosedEvent +) From 1656be1970416a284c71e2fa8cb056a21f42c2a6 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:27:56 +1000 Subject: [PATCH 121/132] Add move_to to player. --- wavelink/player.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index a08dd2ae..8830089e 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -285,7 +285,7 @@ async def _search(query: str | None) -> T_a: self.auto_queue.history.put(now) await self.play(now, add_history=False) - + # Possibly adjust these thresholds? history: list[Playable] = ( self.auto_queue[:40] + self.queue[:40] + self.queue.history[:-41:-1] + self.auto_queue.history[:-61:-1] @@ -488,7 +488,7 @@ async def _dispatch_voice_update(self) -> None: logger.debug(f"Player {self.guild.id} is dispatching VOICE_UPDATE.") async def connect( - self, *, timeout: float = 5.0, reconnect: bool, self_deaf: bool = False, self_mute: bool = False + self, *, timeout: float = 10.0, reconnect: bool, self_deaf: bool = False, self_mute: bool = False ) -> None: """ @@ -525,6 +525,41 @@ async def connect( msg = f"Unable to connect to {self.channel} as it exceeded the timeout of {timeout} seconds." raise ChannelTimeoutException(msg) + async def move_to( + self, channel: VocalGuildChannel | None, *, timeout: float = 10.0, self_deaf: bool = False, self_mute: bool = False + ) -> None: + """Method to move the player to another channel. + + Parameters + ---------- + channel: :class:`discord.VoiceChannel` | :class:`discord.StageChannel` + The new channel to move to. + timeout: float + The timeout in ``seconds`` before raising. Defaults to 10.0. + + Raises + ------ + ChannelTimeoutException + Connecting to the voice channel timed out. + InvalidChannelStateException + You tried to connect this player without an appropriate guild. + """ + if not self.guild: + raise InvalidChannelStateException(f"Player tried to move without a valid guild.") + + self._connection_event.clear() + await self.guild.change_voice_state(channel=channel) + + if channel is None: + return + + try: + async with async_timeout.timeout(timeout): + await self._connection_event.wait() + except (asyncio.TimeoutError, asyncio.CancelledError): + msg = f"Unable to connect to {channel} as it exceeded the timeout of {timeout} seconds." + raise ChannelTimeoutException(msg) + async def play( self, track: Playable, From cfba47fc1ce0db0d2cdc856de3ca5d4db12e75cb Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:28:30 +1000 Subject: [PATCH 122/132] Run black --- wavelink/queue.py | 6 +++--- wavelink/types/__init__.py | 2 +- wavelink/websocket.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/wavelink/queue.py b/wavelink/queue.py index 6d8d4461..3c04307f 100644 --- a/wavelink/queue.py +++ b/wavelink/queue.py @@ -171,7 +171,7 @@ def __init__(self, history: bool = True) -> None: if history: self.history = Queue(history=False) - + self._loaded: Playable | None = None self._mode: QueueMode = QueueMode.normal self._waiters: deque[asyncio.Future[None]] = deque() @@ -197,10 +197,10 @@ def _wakeup_next(self) -> None: def _get(self) -> Playable: if self.mode is QueueMode.loop and self._loaded: return self._loaded - + if self.mode is QueueMode.loop_all and not self: assert self.history is not None - + self._queue.extend(self.history._queue) self.history.clear() diff --git a/wavelink/types/__init__.py b/wavelink/types/__init__.py index f9837fff..6fac424b 100644 --- a/wavelink/types/__init__.py +++ b/wavelink/types/__init__.py @@ -20,4 +20,4 @@ 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. -""" \ No newline at end of file +""" diff --git a/wavelink/websocket.py b/wavelink/websocket.py index 5fec9997..96222563 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -165,7 +165,7 @@ async def keep_alive(self) -> None: self.node._status = NodeStatus.CONNECTED self.node._session_id = session_id - + await self._update_node() ready_payload: NodeReadyEventPayload = NodeReadyEventPayload( From b60b53d98b92ce1413c80581639d904b9461b09c Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:28:48 +1000 Subject: [PATCH 123/132] Change documentation --- wavelink/tracks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/tracks.py b/wavelink/tracks.py index aa782110..42283a86 100644 --- a/wavelink/tracks.py +++ b/wavelink/tracks.py @@ -279,7 +279,7 @@ async def search(cls, query: str, /, *, source: TrackSource | str | None = Track Otherwise, a ``str`` may be provided for plugin based searches, E.g. "spsearch:" for the LavaSrc Spotify based search. - Defaults to :attr:`wavelink.TrackSource.YouTube` which is equivalent to "ytsearch:". + Defaults to :attr:`wavelink.TrackSource.YouTubeMusic` which is equivalent to "ytmsearch:". Returns From 8d69d9d78168c75f6a43b7bc7cd66d03379c6042 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:30:41 +1000 Subject: [PATCH 124/132] Update README --- README.rst | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/README.rst b/README.rst index 3fe7150a..66fb365a 100644 --- a/README.rst +++ b/README.rst @@ -28,8 +28,10 @@ Wavelink is a robust and powerful Lavalink wrapper for `Discord.py `_ +Migrating from Version 2 to Version 3: +###################################### + +`Migrating Guide `_ **Features:** @@ -43,11 +45,11 @@ Wavelink features a fully asynchronous API that's intuitive and easy to use. Documentation ---------------------------- -`Official Documentation `_ +------------- +`Official Documentation `_ Support ---------------------------- +------- For support using WaveLink, please join the official `support server `_ on `Discord `_. @@ -56,32 +58,41 @@ For support using WaveLink, please join the official `support server Installation ---------------------------- +------------ **WaveLink 3 requires Python 3.10+** **Windows** .. code:: sh - py -3.10 -m pip install -U wavelink --pre + py -3.10 -m pip install -U wavelink **Linux** .. code:: sh - python3.10 -m pip install -U wavelink --pre + python3.10 -m pip install -U wavelink **Virtual Environments** .. code:: sh - pip install -U wavelink --pre + pip install -U wavelink Getting Started ----------------------------- +--------------- + +**See Examples:** `Examples `_ + + +Lavalink +-------- + +Wavelink **3** requires **Lavalink v4**. +See: `Lavalink `_ -**See Examples:** `Examples `_ +For spotify support, simply install and use `Lavalink `_ with your `wavelink.Playable` Notes From 3f6350caf9cd45b9a49fee9ab5b09721a394de98 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:32:21 +1000 Subject: [PATCH 125/132] Bump version (Release) --- pyproject.toml | 2 +- wavelink/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 99eab574..93c48007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wavelink" -version = "3.0.0rc2" +version = "3.0.0" authors = [ { name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" }, ] diff --git a/wavelink/__init__.py b/wavelink/__init__.py index d79839f6..09241816 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -25,7 +25,7 @@ __author__ = "PythonistaGuild, EvieePy" __license__ = "MIT" __copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy" -__version__ = "3.0.0rc2" +__version__ = "3.0.0" from .enums import * From 417ce4732e4f2221180e8547f5cba3f622873c54 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:34:50 +1000 Subject: [PATCH 126/132] Fix typo in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 66fb365a..2c39b902 100644 --- a/README.rst +++ b/README.rst @@ -92,7 +92,7 @@ Lavalink Wavelink **3** requires **Lavalink v4**. See: `Lavalink `_ -For spotify support, simply install and use `Lavalink `_ with your `wavelink.Playable` +For spotify support, simply install and use `LavaSrc `_ with your `wavelink.Playable` Notes From 1d973b40221a16fef7d6458fdab03a7fe744d02b Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 18:42:31 +1000 Subject: [PATCH 127/132] Run black --- wavelink/player.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index 8830089e..2b5f8a4e 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -526,7 +526,12 @@ async def connect( raise ChannelTimeoutException(msg) async def move_to( - self, channel: VocalGuildChannel | None, *, timeout: float = 10.0, self_deaf: bool = False, self_mute: bool = False + self, + channel: VocalGuildChannel | None, + *, + timeout: float = 10.0, + self_deaf: bool = False, + self_mute: bool = False, ) -> None: """Method to move the player to another channel. @@ -549,7 +554,7 @@ async def move_to( self._connection_event.clear() await self.guild.change_voice_state(channel=channel) - + if channel is None: return From ec0c5e8dbfcf8bb19de76df2b625ec4d8bfa847e Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 19:11:09 +1000 Subject: [PATCH 128/132] One new weird pyright error? --- wavelink/player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/player.py b/wavelink/player.py index 2b5f8a4e..ff5d9255 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -851,7 +851,7 @@ async def _destroy(self) -> None: assert self.guild self._invalidate() - player: Self | None = self.node._players.pop(self.guild.id, None) + player: Player | None = self.node._players.pop(self.guild.id, None) if player: try: From 5f64fd80c00220b59fe9998d5b090c48c754dfda Mon Sep 17 00:00:00 2001 From: EvieePy Date: Mon, 27 Nov 2023 19:14:41 +1000 Subject: [PATCH 129/132] Run isort --- wavelink/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wavelink/__init__.py b/wavelink/__init__.py index 09241816..a92a252e 100644 --- a/wavelink/__init__.py +++ b/wavelink/__init__.py @@ -31,7 +31,8 @@ from .enums import * from .exceptions import * from .filters import * -from .lfu import CapacityZero as CapacityZero, LFUCache as LFUCache +from .lfu import CapacityZero as CapacityZero +from .lfu import LFUCache as LFUCache from .node import * from .payloads import * from .player import Player as Player From 7e948f560e30bb45ea4f9e2f8ea9e4bcc9787fb5 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Tue, 28 Nov 2023 04:07:09 +1000 Subject: [PATCH 130/132] Add self_deaf/mute to move_to --- wavelink/player.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/wavelink/player.py b/wavelink/player.py index ff5d9255..c8870ddd 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -530,8 +530,8 @@ async def move_to( channel: VocalGuildChannel | None, *, timeout: float = 10.0, - self_deaf: bool = False, - self_mute: bool = False, + self_deaf: bool | None = None, + self_mute: bool | None = None, ) -> None: """Method to move the player to another channel. @@ -541,6 +541,12 @@ async def move_to( The new channel to move to. timeout: float The timeout in ``seconds`` before raising. Defaults to 10.0. + self_deaf: bool | None + Whether to deafen when moving. Defaults to ``None`` which keeps the current setting or ``False`` + if they can not be determined. + self_mute: bool | None + Whether to self mute when moving. Defaults to ``None`` which keeps the current setting or ``False`` + if they can not be determined. Raises ------ @@ -553,7 +559,18 @@ async def move_to( raise InvalidChannelStateException(f"Player tried to move without a valid guild.") self._connection_event.clear() - await self.guild.change_voice_state(channel=channel) + voice: discord.VoiceState | None = self.guild.me.voice + + if self_deaf is None and voice: + self_deaf = voice.self_deaf + + if self_mute is None and voice: + self_mute = voice.self_mute + + self_deaf = bool(self_deaf) + self_mute = bool(self_mute) + + await self.guild.change_voice_state(channel=channel, self_mute=self_mute, self_deaf=self_deaf) if channel is None: return From d37f3b38f36c645e48f7a5a99380847bdae1edd7 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Tue, 28 Nov 2023 06:52:29 +1000 Subject: [PATCH 131/132] Remove unnecessary f-string --- wavelink/player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wavelink/player.py b/wavelink/player.py index c8870ddd..6d0fb59a 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -556,7 +556,7 @@ async def move_to( You tried to connect this player without an appropriate guild. """ if not self.guild: - raise InvalidChannelStateException(f"Player tried to move without a valid guild.") + raise InvalidChannelStateException("Player tried to move without a valid guild.") self._connection_event.clear() voice: discord.VoiceState | None = self.guild.me.voice From fa239579f3b7fc49b9297f8ce5cba0fdc3167245 Mon Sep 17 00:00:00 2001 From: EvieePy Date: Tue, 28 Nov 2023 07:22:43 +1000 Subject: [PATCH 132/132] Publish to PyPi workflow --- .github/workflows/build_and_publish.yml | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/build_and_publish.yml diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml new file mode 100644 index 00000000..f6a49e29 --- /dev/null +++ b/.github/workflows/build_and_publish.yml @@ -0,0 +1,50 @@ +name: Publish to PyPi @ Release + +on: + release: + types: [published] + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build source and wheels + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v3 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: Upload to PyPi + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/Wavelink + permissions: + id-token: write + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution to PyPi + uses: pypa/gh-action-pypi-publish@release/v1